feat(chat): 对话区左侧用户输入快捷跳转导航

This commit is contained in:
JOJO 2026-07-26 00:56:08 +08:00
parent 87a6a03943
commit 7ed54456d6
2 changed files with 348 additions and 5 deletions

View File

@ -1,12 +1,14 @@
<template> <template>
<div <div class="chat-area-shell" ref="shellRef">
class="messages-area messages-area--stick" <div
:class="{ 'messages-area--render-pending': renderPending }" class="messages-area messages-area--stick"
ref="scrollRef" :class="{ 'messages-area--render-pending': renderPending }"
> ref="scrollRef"
>
<div class="messages-flow" ref="contentRef"> <div class="messages-flow" ref="contentRef">
<!-- 窗口化渲染virtua 只挂载可视区+buffer 的消息块未挂载项按实测高度缓存占位 --> <!-- 窗口化渲染virtua 只挂载可视区+buffer 的消息块未挂载项按实测高度缓存占位 -->
<Virtualizer <Virtualizer
ref="virtualizerRef"
v-if="stickScrollElement" v-if="stickScrollElement"
:data="filteredMessages || []" :data="filteredMessages || []"
:scroll-ref="stickScrollElement" :scroll-ref="stickScrollElement"
@ -676,6 +678,36 @@
</Virtualizer> </Virtualizer>
</div> </div>
<div class="messages-bottom-spacer" aria-hidden="true"></div> <div class="messages-bottom-spacer" aria-hidden="true"></div>
</div>
<!-- 左侧用户输入快捷跳转导航每个真实用户输入一根横线hover 波浪 + 预览点击跳转 -->
<nav
v-if="quickNavItems.length > 0"
class="chat-quick-nav"
:style="{ '--qn-count': String(quickNavItems.length) }"
aria-label="用户输入快捷跳转"
@mousemove="onQuickNavMouseMove"
@mouseleave="onQuickNavMouseLeave"
>
<div
v-for="(item, i) in quickNavItems"
:key="item.msgIndex"
class="qn-line"
:class="{ 'is-hot': quickNavActiveIndex === i }"
:style="{ width: quickNavLineWidth(i) + 'px' }"
:ref="(el) => registerQuickNavLine(i, el)"
@click="onQuickNavLineClick(item)"
></div>
</nav>
<div
class="quick-nav-preview"
:class="{ 'is-visible': quickNavActiveIndex >= 0 }"
:style="quickNavPreviewStyle"
>
<div class="quick-nav-preview-user">{{ quickNavPreviewUser }}</div>
<div class="quick-nav-preview-ai">
<MarkdownRenderer :content="quickNavPreviewAi" :is-streaming="false" />
</div>
</div>
</div> </div>
</template> </template>
@ -2128,6 +2160,155 @@ function getGeneratingLetters(message: any) {
return Array.from(label); return Array.from(label);
} }
// ===== =====
// role === 'user' metadata.message_source === 'user'//
const shellRef = ref<HTMLElement | null>(null);
const virtualizerRef = ref<any>(null);
const quickNavActiveIndex = ref(-1);
const quickNavLineEls = new Map<number, HTMLElement>();
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<Record<string, string>>({});
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({ defineExpose({
rootEl, rootEl,
getThinkingRef, getThinkingRef,

View File

@ -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 { .messages-area {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;