diff --git a/static/src/components/chat/CitationPopover.vue b/static/src/components/chat/CitationPopover.vue index 10312571..34aa0fa6 100644 --- a/static/src/components/chat/CitationPopover.vue +++ b/static/src/components/chat/CitationPopover.vue @@ -53,28 +53,69 @@ - + @@ -106,9 +147,32 @@ const single = computed(() => citationPopover.annotations.length === 1 ? citationPopover.annotations[0] : null, ); +/** 多来源分页:当前展示的条目索引(弹层打开时重置为 0,箭头步进) */ +const currentIndex = ref(0); + +/** 始终指向当前展示的条目:单来源 = 唯一一条;多来源 = 分页当前页 */ +const current = computed(() => { + const list = citationPopover.annotations; + if (!list.length) return null; + return list[Math.min(currentIndex.value, list.length - 1)] ?? null; +}); + +function stepPager(delta: number) { + const list = citationPopover.annotations; + if (list.length < 2) return; + const next = Math.min(Math.max(currentIndex.value + delta, 0), list.length - 1); + if (next === currentIndex.value) return; + currentIndex.value = next; + // 内容高度变化后重新定位,并给新条目做 favicon 竞速升级 + void nextTick(() => { + positionPopover(); + if (popoverEl.value) upgradeCitationFavicons(popoverEl.value); + }); +} + // 底部有动作才渲染 footer:网页=打开来源;文件=在电脑上直接打开(仅宿主机) const hasFooterAction = computed(() => { - const s = single.value; + const s = current.value; if (!s) return false; if (isFile(s)) return !!props.hostMode; return !!s.url; @@ -178,12 +242,6 @@ function locatorText(ann: CitationAnnotation): string { return bits.join(' · '); } -function rowSubText(ann: CitationAnnotation): string { - // 行号徽章已提到标题行,副行固定显示路径(网页显示域名) - if (isFile(ann)) return ann.file_path || ''; - return shortDomain(ann.domain); -} - function openFile(ann: CitationAnnotation) { if (!ann.file_path || !canPreviewFile.value) return; quickDock.openPreview(ann.file_path); @@ -192,15 +250,21 @@ function openFile(ann: CitationAnnotation) { /* ---------- 文件内容片段懒加载 ---------- * 后端落库时已富化 snippet;但旧消息(富化前持久化)与流式期的临时 annotation - * 没有 snippet,这里在弹层打开时按 file_path 拉一次内容补齐。 */ + * 没有 snippet,这里在弹层打开时按 file_path 拉一次内容补齐。 + * 多来源时打开瞬间即并发预取全部条目,避免切换箭头时内容闪烁。 */ const snippetCache = ref>({}); +/** in-flight 去重:同一 id 并发(当前条目 watch + 全量预取)只发一次请求 */ +const pendingSnippets = new Set(); + function displaySnippet(ann: CitationAnnotation): string { return ann.snippet || snippetCache.value[ann.id] || ''; } async function maybeFetchSnippet(ann: CitationAnnotation) { if (!isFile(ann) || isImageFile(ann) || ann.snippet || !ann.file_path || snippetCache.value[ann.id]) return; + if (pendingSnippets.has(ann.id)) return; + pendingSnippets.add(ann.id); const path = ann.file_path; try { const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`); @@ -228,13 +292,22 @@ async function maybeFetchSnippet(ann: CitationAnnotation) { } } catch { /* 读取失败静默:弹层退化为仅路径/定位信息 */ + } finally { + pendingSnippets.delete(ann.id); + } +} + +/** 打开弹层时并发预取全部条目的文件摘要(图片条目跳过,直接显示预览) */ +function prefetchAllSnippets() { + for (const ann of citationPopover.annotations) { + void maybeFetchSnippet(ann); } } watch( - () => [citationPopover.visible, single.value?.id] as const, + () => [citationPopover.visible, current.value?.id] as const, ([visible]) => { - if (visible && single.value) maybeFetchSnippet(single.value); + if (visible && current.value) maybeFetchSnippet(current.value); }, ); @@ -273,15 +346,6 @@ async function openOnComputer(ann: CitationAnnotation) { closeCitationPopover(); } -function onRowClick(ann: CitationAnnotation) { - if (isFile(ann)) { - openFile(ann); - } else if (ann.url) { - window.open(ann.url, '_blank', 'noopener'); - closeCitationPopover(); - } -} - /** 定位:优先放胶囊下方,空间不足翻到上方;水平方向视口内夹取 */ function positionPopover() { const anchor = citationPopover.anchor; @@ -303,6 +367,8 @@ watch( () => citationPopover.visible, async (visible) => { if (visible) { + currentIndex.value = 0; // 多来源分页每次打开回到第 1 条 + prefetchAllSnippets(); // 并发预取全部条目摘要,切换时不再闪烁 await nextTick(); positionPopover(); // v-html 渲染出的字母占位统一走多源竞速升级 @@ -387,11 +453,56 @@ onBeforeUnmount(() => { flex: none; } .pop-domain { + min-width: 0; /* 标题过长时由 ellipsis 截断,给右侧分页器让位 */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +/* 多来源分页:左侧 图标+域名,右侧 左右箭头+页码 */ +.pop-pager { + flex: none; + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: auto; +} +.pop-pager-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} +.pop-pager-btn:hover:not(:disabled) { + background: var(--hover-bg); + color: var(--text-primary); +} +.pop-pager-btn:disabled { + color: var(--text-tertiary); + opacity: 0.45; + cursor: default; +} +.pop-pager-btn svg { + width: 14px; + height: 14px; +} +.pop-pager-count { + min-width: 26px; + text-align: center; + font-size: 11px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} .pop-body { padding: 10px 12px 12px; @@ -474,73 +585,6 @@ onBeforeUnmount(() => { text-decoration: underline; } -.pop-list-row { - display: flex; - align-items: center; - gap: 8px; - height: 44px; - padding: 0 12px; - cursor: pointer; -} -.pop-list-row:hover { - background: var(--hover-bg); -} -.pop-list-row + .pop-list-row { - border-top: 1px solid var(--border-default); -} -.pop-list-icon { - flex: none; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; -} -.pop-list-icon :deep(.chip-favicon) { - width: 14px; - height: 14px; -} -.pop-list-icon :deep(.chip-file-icon) { - width: 14px; - height: 14px; - color: var(--text-tertiary); /* 显式颜色绑定,stroke=currentColor 才能生效 */ -} -.pop-list-text { - min-width: 0; - flex: 1; - display: flex; - flex-direction: column; -} -.pop-list-title { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - font-size: 12.5px; - color: var(--text-primary); - line-height: 1.3; -} -.pop-list-title-text { - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -/* 列表行内的小型行号徽章(44px 行高内容纳得下) */ -.pop-locator--sm { - height: 16px; - padding: 0 6px; - font-size: 10px; -} -.pop-list-domain { - font-size: 11px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.3; -} - .pop-header :deep(.chip-favicon) { width: 12px; height: 12px; @@ -551,8 +595,7 @@ onBeforeUnmount(() => { height: 12px; color: var(--text-tertiary); } -.pop-header :deep(.chip-letter), -.pop-list-icon :deep(.chip-letter) { +.pop-header :deep(.chip-letter) { display: inline-flex; align-items: center; justify-content: center; diff --git a/static/src/locales/en-US/chat.ts b/static/src/locales/en-US/chat.ts index af6d45be..8fcd56a4 100644 --- a/static/src/locales/en-US/chat.ts +++ b/static/src/locales/en-US/chat.ts @@ -38,8 +38,9 @@ export default { thinkingRunning: 'Thinking...', // —— Inline citations (citation chip / popover) —— - citationSources: '{n} sources', citationOpenSource: 'Open source', + citationPrev: 'Previous source', + citationNext: 'Next source', // —— File append (append / append_payload) —— targetFile: 'target file', diff --git a/static/src/locales/zh-CN/chat.ts b/static/src/locales/zh-CN/chat.ts index 47aab241..3afcf675 100644 --- a/static/src/locales/zh-CN/chat.ts +++ b/static/src/locales/zh-CN/chat.ts @@ -48,8 +48,9 @@ export default { thinkingRunning: '正在思考...', // —— 行内引用(citation chip / popover) —— - citationSources: '{n} 个来源', citationOpenSource: '打开来源', + citationPrev: '上一个来源', + citationNext: '下一个来源', // —— 文件追加(append / append_payload) —— targetFile: '目标文件',