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:
parent
9220f3a3e7
commit
4496726009
@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const FILE_ICON_SVG =
|
||||
'<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"/>' +
|
||||
@ -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 (
|
||||
`<img class="chip-favicon" loading="lazy" alt="" ` +
|
||||
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
|
||||
`onerror="this.outerHTML='<span class="chip-letter">${letter}</span>'">`
|
||||
);
|
||||
// 先渲染首字母兜底,真实 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
`<img class="chip-favicon" loading="lazy" alt="" ` +
|
||||
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
|
||||
`onerror="this.outerHTML='<span class="chip-letter">${letter}</span>'">`
|
||||
);
|
||||
}
|
||||
|
||||
const FILE_ICON_SVG =
|
||||
'<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="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></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 {
|
||||
|
||||
154
static/src/components/chat/citationFavicon.ts
Normal file
154
static/src/components/chat/citationFavicon.ts
Normal 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.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<string, Promise<string | null>>();
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.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 `<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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user