Преглед на файлове

[Feat 0000] AI助手添加会话消息滚动条预览、定位功能

wangkeyi преди 1 седмица
родител
ревизия
80c26191d0
променени са 1 файла, в които са добавени 297 реда и са изтрити 2 реда
  1. 297 2
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

+ 297 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -1,5 +1,36 @@
 <template>
   <div class="chat-messages-container">
+    <!-- 左侧对话缩略导航 -->
+    <div
+      v-if="qaPairs.length > 0"
+      class="conversation-nav"
+      ref="navRef"
+      @mouseenter="onNavMove"
+      @mousemove="onNavMove"
+      @mouseleave="onNavLeave"
+      @scroll="onNavScroll"
+    >
+      <div
+        v-for="(pair, pIdx) in qaPairs"
+        :key="pIdx"
+        :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
+        :style="{ width: `${navLineWidth(pIdx)}px` }"
+        @click="scrollToPair(pair)"
+      ></div>
+    </div>
+    <Transition name="tooltip-fade">
+      <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
+        <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
+        <div v-if="hoveredPair.question" class="tooltip-row">
+          <span class="tooltip-label tooltip-label-q">问</span>
+          <span class="tooltip-text">{{ hoveredPair.question }}</span>
+        </div>
+        <div v-if="hoveredPair.answer" class="tooltip-row">
+          <span class="tooltip-label tooltip-label-a">答</span>
+          <span class="tooltip-text">{{ hoveredPair.answer }}</span>
+        </div>
+      </div>
+    </Transition>
     <div class="chat-messages" ref="messagesRef">
       <!-- 新对话缺省图 -->
       <div v-if="messages.length === 0" class="welcome-empty">
@@ -305,7 +336,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
+  import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
   import {
     renderMarkdown,
@@ -790,6 +821,146 @@
     }
   };
 
