From 4496726009f4f63d15d22de4887c4400be2190c3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 2 Sep 2026 10:53:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20citation=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AD=97=E6=AF=8D=E5=85=9C=E5=BA=95+?= =?UTF-8?q?=E5=A4=9A=E6=BA=90=E7=AB=9E=E9=80=9F=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=BD=E5=86=85=E7=BD=91=E7=BB=9C=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit favicon 源原仅 google s2,国内被墙时请求挂起、onerror 兜底不触发, 图标长期空白。改为: - 立即渲染域名首字母兜底,任何网络环境不留空白 - google / yandex / icon.horse 三源 2s 窗口并行探测,按可信度决策: icon.horse 对无图标站返回 200+灰色字母占位图(不可信),仅在 可信源(google 404 / yandex 1x1 可识别)佐证成功时才采纳其高清图; 无可信源成功则保持字母 - 域名级缓存,同一域名全局只探测一次 - 移除内联 onerror 字符串拼接,顺带删除 popover 死代码 escapeText --- .../src/components/chat/CitationPopover.vue | 20 +-- static/src/components/chat/citationChips.ts | 18 +- static/src/components/chat/citationFavicon.ts | 154 ++++++++++++++++++ .../styles/components/chat/_chat-area.scss | 6 +- 4 files changed, 170 insertions(+), 28 deletions(-) create mode 100644 static/src/components/chat/citationFavicon.ts diff --git a/static/src/components/chat/CitationPopover.vue b/static/src/components/chat/CitationPopover.vue index 5971d0a4..10312571 100644 --- a/static/src/components/chat/CitationPopover.vue +++ b/static/src/components/chat/CitationPopover.vue @@ -92,6 +92,7 @@ import { closeCitationPopover, type CitationAnnotation, } from './citationChips'; +import { faviconFallbackHtml, upgradeCitationFavicons } from './citationFavicon'; defineOptions({ name: 'CitationPopover' }); @@ -149,14 +150,6 @@ function shortDomain(domain?: string) { return (domain || '').replace(/^www\./, ''); } -function escapeText(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - const FILE_ICON_SVG = '' + '' + @@ -164,13 +157,8 @@ const FILE_ICON_SVG = function iconHtml(ann: CitationAnnotation): string { if (isFile(ann)) return FILE_ICON_SVG; - const d = shortDomain(ann.domain); - const letter = (d[0] || '?').toUpperCase(); - return ( - `` - ); + // 先渲染首字母兜底,真实 favicon 由 upgradeCitationFavicons 竞速成功后原地替换 + return faviconFallbackHtml(ann.domain || ''); } function headerText(ann: CitationAnnotation): string { @@ -317,6 +305,8 @@ watch( if (visible) { await nextTick(); positionPopover(); + // v-html 渲染出的字母占位统一走多源竞速升级 + if (popoverEl.value) upgradeCitationFavicons(popoverEl.value); } }, ); diff --git a/static/src/components/chat/citationChips.ts b/static/src/components/chat/citationChips.ts index ecb35585..65ed1386 100644 --- a/static/src/components/chat/citationChips.ts +++ b/static/src/components/chat/citationChips.ts @@ -7,6 +7,7 @@ * chip 内容并接管交互(悬停延迟开 / 移出延迟关 / 点击固定 / 滚动收起)。 */ import { reactive } from 'vue'; +import { faviconFallbackHtml, upgradeCitationFavicons } from './citationFavicon'; export interface CitationAnnotation { id: string; @@ -166,24 +167,14 @@ function chipLabel(ann: CitationAnnotation): string { return ann.type === 'file_citation' ? (ann.file_name || ann.file_path || '') : shortDomain(ann.domain); } -function faviconHtml(domain: string): string { - const d = shortDomain(domain); - // 站点图标加载失败时退化为域名首字符 - const letter = (d[0] || '?').toUpperCase(); - return ( - `` - ); -} - const FILE_ICON_SVG = '' + '' + ''; function chipIconHtml(ann: CitationAnnotation): string { - return ann.type === 'file_citation' ? FILE_ICON_SVG : faviconHtml(ann.domain || ''); + // 先渲染首字母兜底,真实 favicon 由 upgradeCitationFavicons 竞速成功后原地替换 + return ann.type === 'file_citation' ? FILE_ICON_SVG : faviconFallbackHtml(ann.domain || ''); } /** @@ -264,6 +255,9 @@ export function enhanceCitationChips( chip._citationAnnotations = annotations; attachChipTriggers(chip); }); + + // 字母占位统一走多源竞速升级(域名级缓存,重复调用无副作用) + upgradeCitationFavicons(container); } function escapeText(s: string): string { diff --git a/static/src/components/chat/citationFavicon.ts b/static/src/components/chat/citationFavicon.ts new file mode 100644 index 00000000..bcfc5f95 --- /dev/null +++ b/static/src/components/chat/citationFavicon.ts @@ -0,0 +1,154 @@ +/** + * 引用来源 favicon:字母兜底 + 多源竞速决策。 + * + * 背景:原实现直接 + onerror 回退字母, + * 但 google 服务在国内网络被墙时请求长时间挂起(不触发 error), + * 图标位置长期空白。改为: + * 1. 立即渲染域名首字母兜底(任何网络环境下不留空白); + * 2. 对多个图标源并行探测,2s 窗口内收集结果后统一决策; + * 3. 全部失败 / 超时 / 确认站点无图标 → 保持字母,不再变化。 + * + * 各源对「无图标站点」的行为(2026-09 国内直连 + 代理环境实测): + * - google s2:国内被墙;代理环境下最快且对无图标站返回 HTTP 404 + * (img 触发 onerror),结果**可信**; + * - yandex:国内直连可达(~1s),无图标站返回 200 + 1x1 空白 PNG, + * 用 naturalWidth > 1 判定后结果**可信**;缺点是图标只有 16px; + * - icon.horse:国内直连可达(1~2.5s),图标质量最高(可达 256px), + * 但对无图标站也返回 200 + 灰色字母占位图(免费版不可关闭), + * 结果**不可信**——仅在任一可信源同时成功(佐证站点确有图标)时才采纳。 + */ +interface SourceDef { + build: (domain: string) => string; + /** 可信源:能可靠区分「无图标」(404 或占位图可识别),成功结果可单独采纳 */ + trusted: boolean; + /** 图片最小边长:加载成功但小于该值视为无图标占位(yandex 无图标返回 1x1 空白图) */ + minSize?: number; +} + +/** 单域名探测窗口:超时后按已收集结果决策 */ +const RACE_WINDOW_MS = 2000; + +const SOURCES: SourceDef[] = [ + { + trusted: true, + build: (d) => `https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64` + }, + { + trusted: true, + minSize: 2, + build: (d) => `https://favicon.yandex.net/favicon/${encodeURIComponent(d)}` + }, + { + trusted: false, + build: (d) => `https://icon.horse/icon/${encodeURIComponent(d)}` + } +]; + +/** 域名级结果缓存:同一域名全局只探测一次,所有 chip / popover 共享结果 */ +const domainRaceCache = new Map>(); + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** 字母兜底占位 HTML(带域名标记,供 upgradeCitationFavicons 扫描升级) */ +export function faviconFallbackHtml(domain: string): string { + const d = (domain || '').replace(/^www\./, ''); + const letter = escapeHtml((d[0] || '?').toUpperCase()); + return `${letter}`; +} + +type SourceOutcome = { status: 'pending' } | { status: 'ok'; url: string } | { status: 'fail' }; + +/** + * 多源并行探测,窗口结束(或全部源返回)后统一决策: + * 1. icon.horse 成功且任一可信源成功 → 采纳 icon.horse(佐证通过,质量最高); + * 2. 否则有可信源成功 → 按 SOURCES 顺序采纳第一个可信结果; + * 3. 无可信源成功(站点无图标 / 全部失败 / 超时)→ null,保持字母兜底。 + */ +function raceDomain(domain: string): Promise { + const cached = domainRaceCache.get(domain); + if (cached) return cached; + + const race = new Promise((resolve) => { + const outcomes: SourceOutcome[] = SOURCES.map(() => ({ status: 'pending' })); + const pending = new Set(); + let decided = false; + + const decide = () => { + if (decided) return; + decided = true; + clearTimeout(timer); + // 中止仍在挂起的请求(被墙源的连接会挂到 TCP 超时,主动置空 src 回收) + pending.forEach((img) => { + img.onload = null; + img.onerror = null; + img.src = ''; + }); + pending.clear(); + + const firstTrusted = SOURCES.findIndex((s, i) => s.trusted && outcomes[i].status === 'ok'); + const horseIdx = SOURCES.findIndex((s, i) => !s.trusted && outcomes[i].status === 'ok'); + if (firstTrusted >= 0 && horseIdx >= 0) { + resolve((outcomes[horseIdx] as { status: 'ok'; url: string }).url); + } else if (firstTrusted >= 0) { + resolve((outcomes[firstTrusted] as { status: 'ok'; url: string }).url); + } else { + resolve(null); + } + }; + + const timer = setTimeout(decide, RACE_WINDOW_MS); + + SOURCES.forEach((src, i) => { + const url = src.build(domain); + const img = new Image(); + pending.add(img); + img.onload = () => { + pending.delete(img); + const minSize = src.minSize ?? 1; + outcomes[i] = + img.naturalWidth >= minSize && img.naturalHeight >= minSize + ? { status: 'ok', url } + : { status: 'fail' }; + if (!pending.size) decide(); // 全部源已返回,提前结算 + }; + img.onerror = () => { + pending.delete(img); + outcomes[i] = { status: 'fail' }; + if (!pending.size) decide(); + }; + img.src = url; + }); + }); + + domainRaceCache.set(domain, race); + return race; +} + +/** + * 扫描容器内的字母占位元素并启动探测升级: + * 决策出真实 favicon 后原地替换(URL 已在探测时加载过,命中缓存无二次请求)。 + */ +export function upgradeCitationFavicons(container: ParentNode) { + const els = container.querySelectorAll( + '.chip-letter[data-favicon-domain]:not([data-favicon-racing])' + ); + els.forEach((el) => { + const domain = el.dataset.faviconDomain || ''; + if (!domain) return; + el.dataset.faviconRacing = '1'; + void raceDomain(domain).then((url) => { + if (!url || !el.isConnected) return; + const img = document.createElement('img'); + img.className = 'chip-favicon'; + img.alt = ''; + img.src = url; + el.replaceWith(img); + }); + }); +} diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index e51936ab..6c30c3f2 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -2525,7 +2525,11 @@ a.md-download-link { flex: none; } - .chip-favicon + .chip-favicon { + // 双图标叠放:favicon 与字母兜底的任意相邻组合保持一致视觉 + .chip-favicon + .chip-favicon, + .chip-letter + .chip-letter, + .chip-favicon + .chip-letter, + .chip-letter + .chip-favicon { margin-left: -5px; border: 1px solid var(--surface-muted); }