diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 3add3b9e..15af7650 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -1,12 +1,14 @@ @@ -2128,6 +2160,155 @@ function getGeneratingLetters(message: any) { return Array.from(label); } +// ===== 左侧用户输入快捷跳转导航 ===== +// 数据口径与深度压缩一致:role === 'user' 且 metadata.message_source === 'user'(引导/通知/压缩等不计入) +const shellRef = ref(null); +const virtualizerRef = ref(null); +const quickNavActiveIndex = ref(-1); +const quickNavLineEls = new Map(); + +const isRealUserNavMessage = (msg: any): boolean => { + if (!msg || msg.role !== 'user') { + return false; + } + return String(msg?.metadata?.message_source || 'user').trim().toLowerCase() === 'user'; +}; + +function extractUserFirstLine(msg: any): string { + const content = msg?.content; + let text = ''; + if (typeof content === 'string') { + text = content; + } else if (Array.isArray(content)) { + text = content + .filter((it: any) => it && it.type === 'text' && typeof it.text === 'string') + .map((it: any) => it.text) + .join('\n'); + } else if (content != null) { + text = String(content); + } + const lines = text + .split('\n') + .map((l: string) => l.trim()) + .filter((l: string) => l && !l.startsWith('[系统通知|')); + return lines[0] || '(空输入)'; +} + +function extractAssistantLastText(msg: any): string { + const actions = Array.isArray(msg?.actions) ? msg.actions : []; + for (let i = actions.length - 1; i >= 0; i -= 1) { + const action = actions[i]; + if (action?.type === 'text' && typeof action?.content === 'string' && action.content.trim()) { + return action.content.trim(); + } + } + const content = msg?.content; + return typeof content === 'string' ? content.trim() : ''; +} + +const quickNavItems = computed(() => { + const msgs = getFilteredMessagesSafe(); + const userIndexes: number[] = []; + for (let i = 0; i < msgs.length; i += 1) { + if (isRealUserNavMessage(msgs[i])) { + userIndexes.push(i); + } + } + return userIndexes.map((msgIndex, k) => { + // 该输入所产生的最后一次输出:下一条真实用户输入之前、最后一条 assistant 消息的最终文本 + const end = k + 1 < userIndexes.length ? userIndexes[k + 1] : msgs.length; + let aiText = ''; + for (let i = end - 1; i > msgIndex; i -= 1) { + const m = msgs[i]; + if (!m || m.role !== 'assistant') { + continue; + } + const text = extractAssistantLastText(m); + if (text) { + aiText = text; + break; + } + } + return { + msgIndex, + userText: extractUserFirstLine(msgs[msgIndex]), + aiText: aiText || '(暂无回复)' + }; + }); +}); + +const QUICK_NAV_LINE_WIDTHS = [34, 25, 17, 12]; +const QUICK_NAV_LINE_BASE_WIDTH = 8; + +function quickNavLineWidth(index: number): number { + const active = quickNavActiveIndex.value; + if (active < 0) { + return QUICK_NAV_LINE_BASE_WIDTH; + } + const dist = Math.abs(index - active); + return QUICK_NAV_LINE_WIDTHS[dist] ?? QUICK_NAV_LINE_BASE_WIDTH; +} + +function registerQuickNavLine(index: number, el: any) { + if (el) { + quickNavLineEls.set(index, el as HTMLElement); + } else { + quickNavLineEls.delete(index); + } +} + +function onQuickNavMouseMove(event: MouseEvent) { + let best = -1; + let bestDist = Infinity; + quickNavLineEls.forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const d = Math.abs(event.clientY - (rect.top + rect.height / 2)); + if (d < bestDist) { + bestDist = d; + best = i; + } + }); + quickNavActiveIndex.value = best; +} + +function onQuickNavMouseLeave() { + quickNavActiveIndex.value = -1; +} + +function onQuickNavLineClick(item: any) { + virtualizerRef.value?.scrollToIndex?.(item.msgIndex, { align: 'start', smooth: true }); +} + +// ===== 悬浮预览卡 ===== +const QUICK_NAV_PREVIEW_HEIGHT = 116; +const quickNavPreviewUser = ref(''); +const quickNavPreviewAi = ref(''); +const quickNavPreviewStyle = ref>({}); + +watch(quickNavActiveIndex, (idx) => { + if (idx < 0) { + return; + } + const item = quickNavItems.value[idx]; + if (!item) { + return; + } + quickNavPreviewUser.value = item.userText; + quickNavPreviewAi.value = item.aiText; + const shell = shellRef.value; + const lineEl = quickNavLineEls.get(idx); + if (!shell || !lineEl) { + return; + } + const shellRect = shell.getBoundingClientRect(); + const lineRect = lineEl.getBoundingClientRect(); + const centerY = lineRect.top - shellRect.top + lineRect.height / 2; + let top = centerY - QUICK_NAV_PREVIEW_HEIGHT / 2; + top = Math.max(8, Math.min(top, shellRect.height - QUICK_NAV_PREVIEW_HEIGHT - 8)); + const left = lineRect.right - shellRect.left + 14; + quickNavPreviewStyle.value = { top: `${top}px`, left: `${left}px` }; +}); + defineExpose({ rootEl, getThinkingRef, diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index 90300228..fbd6f9a4 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -90,6 +90,168 @@ } /* 核心聊天区样式,确保对话内容在主面板中可见 */ +.chat-area-shell { + flex: 1; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; +} + +.chat-area-shell > .messages-area { + flex: 1; +} + +/* 左侧用户输入快捷跳转导航 */ +.chat-quick-nav { + position: absolute; + left: 18px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 10px 8px; + height: min(62vh, calc(var(--qn-count, 1) * 9px + 20px)); + z-index: 30; +} + +.qn-line { + height: 2px; + width: 8px; + border-radius: 1px; + background: var(--text-tertiary); + opacity: 0.45; + cursor: pointer; + transition: width 0.16s ease, opacity 0.16s ease, background-color 0.16s ease; + position: relative; + flex-shrink: 0; +} + +/* 扩大每根线的上下热区,「周围」也能触发波浪 */ +.qn-line::before { + content: ""; + position: absolute; + left: -6px; + right: -10px; + top: -5px; + bottom: -5px; +} + +.qn-line.is-hot { + background: var(--text-primary); + opacity: 1; +} + +/* 悬浮预览卡:上行用户原始输入,下行该轮最后一次 AI 输出(Markdown 渲染) */ +.quick-nav-preview { + position: absolute; + width: 340px; + height: 116px; + display: flex; + flex-direction: column; + background: var(--surface-raised); + border: 1px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-mid); + padding: 14px 16px; + z-index: 40; + pointer-events: none; + opacity: 0; + transform: translateX(-4px); + transition: opacity 0.12s ease, transform 0.12s ease; + overflow: hidden; +} + +.quick-nav-preview.is-visible { + opacity: 1; + transform: translateX(0); +} + +.quick-nav-preview-user { + flex-shrink: 0; + font-weight: 600; + font-size: 13.5px; + line-height: 1.45; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 8px; +} + +.quick-nav-preview-ai { + flex: 1; + min-height: 0; + overflow: hidden; + font-size: 12.5px; + line-height: 1.6; + color: var(--text-secondary); +} + +.quick-nav-preview-ai :deep(.markdown-renderer) { + font-size: inherit; + line-height: inherit; + color: inherit; +} + +.quick-nav-preview-ai :deep(p) { + margin: 0 0 4px; +} + +.quick-nav-preview-ai :deep(ul), +.quick-nav-preview-ai :deep(ol) { + margin: 0 0 4px; + padding-left: 18px; +} + +.quick-nav-preview-ai :deep(li) { + margin-bottom: 2px; +} + +.quick-nav-preview-ai :deep(li:last-child), +.quick-nav-preview-ai :deep(p:last-child) { + margin-bottom: 0; +} + +.quick-nav-preview-ai :deep(strong) { + color: var(--text-primary); + font-weight: 600; +} + +.quick-nav-preview-ai :deep(code) { + font-size: 12px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + background: var(--surface-muted); + border-radius: 4px; + padding: 0 4px; +} + +.quick-nav-preview-ai :deep(pre) { + margin: 0 0 4px; + white-space: pre-wrap; + word-break: break-all; + font-size: 12px; +} + +.quick-nav-preview-ai :deep(h1), +.quick-nav-preview-ai :deep(h2), +.quick-nav-preview-ai :deep(h3), +.quick-nav-preview-ai :deep(h4), +.quick-nav-preview-ai :deep(h5), +.quick-nav-preview-ai :deep(h6) { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; +} + +@media (max-width: 640px) { + .chat-quick-nav { + display: none; + } +} + .messages-area { flex: 1; overflow-y: auto;