feat(chat): 对话区左侧用户输入快捷跳转导航
This commit is contained in:
parent
87a6a03943
commit
7ed54456d6
@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="messages-area messages-area--stick"
|
||||
:class="{ 'messages-area--render-pending': renderPending }"
|
||||
ref="scrollRef"
|
||||
>
|
||||
<div class="chat-area-shell" ref="shellRef">
|
||||
<div
|
||||
class="messages-area messages-area--stick"
|
||||
:class="{ 'messages-area--render-pending': renderPending }"
|
||||
ref="scrollRef"
|
||||
>
|
||||
<div class="messages-flow" ref="contentRef">
|
||||
<!-- 窗口化渲染:virtua 只挂载可视区+buffer 的消息块,未挂载项按实测高度缓存占位 -->
|
||||
<Virtualizer
|
||||
ref="virtualizerRef"
|
||||
v-if="stickScrollElement"
|
||||
:data="filteredMessages || []"
|
||||
:scroll-ref="stickScrollElement"
|
||||
@ -676,6 +678,36 @@
|
||||
</Virtualizer>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@ -2128,6 +2160,155 @@ function getGeneratingLetters(message: any) {
|
||||
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({
|
||||
rootEl,
|
||||
getThinkingRef,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user