|
|
@@ -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;
|