+  // ===== 左侧对话缩略导航 =====
+  interface QaPair {
+    startIndex: number;
+    question: string;
+    answer: string;
+  }
+
+  const toPlainText = (md: string): string =>
+    md
+      .replace(/```[\s\S]*?```/g, ' [代码] ')
+      .replace(/`[^`]*`/g, ' ')
+      .replace(/<[^>]+>/g, '')
+      .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+      .replace(/^#{1,6}\s*/gm, '')
+      .replace(/[*_~>|]/g, '')
+      .replace(/\s+/g, ' ')
+      .trim();
+
+  const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
+
+  // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
+  const qaPairs = computed<QaPair[]>(() => {
+    const pairs: QaPair[] = [];
+    let current: QaPair | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'user') {
+        if (current) pairs.push(current);
+        current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
+      } else {
+        if (!current) current = { startIndex: index, question: '', answer: '' };
+        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
+        if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
+      }
+    });
+    if (current) pairs.push(current);
+    return pairs;
+  });
+
+  const navRef = ref<HTMLElement>();
+  const tooltipRef = ref<HTMLElement>();
+  const hoveredPair = ref<QaPair | null>(null);
+  const tooltipTop = ref(0);
+  const tooltipArrowTop = ref(0);
+  const activeMessage = ref<Message | null>(null);
+
+  // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
+  const navMouseY = ref<number | null>(null);
+  // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
+  const lineCenters = ref<number[]>([]);
+
+  const updateLineCenters = () => {
+    const nav = navRef.value;
+    if (!nav) {
+      lineCenters.value = [];
+      return;
+    }
+    const centers: number[] = [];
+    nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
+    lineCenters.value = centers;
+  };
+
+  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形
+  const navLineWidth = (pIdx: number): number => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return 10;
+    const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
+    return 10 + 8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
+  };
+
+  const nearestLineIndex = computed(() => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return -1;
+    let best = -1;
+    let bestDist = Infinity;
+    lineCenters.value.forEach((center, i) => {
+      const dist = Math.abs(center - mouseY);
+      if (dist < bestDist) {
+        bestDist = dist;
+        best = i;
+      }
+    });
+    return best;
+  });
+
+  let lastTooltipIndex = -1;
+
+  const applyTooltip = async (pIdx: number) => {
+    const pair = qaPairs.value[pIdx];
+    if (!pair) {
+      hoveredPair.value = null;
+      return;
+    }
+    hoveredPair.value = pair;
+    await nextTick();
+    const tooltip = tooltipRef.value;
+    const nav = navRef.value;
+    const container = nav?.parentElement as HTMLElement | null;
+    if (!tooltip || !nav || !container) return;
+    const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
+    const tooltipH = tooltip.offsetHeight;
+    const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
+    tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
+    tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
+  };
+
+  const onNavMove = (e: MouseEvent) => {
+    const nav = navRef.value;
+    if (!nav) return;
+    // 横线数量变化(新增问答/首次悬浮)时重建坐标
+    if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
+    navMouseY.value = e.clientY - nav.getBoundingClientRect().top + nav.scrollTop;
+    const nearest = nearestLineIndex.value;
+    if (nearest >= 0 && nearest !== lastTooltipIndex) {
+      lastTooltipIndex = nearest;
+      applyTooltip(nearest);
+    }
+  };
+
+  const onNavLeave = () => {
+    navMouseY.value = null;
+    hoveredPair.value = null;
+    lastTooltipIndex = -1;
+  };
+
+  const onNavScroll = () => {
+    updateLineCenters();
+    if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
+  };
+
+  const scrollToPair = (pair: QaPair) => {
+    activeMessage.value = props.messages[pair.startIndex];
+    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
+    const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
+    if (!el) return;
+    el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    el.classList.add('nav-located-highlight');
+    setTimeout(() => el.classList.remove('nav-located-highlight'), 1500);
+  };
+
   defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, initAskUserAnswers });
 </script>
 
@@ -798,10 +969,129 @@
     flex: 1;
     position: relative;
     display: flex;
-    flex-direction: column;
+    flex-direction: row;
     overflow: hidden;
   }
 
+  .conversation-nav {
+    position: relative;
+    width: 28px;
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    overflow-y: auto;
+    padding: 6px 0;
+    scrollbar-width: none;
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    .nav-line {
+      width: 10px;
+      height: 3px;
+      border-radius: 2px;
+      background: rgba(139, 148, 158, 0.4);
+      margin: 5px 0;
+      flex-shrink: 0;
+      cursor: pointer;
+      transition:
+        width 0.15s ease-out,
+        background 0.2s;
+
+      &:first-child {
+        margin-top: auto;
+      }
+
+      &:last-child {
+        margin-bottom: auto;
+      }
+
+      &.nav-line-hot {
+        background: #0a84ff;
+      }
+
+      &.active {
+        background: #0a84ff;
+      }
+    }
+  }
+
+  .conversation-tooltip {
+    position: absolute;
+    left: 36px;
+    width: 260px;
+    background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+    padding: 10px 12px;
+    z-index: 20;
+    pointer-events: none;
+
+    .tooltip-arrow {
+      position: absolute;
+      left: -6px;
+      width: 10px;
+      height: 10px;
+      transform: rotate(45deg);
+      background: #0d2137;
+      border-left: 1px solid #1a4a6f;
+      border-bottom: 1px solid #1a4a6f;
+    }
+
+    .tooltip-row {
+      display: flex;
+      align-items: flex-start;
+      gap: 8px;
+      font-size: 12px;
+      line-height: 1.5;
+
+      & + .tooltip-row {
+        margin-top: 8px;
+        padding-top: 8px;
+        border-top: 1px solid rgba(63, 80, 106, 0.3);
+      }
+
+      .tooltip-label {
+        flex-shrink: 0;
+        width: 16px;
+        height: 16px;
+        line-height: 16px;
+        margin-top: 1px;
+        text-align: center;
+        border-radius: 3px;
+        font-size: 11px;
+
+        &-q {
+          color: #79c0ff;
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &-a {
+          color: #8b949e;
+          background: rgba(139, 148, 158, 0.15);
+        }
+      }
+
+      .tooltip-text {
+        color: #c9d1d9;
+        word-break: break-word;
+      }
+    }
+  }
+
+  .tooltip-fade-enter-active,
+  .tooltip-fade-leave-active {
+    transition: opacity 0.15s ease;
+  }
+
+  .tooltip-fade-enter-from,
+  .tooltip-fade-leave-to {
+    opacity: 0;
+  }
+
   .chat-messages {
     flex: 1;
     overflow-y: auto;
@@ -827,6 +1117,11 @@
     .message-wrapper {
       // margin-bottom: 20px;
 
+      &.nav-located-highlight {
+        background: rgba(10, 132, 255, 0.08);
+        border-radius: 6px;
+      }
+
       .ai-message {
         display: flex;
         flex-wrap: wrap;