feat(chat): 引用弹层多来源改为分页卡片,头部点击可打开文件

- 多引用不再显示列表,统一为单卡片渲染(标题/URL/摘要/图片/底部操作)
- 顶部右侧左右箭头+页码切换(箭头沿用个人空间展开菜单 chevron 画法)
- 打开弹层即并发预取全部文件条目摘要,切换不闪烁(in-flight 去重)
- 多引用头部点击可在侧边栏打开当前文件
This commit is contained in:
JOJO 2026-09-02 14:35:39 +08:00
parent 4496726009
commit e2ba0bdda0
3 changed files with 152 additions and 107 deletions

View File

@ -53,27 +53,68 @@
</div> </div>
</template> </template>
<!-- 多来源列表 --> <!-- 多来源分页卡片内容区与单来源一致头部右侧左右箭头切换 -->
<template v-else> <template v-else>
<div class="pop-header"> <div
<span class="pop-domain">{{ t('chat.citationSources', { n: citationPopover.annotations.length }) }}</span> class="pop-header"
:class="{ clickable: current && isFile(current) && canPreviewFile }"
@click="onHeaderClick(current)"
>
<span class="pop-icon" v-html="iconHtml(current)"></span>
<span class="pop-domain">{{ headerText(current) }}</span>
<span class="pop-pager">
<button
type="button"
class="pop-pager-btn"
:disabled="currentIndex <= 0"
:aria-label="t('chat.citationPrev')"
@click.stop="stepPager(-1)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 6-6 6 6 6" /></svg>
</button>
<span class="pop-pager-count">{{ currentIndex + 1 }}/{{ citationPopover.annotations.length }}</span>
<button
type="button"
class="pop-pager-btn"
:disabled="currentIndex >= citationPopover.annotations.length - 1"
:aria-label="t('chat.citationNext')"
@click.stop="stepPager(1)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
</button>
</span>
</div> </div>
<div class="pop-scroll"> <div class="pop-scroll">
<div <div class="pop-body">
v-for="ann in citationPopover.annotations" <div class="pop-title-row">
:key="ann.id" <div class="pop-title">{{ current.title || current.file_name || '' }}</div>
class="pop-list-row" <span v-if="locatorText(current)" class="pop-locator">{{ locatorText(current) }}</span>
@click="onRowClick(ann)"
>
<span class="pop-list-icon" v-html="iconHtml(ann)"></span>
<span class="pop-list-text">
<span class="pop-list-title">
<span class="pop-list-title-text">{{ ann.title || ann.file_name || '' }}</span>
<span v-if="isFile(ann) && locatorText(ann)" class="pop-locator pop-locator--sm">{{ locatorText(ann) }}</span>
</span>
<span class="pop-list-domain">{{ rowSubText(ann) }}</span>
</span>
</div> </div>
<div v-if="current.url" class="pop-url">{{ current.url }}</div>
<div v-if="isImageFile(current)" class="pop-image">
<img
:src="fileContentUrl(current)"
:alt="current.file_name || ''"
loading="lazy"
@click.stop="openCitationPreview(current)"
/>
</div>
<div v-else-if="displaySnippet(current)" class="pop-snippet">{{ displaySnippet(current) }}</div>
</div>
</div>
<div v-if="hasFooterAction" class="pop-footer">
<a
v-if="!isFile(current) && current.url"
class="pop-open"
:href="current.url"
target="_blank"
rel="noopener"
>{{ t('chat.citationOpenSource') }} </a>
<span
v-else-if="isFile(current) && hostMode"
class="pop-open"
@click="openOnComputer(current)"
>{{ t('quickdock.menuRevealInManager') }}</span>
</div> </div>
</template> </template>
</div> </div>
@ -106,9 +147,32 @@ const single = computed<CitationAnnotation | null>(() =>
citationPopover.annotations.length === 1 ? citationPopover.annotations[0] : null, citationPopover.annotations.length === 1 ? citationPopover.annotations[0] : null,
); );
/** 多来源分页:当前展示的条目索引(弹层打开时重置为 0箭头步进 */
const currentIndex = ref(0);
/** 始终指向当前展示的条目:单来源 = 唯一一条;多来源 = 分页当前页 */
const current = computed<CitationAnnotation | null>(() => {
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==宿 // footer==宿
const hasFooterAction = computed(() => { const hasFooterAction = computed(() => {
const s = single.value; const s = current.value;
if (!s) return false; if (!s) return false;
if (isFile(s)) return !!props.hostMode; if (isFile(s)) return !!props.hostMode;
return !!s.url; return !!s.url;
@ -178,12 +242,6 @@ function locatorText(ann: CitationAnnotation): string {
return bits.join(' · '); return bits.join(' · ');
} }
function rowSubText(ann: CitationAnnotation): string {
//
if (isFile(ann)) return ann.file_path || '';
return shortDomain(ann.domain);
}
function openFile(ann: CitationAnnotation) { function openFile(ann: CitationAnnotation) {
if (!ann.file_path || !canPreviewFile.value) return; if (!ann.file_path || !canPreviewFile.value) return;
quickDock.openPreview(ann.file_path); quickDock.openPreview(ann.file_path);
@ -192,15 +250,21 @@ function openFile(ann: CitationAnnotation) {
/* ---------- ---------- /* ---------- ----------
* 后端落库时已富化 snippet但旧消息富化前持久化与流式期的临时 annotation * 后端落库时已富化 snippet但旧消息富化前持久化与流式期的临时 annotation
* 没有 snippet这里在弹层打开时按 file_path 拉一次内容补齐 */ * 没有 snippet这里在弹层打开时按 file_path 拉一次内容补齐
* 多来源时打开瞬间即并发预取全部条目避免切换箭头时内容闪烁 */
const snippetCache = ref<Record<string, string>>({}); const snippetCache = ref<Record<string, string>>({});
/** in-flight 去重:同一 id 并发(当前条目 watch + 全量预取)只发一次请求 */
const pendingSnippets = new Set<string>();
function displaySnippet(ann: CitationAnnotation): string { function displaySnippet(ann: CitationAnnotation): string {
return ann.snippet || snippetCache.value[ann.id] || ''; return ann.snippet || snippetCache.value[ann.id] || '';
} }
async function maybeFetchSnippet(ann: CitationAnnotation) { async function maybeFetchSnippet(ann: CitationAnnotation) {
if (!isFile(ann) || isImageFile(ann) || ann.snippet || !ann.file_path || snippetCache.value[ann.id]) return; 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; const path = ann.file_path;
try { try {
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`); const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
@ -228,13 +292,22 @@ async function maybeFetchSnippet(ann: CitationAnnotation) {
} }
} catch { } catch {
/* 读取失败静默:弹层退化为仅路径/定位信息 */ /* 读取失败静默:弹层退化为仅路径/定位信息 */
} finally {
pendingSnippets.delete(ann.id);
}
}
/** 打开弹层时并发预取全部条目的文件摘要(图片条目跳过,直接显示预览) */
function prefetchAllSnippets() {
for (const ann of citationPopover.annotations) {
void maybeFetchSnippet(ann);
} }
} }
watch( watch(
() => [citationPopover.visible, single.value?.id] as const, () => [citationPopover.visible, current.value?.id] as const,
([visible]) => { ([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(); closeCitationPopover();
} }
function onRowClick(ann: CitationAnnotation) {
if (isFile(ann)) {
openFile(ann);
} else if (ann.url) {
window.open(ann.url, '_blank', 'noopener');
closeCitationPopover();
}
}
/** 定位:优先放胶囊下方,空间不足翻到上方;水平方向视口内夹取 */ /** 定位:优先放胶囊下方,空间不足翻到上方;水平方向视口内夹取 */
function positionPopover() { function positionPopover() {
const anchor = citationPopover.anchor; const anchor = citationPopover.anchor;
@ -303,6 +367,8 @@ watch(
() => citationPopover.visible, () => citationPopover.visible,
async (visible) => { async (visible) => {
if (visible) { if (visible) {
currentIndex.value = 0; // 1
prefetchAllSnippets(); //
await nextTick(); await nextTick();
positionPopover(); positionPopover();
// v-html // v-html
@ -387,11 +453,56 @@ onBeforeUnmount(() => {
flex: none; flex: none;
} }
.pop-domain { .pop-domain {
min-width: 0; /* 标题过长时由 ellipsis 截断,给右侧分页器让位 */
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-weight: 500; 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 { .pop-body {
padding: 10px 12px 12px; padding: 10px 12px 12px;
@ -474,73 +585,6 @@ onBeforeUnmount(() => {
text-decoration: underline; 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) { .pop-header :deep(.chip-favicon) {
width: 12px; width: 12px;
height: 12px; height: 12px;
@ -551,8 +595,7 @@ onBeforeUnmount(() => {
height: 12px; height: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.pop-header :deep(.chip-letter), .pop-header :deep(.chip-letter) {
.pop-list-icon :deep(.chip-letter) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -38,8 +38,9 @@ export default {
thinkingRunning: 'Thinking...', thinkingRunning: 'Thinking...',
// —— Inline citations (citation chip / popover) —— // —— Inline citations (citation chip / popover) ——
citationSources: '{n} sources',
citationOpenSource: 'Open source', citationOpenSource: 'Open source',
citationPrev: 'Previous source',
citationNext: 'Next source',
// —— File append (append / append_payload) —— // —— File append (append / append_payload) ——
targetFile: 'target file', targetFile: 'target file',

View File

@ -48,8 +48,9 @@ export default {
thinkingRunning: '正在思考...', thinkingRunning: '正在思考...',
// —— 行内引用citation chip / popover —— // —— 行内引用citation chip / popover ——
citationSources: '{n} 个来源',
citationOpenSource: '打开来源', citationOpenSource: '打开来源',
citationPrev: '上一个来源',
citationNext: '下一个来源',
// —— 文件追加append / append_payload —— // —— 文件追加append / append_payload ——
targetFile: '目标文件', targetFile: '目标文件',