fix(chat): citation 图标改为字母兜底+多源竞速,修复国内网络空白

favicon 源原仅 google s2,国内被墙时请求挂起、onerror 兜底不触发,
图标长期空白。改为:
- 立即渲染域名首字母兜底,任何网络环境不留空白
- google / yandex / icon.horse 三源 2s 窗口并行探测,按可信度决策:
  icon.horse 对无图标站返回 200+灰色字母占位图(不可信),仅在
  可信源(google 404 / yandex 1x1 可识别)佐证成功时才采纳其高清图;
  无可信源成功则保持字母
- 域名级缓存,同一域名全局只探测一次
- 移除内联 onerror 字符串拼接,顺带删除 popover 死代码 escapeText
This commit is contained in:
JOJO 2026-09-02 10:53:03 +08:00
parent 9220f3a3e7
commit 4496726009
4 changed files with 170 additions and 28 deletions

View File

@ -92,6 +92,7 @@ import {
closeCitationPopover, closeCitationPopover,
type CitationAnnotation, type CitationAnnotation,
} from './citationChips'; } from './citationChips';
import { faviconFallbackHtml, upgradeCitationFavicons } from './citationFavicon';
defineOptions({ name: 'CitationPopover' }); defineOptions({ name: 'CitationPopover' });
@ -149,14 +150,6 @@ function shortDomain(domain?: string) {
return (domain || '').replace(/^www\./, ''); return (domain || '').replace(/^www\./, '');
} }
function escapeText(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const FILE_ICON_SVG = const FILE_ICON_SVG =
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' + '<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' + '<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
@ -164,13 +157,8 @@ const FILE_ICON_SVG =
function iconHtml(ann: CitationAnnotation): string { function iconHtml(ann: CitationAnnotation): string {
if (isFile(ann)) return FILE_ICON_SVG; if (isFile(ann)) return FILE_ICON_SVG;
const d = shortDomain(ann.domain); // favicon upgradeCitationFavicons
const letter = (d[0] || '?').toUpperCase(); return faviconFallbackHtml(ann.domain || '');
return (
`<img class="chip-favicon" loading="lazy" alt="" ` +
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
`onerror="this.outerHTML='<span class=&quot;chip-letter&quot;>${letter}</span>'">`
);
} }
function headerText(ann: CitationAnnotation): string { function headerText(ann: CitationAnnotation): string {
@ -317,6 +305,8 @@ watch(
if (visible) { if (visible) {
await nextTick(); await nextTick();
positionPopover(); positionPopover();
// v-html
if (popoverEl.value) upgradeCitationFavicons(popoverEl.value);
} }
}, },
); );

View File

@ -7,6 +7,7 @@
* chip / / / * chip / / /
*/ */
import { reactive } from 'vue'; import { reactive } from 'vue';
import { faviconFallbackHtml, upgradeCitationFavicons } from './citationFavicon';
export interface CitationAnnotation { export interface CitationAnnotation {
id: string; 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); 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 (
`<img class="chip-favicon" loading="lazy" alt="" ` +
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
`onerror="this.outerHTML='<span class=&quot;chip-letter&quot;>${letter}</span>'">`
);
}
const FILE_ICON_SVG = const FILE_ICON_SVG =
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' + '<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' + '<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
'<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>'; '<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
function chipIconHtml(ann: CitationAnnotation): string { 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; chip._citationAnnotations = annotations;
attachChipTriggers(chip); attachChipTriggers(chip);
}); });
// 字母占位统一走多源竞速升级(域名级缓存,重复调用无副作用)
upgradeCitationFavicons(container);
} }
function escapeText(s: string): string { function escapeText(s: string): string {

View File

@ -0,0 +1,154 @@
/**
* favicon +
*
* <img src="google 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.horse1~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<string, Promise<string | null>>();
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 字母兜底占位 HTML带域名标记供 upgradeCitationFavicons 扫描升级) */
export function faviconFallbackHtml(domain: string): string {
const d = (domain || '').replace(/^www\./, '');
const letter = escapeHtml((d[0] || '?').toUpperCase());
return `<span class="chip-letter" data-favicon-domain="${escapeHtml(d)}">${letter}</span>`;
}
type SourceOutcome = { status: 'pending' } | { status: 'ok'; url: string } | { status: 'fail' };
/**
*
* 1. icon.horse icon.horse
* 2. SOURCES
* 3. / / null
*/
function raceDomain(domain: string): Promise<string | null> {
const cached = domainRaceCache.get(domain);
if (cached) return cached;
const race = new Promise<string | null>((resolve) => {
const outcomes: SourceOutcome[] = SOURCES.map(() => ({ status: 'pending' }));
const pending = new Set<HTMLImageElement>();
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<HTMLElement>(
'.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);
});
});
}

View File

@ -2525,7 +2525,11 @@ a.md-download-link {
flex: none; 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; margin-left: -5px;
border: 1px solid var(--surface-muted); border: 1px solid var(--surface-muted);
} }