feat(chat): 对话区左侧用户输入快捷跳转导航
This commit is contained in:
parent
87a6a03943
commit
7ed54456d6
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user