feat: improve chat source rendering

This commit is contained in:
XianYuDaXian committed 2026-03-24 18:22:16 +08:00
1 parent 79af9c63eb
commit fba7a65a1a
3 files changed
+999 -32

No files matched your search

+239 -1
View File
@@ -95,6 +95,170 @@ def extract_tool_text(raw: str, rollout_id: str = "") -> str:
return re.sub(r"<[^>]+>", "", raw, flags=re.DOTALL).strip()
def _tool_card_to_source_group(card_id: str, card: Dict[str, Any]) -> Dict[str, Any] | None:
if not card_id or not isinstance(card, dict):
return None
if web_search := card.get("webSearch"):
args = web_search.get("args") or {}
query = args.get("query") or args.get("q") or ""
return {
"tool_usage_card_id": card_id,
"kind": "web_search",
"query": query,
"results": [],
}
if image_search := card.get("imageSearch"):
args = image_search.get("args") or {}
query = (
args.get("imageDescription")
or args.get("image_description")
or args.get("query")
or ""
)
return {
"tool_usage_card_id": card_id,
"kind": "search_images",
"query": query,
"results": [],
}
return None
def _normalize_source_results(items: Any) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
seen: set[str] = set()
for item in items or []:
if not isinstance(item, dict):
continue
url = str(item.get("url") or item.get("link") or "").strip()
if not url or url in seen:
continue
seen.add(url)
normalized.append(
{
"url": url,
"title": str(item.get("title") or "").strip(),
"preview": str(item.get("preview") or item.get("snippet") or "").strip(),
}
)
return normalized
def _extract_card_attachment_sources(card_attachments: Any) -> Dict[str, List[Dict[str, str]]]:
citations: List[Dict[str, str]] = []
images: List[Dict[str, str]] = []
seen_citations: set[str] = set()
seen_images: set[str] = set()
for raw in card_attachments or []:
if not isinstance(raw, str) or not raw.strip():
continue
try:
card = orjson.loads(raw)
except orjson.JSONDecodeError:
continue
if not isinstance(card, dict):
continue
card_type = str(card.get("type") or "")
if card_type == "render_inline_citation":
url = str(card.get("url") or "").strip()
if url and url not in seen_citations:
seen_citations.add(url)
citations.append(
{
"url": url,
"title": "",
}
)
continue
if card_type == "render_searched_image":
image = card.get("image") or {}
url = str(image.get("link") or image.get("original") or "").strip()
if url and url not in seen_images:
seen_images.add(url)
images.append(
{
"url": url,
"title": str(image.get("title") or "").strip(),
"thumbnail": str(image.get("thumbnail") or "").strip(),
"original": str(image.get("original") or "").strip(),
}
)
return {"citations": citations, "images": images}
def extract_sources_payload(model_response: Dict[str, Any]) -> Dict[str, Any] | None:
if not isinstance(model_response, dict):
return None
groups_by_id: Dict[str, Dict[str, Any]] = {}
group_order: List[str] = []
def ensure_group(card_id: str, card: Dict[str, Any] | None = None) -> Dict[str, Any] | None:
if not card_id:
return None
group = groups_by_id.get(card_id)
if group is None and isinstance(card, dict):
group = _tool_card_to_source_group(card_id, card)
if group:
groups_by_id[card_id] = group
group_order.append(card_id)
return group
for step in model_response.get("steps") or []:
if not isinstance(step, dict):
continue
for card in step.get("toolUsageCards") or []:
if not isinstance(card, dict):
continue
card_id = str(card.get("toolUsageCardId") or "").strip()
ensure_group(card_id, card)
for result in step.get("toolUsageResults") or []:
if not isinstance(result, dict):
continue
card_id = str(result.get("toolUsageCardId") or "").strip()
group = ensure_group(card_id)
if not group:
continue
web_results = ((result.get("webSearchResults") or {}).get("results")) or []
normalized = _normalize_source_results(web_results)
if normalized:
group["results"] = normalized
if not groups_by_id:
fallback_results = _normalize_source_results(
((model_response.get("webSearchResults") or {}).get("results")) or []
)
if fallback_results:
groups_by_id["__fallback__"] = {
"tool_usage_card_id": "__fallback__",
"kind": "web_search",
"query": "",
"results": fallback_results,
}
group_order.append("__fallback__")
attachments = _extract_card_attachment_sources(model_response.get("cardAttachmentsJson"))
groups = [groups_by_id[group_id] for group_id in group_order if groups_by_id.get(group_id)]
payload = {
"groups": groups,
"citations": attachments["citations"],
"images": attachments["images"],
}
if payload["groups"] or payload["citations"] or payload["images"]:
return payload
return None
def _get_chat_semaphore() -> asyncio.Semaphore:
global _CHAT_SEMAPHORE, _CHAT_SEM_VALUE
value = max(1, int(get_config("chat.concurrent")))
@@ -570,6 +734,10 @@ class StreamProcessor(proc_base.BaseProcessor):
self._tool_partial = ""
self._tool_calls_seen = False
self._tool_call_index = 0
self._source_groups: Dict[str, Dict[str, Any]] = {}
self._source_order: List[str] = []
self._citation_sources: List[Dict[str, str]] = []
self._image_sources: List[Dict[str, str]] = []
def _filter_tool_card(self, token: str) -> str:
if not token or not self.tool_usage_enabled:
@@ -734,6 +902,7 @@ class StreamProcessor(proc_base.BaseProcessor):
role: str = None,
finish: str = None,
tool_calls: list = None,
sources: Dict[str, Any] = None,
) -> str:
"""Build SSE response."""
delta = {}
@@ -755,8 +924,38 @@ class StreamProcessor(proc_base.BaseProcessor):
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
],
}
if sources is not None:
chunk["sources"] = sources
return f"data: {orjson.dumps(chunk).decode()}\n\n"
def _ensure_source_group(
self, card_id: str, card: Dict[str, Any] | None = None
) -> Dict[str, Any] | None:
if not card_id:
return None
group = self._source_groups.get(card_id)
if group is None and isinstance(card, dict):
group = _tool_card_to_source_group(card_id, card)
if group:
self._source_groups[card_id] = group
self._source_order.append(card_id)
return group
def _sources_payload(self) -> Dict[str, Any] | None:
groups = [
self._source_groups[group_id]
for group_id in self._source_order
if self._source_groups.get(group_id)
]
payload = {
"groups": groups,
"citations": self._citation_sources,
"images": self._image_sources,
}
if payload["groups"] or payload["citations"] or payload["images"]:
return payload
return None
async def _close_think_block(self) -> AsyncGenerator[str, None]:
"""Close the visible think block before emitting normal answer content."""
if self.think_opened:
@@ -848,6 +1047,19 @@ class StreamProcessor(proc_base.BaseProcessor):
except orjson.JSONDecodeError:
card_data = None
if isinstance(card_data, dict):
attachments = _extract_card_attachment_sources([json_data])
if attachments["citations"]:
seen = {item.get("url") for item in self._citation_sources}
for item in attachments["citations"]:
if item.get("url") and item.get("url") not in seen:
seen.add(item["url"])
self._citation_sources.append(item)
if attachments["images"]:
seen = {item.get("url") for item in self._image_sources}
for item in attachments["images"]:
if item.get("url") and item.get("url") not in seen:
seen.add(item["url"])
self._image_sources.append(item)
image = card_data.get("image") or {}
original = image.get("original")
title = image.get("title") or ""
@@ -861,6 +1073,27 @@ class StreamProcessor(proc_base.BaseProcessor):
yield self._sse(f"![image]({original})\n")
continue
if tool_card := resp.get("toolUsageCard"):
card_id = str(
resp.get("toolUsageCardId") or tool_card.get("toolUsageCardId") or ""
).strip()
self._ensure_source_group(card_id, tool_card)
sources_payload = self._sources_payload()
if sources_payload:
yield self._sse(sources=sources_payload)
if resp.get("messageTag") == "raw_function_result":
card_id = str(resp.get("toolUsageCardId") or "").strip()
group = self._ensure_source_group(card_id)
web_results = ((resp.get("webSearchResults") or {}).get("results")) or []
normalized = _normalize_source_results(web_results)
if group is not None and normalized:
group["results"] = normalized
sources_payload = self._sources_payload()
if sources_payload:
yield self._sse(sources=sources_payload)
continue
if (token := resp.get("token")) is not None:
if not token:
continue
@@ -909,7 +1142,7 @@ class StreamProcessor(proc_base.BaseProcessor):
elif kind == "tool":
yield self._sse(tool_calls=[payload])
finish_reason = "tool_calls" if self._tool_stream_enabled and self._tool_calls_seen else "stop"
yield self._sse(finish=finish_reason)
yield self._sse(finish=finish_reason, sources=self._sources_payload())
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Stream cancelled by client", extra={"model": self.model})
@@ -1012,6 +1245,7 @@ class CollectProcessor(proc_base.BaseProcessor):
response_id = ""
fingerprint = ""
content = ""
final_model_response: Dict[str, Any] = {}
idle_timeout = get_config("chat.stream_timeout")
first_item_timeout = get_config("chat.first_token_timeout")
@@ -1033,6 +1267,7 @@ class CollectProcessor(proc_base.BaseProcessor):
fingerprint = llm.get("modelHash", "")
if mr := resp.get("modelResponse"):
final_model_response = mr
response_id = mr.get("responseId", "")
content = mr.get("message", "")
@@ -1125,6 +1360,7 @@ class CollectProcessor(proc_base.BaseProcessor):
content = text_content
finish_reason = "tool_calls"
sources_payload = extract_sources_payload(final_model_response)
message_obj = {
"role": "assistant",
"content": content,
@@ -1133,6 +1369,8 @@ class CollectProcessor(proc_base.BaseProcessor):
}
if tool_calls_result:
message_obj["tool_calls"] = tool_calls_result
if sources_payload:
message_obj["sources"] = sources_payload
return {
"id": response_id,
+254 -18
View File
@@ -131,6 +131,142 @@ body {
background: rgba(17, 17, 17, 0.06);
}
.sources-widget {
position: relative;
}
.sources-chip {
list-style: none;
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
padding: 4px 10px 4px 4px;
border-radius: 999px;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
color: var(--chat-ink);
cursor: pointer;
}
.sources-chip::-webkit-details-marker {
display: none;
}
.sources-chip:hover {
background: var(--chat-muted);
}
.sources-icons {
display: inline-flex;
align-items: center;
}
.sources-icon-badge,
.sources-icon-fallback,
.source-row-icon {
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sources-icon-badge + .sources-icon-badge {
margin-left: -10px;
}
.sources-icon-badge img,
.source-row-icon img {
width: 16px;
height: 16px;
border-radius: 999px;
}
.sources-icon-fallback,
.source-row-icon {
font-size: 11px;
color: var(--accents-4);
}
.sources-chip-label {
font-size: 12px;
white-space: nowrap;
}
.sources-panel {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
min-width: min(520px, calc(100vw - 48px));
max-width: min(640px, calc(100vw - 48px));
max-height: 320px;
overflow: auto;
padding: 8px;
border-radius: 14px;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
box-shadow: 0 18px 32px rgba(17, 17, 17, 0.14);
z-index: 20;
}
.sources-widget:not([open]) .sources-panel {
display: none;
}
.sources-section-title {
font-size: 11px;
color: var(--accents-4);
padding: 6px 8px 4px;
}
.source-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 10px;
color: inherit;
text-decoration: none;
}
.source-row:hover {
background: var(--chat-muted);
}
.source-row.is-query {
cursor: default;
}
.source-row.is-query:hover {
background: transparent;
}
.source-row-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.source-row-meta {
font-size: 11px;
color: var(--accents-4);
}
.source-row-title {
font-size: 13px;
color: var(--chat-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-bubble {
max-width: 100%;
font-size: 14px;
@@ -146,6 +282,7 @@ body {
.message-row.assistant .message-content {
width: 100%;
max-width: min(860px, 100%);
}
.message-row.user .message-bubble {
@@ -237,20 +374,54 @@ body {
padding: 0;
}
.message-content.rendered img {
max-width: min(420px, 100%);
max-height: 320px;
border-radius: 12px;
margin-top: 12px;
border: 1px solid var(--chat-border);
cursor: zoom-in;
}
.message-content.rendered a {
color: inherit;
text-decoration: underline;
}
.message-content.rendered .inline-citation-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
margin: 0 4px;
padding: 2.5px 8px;
border-radius: 999px;
border: none;
background: color-mix(in srgb, var(--chat-muted) 88%, var(--chat-surface));
color: color-mix(in srgb, var(--chat-ink) 64%, transparent);
font-size: 13px;
font-weight: 500;
line-height: 1;
text-decoration: none;
vertical-align: middle;
white-space: nowrap;
appearance: none;
}
.message-content.rendered .inline-citation-chip:hover {
color: var(--chat-ink);
background: color-mix(in srgb, var(--chat-muted) 70%, var(--chat-surface));
text-decoration: none;
}
.message-content.rendered .inline-citation-chip__label {
display: inline-flex;
align-items: center;
}
.message-content.rendered .inline-citation-chip__count {
display: inline-flex;
align-items: center;
color: color-mix(in srgb, var(--chat-ink) 38%, transparent);
font-size: 11px;
line-height: 1;
}
.message-content.rendered .print-hidden {
display: inline;
}
.message-content.rendered .inline-code {
background: var(--chat-muted);
border: 1px solid var(--chat-border);
@@ -314,25 +485,66 @@ body {
line-height: 1.3;
}
.message-content.rendered h1 { font-size: 20px; }
.message-content.rendered h2 { font-size: 18px; }
.message-content.rendered h3 { font-size: 16px; }
.message-content.rendered h1 { font-size: 24px; }
.message-content.rendered h2 { font-size: 20px; }
.message-content.rendered h3 { font-size: 17px; }
.message-content.rendered h4 { font-size: 15px; }
.message-content.rendered h5 { font-size: 14px; }
.message-content.rendered h6 { font-size: 13px; }
.message-content.rendered p {
margin: 6px 0;
margin: 10px 0;
line-height: 1.78;
}
.message-content.rendered ul,
.message-content.rendered ol {
margin: 6px 0 10px;
padding-left: 20px;
margin: 10px 0 16px;
padding-left: 24px;
}
.message-content.rendered li {
margin: 4px 0;
margin: 8px 0;
line-height: 1.72;
}
.message-content.rendered strong {
font-weight: 700;
}
.message-content.rendered > :first-child {
margin-top: 0;
}
.message-content.rendered > :last-child {
margin-bottom: 0;
}
.message-content.rendered .message-image-card {
display: block;
width: 100%;
max-width: min(680px, 100%);
margin: 18px 0 20px;
}
.message-content.rendered .message-image-card img {
display: block;
width: 100%;
max-width: 100%;
max-height: none;
margin: 0;
border-radius: 16px;
border: 1px solid var(--chat-border);
background: var(--chat-surface);
cursor: zoom-in;
}
.message-content.rendered .message-image-caption {
margin-top: 8px;
padding: 0 2px;
font-size: 13px;
line-height: 1.45;
color: var(--accents-4);
}
.message-content.rendered .table-wrap {
@@ -436,7 +648,7 @@ body {
.img-grid {
display: grid;
gap: 2px;
margin: 12px 0;
margin: 18px 0 20px;
grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
width: 100%;
max-width: 100%;
@@ -446,11 +658,18 @@ body {
.img-grid > * {
display: block;
width: 100%;
margin: 0;
}
.message-content.rendered .img-grid .message-image-card {
max-width: none;
width: 100%;
margin: 0;
}
.message-content.rendered .img-grid img {
width: 100%;
height: auto;
height: 100%;
display: block;
max-width: 100%;
max-height: none;
@@ -458,6 +677,17 @@ body {
border-radius: 12px;
border: 1px solid var(--chat-border);
background: transparent;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.message-content.rendered .img-grid .message-image-caption {
padding: 0 4px;
}
.message-content.rendered .img-grid .message-image-card {
overflow: hidden;
border-radius: 12px;
}
.img-retry {
@@ -1142,6 +1372,12 @@ html[data-theme='dark'] .model-option.active {
}
@media (max-width: 720px) {
.sources-panel {
left: auto;
right: 0;
min-width: min(340px, calc(100vw - 32px));
}
.message-bubble {
max-width: 100%;
}
+506 -13
View File
@@ -137,6 +137,7 @@
const text = getMessageDisplay(msg);
const entry = createMessage(msg.role, text);
if (entry && msg.role === 'assistant') {
entry.sources = msg.sources || null;
updateMessage(entry, text, true);
}
}
@@ -572,9 +573,189 @@
});
}
function getInlineCitationLabel(link) {
if (!link || typeof link !== 'object') return '';
const hostname = normalizeSourceText(link.hostname || getSourceHostname(link.href || ''));
if (hostname) return hostname;
const label = normalizeSourceText(link.label || '');
if (label) return label;
return normalizeSourceText(link.href || '');
}
function buildInlineCitationChip(links) {
const items = Array.isArray(links) ? links.filter(Boolean) : [];
if (!items.length) return '';
const first = items[0];
const href = escapeHtml(String(first.href || '').trim());
const label = escapeHtml(getInlineCitationLabel(first));
if (!href || !label) return '';
const extraCount = items.length - 1;
const titles = items
.map((item) => normalizeSourceText(item.label || item.hostname || item.href || ''))
.filter(Boolean);
const titleAttr = titles.length ? ` title="${escapeHtml(titles.join('\n'))}"` : '';
return `<span class="inline print-hidden"><span class="inline"><a href="${href}" target="_blank" rel="noopener noreferrer nofollow" class="citation inline-citation-chip no-copy inline text-nowrap print-hidden"${titleAttr} data-state="closed"><span class="inline-citation-chip__label">${label}</span>${extraCount > 0 ? `<span class="inline-citation-chip__count">+${extraCount}</span>` : ''}</a></span></span>`;
}
function renderInlineCitationTokens(value, htmlLinks) {
return value.replace(/(?:@@HTMLLINK_\d+@@(?:\s|&nbsp;|\u00a0|\u2060)*)+/g, (match) => {
const indices = Array.from(match.matchAll(/@@HTMLLINK_(\d+)@@/g))
.map((part) => Number(part[1]))
.filter((index) => Number.isFinite(index));
const links = [];
const seen = new Set();
indices.forEach((index) => {
const item = htmlLinks[index];
const href = String(item && item.href || '').trim();
if (!href || seen.has(href)) return;
seen.add(href);
links.push(item);
});
if (!links.length) return '';
const grouped = [];
let currentGroup = [];
let currentKey = '';
links.forEach((item) => {
const key = normalizeSourceText(item.hostname || item.href || '');
if (!currentGroup.length || key === currentKey) {
currentGroup.push(item);
currentKey = key;
return;
}
grouped.push(currentGroup);
currentGroup = [item];
currentKey = key;
});
if (currentGroup.length) grouped.push(currentGroup);
return grouped.map((group) => buildInlineCitationChip(group)).join('');
});
}
function buildStructuredSourceCandidates(sources) {
const candidates = [];
const seen = new Set();
const citations = Array.isArray(sources && sources.citations) ? sources.citations : [];
const groups = Array.isArray(sources && sources.groups) ? sources.groups : [];
const pushCandidate = (item) => {
const href = String(item && item.url || '').trim();
if (!href || seen.has(href)) return;
seen.add(href);
const hostname = getSourceHostname(href);
const label = normalizeSourceText(item && item.title || '') || hostname || href;
const preview = normalizeSourceText(item && item.preview || '');
candidates.push({ href, hostname, label, preview });
};
citations.forEach(pushCandidate);
groups.forEach((group) => {
const results = Array.isArray(group && group.results) ? group.results : [];
results.forEach(pushCandidate);
});
return candidates;
}
function extractCitationTerms(value) {
return (String(value || '').toLowerCase().match(/[a-z0-9][a-z0-9.-]{2,}|[\u4e00-\u9fff]{2,}/g) || [])
.map((term) => term.trim())
.filter((term) => term && !/^(模型|图片|图像|生成|支持|能力|介绍|版本|官方|页面|blog)$/.test(term));
}
function scoreSourceForText(text, candidate) {
const haystack = normalizeSourceText(text || '').toLowerCase();
if (!haystack) return 0;
const terms = [
...extractCitationTerms(candidate && candidate.label || ''),
...extractCitationTerms(candidate && candidate.preview || '')
];
let score = 0;
const seen = new Set();
terms.forEach((term) => {
if (!term || seen.has(term)) return;
seen.add(term);
if (haystack.includes(term)) {
score += term.length >= 6 ? 3 : 2;
}
});
const hostname = String(candidate && candidate.hostname || '').toLowerCase();
if (hostname && haystack.includes(hostname.replace(/\.[a-z]+$/, ''))) {
score += 2;
}
return score;
}
function buildStructuredInlineCitationHtml(items) {
const group = Array.isArray(items) ? items.filter(Boolean) : [];
if (!group.length) return '';
return buildInlineCitationChip(group);
}
function applyStructuredInlineCitations(root, sources) {
if (!root || !sources || typeof sources !== 'object') return;
if (root.querySelector('.inline-citation-chip')) return;
const candidates = buildStructuredSourceCandidates(sources);
if (!candidates.length) return;
const blocks = Array.from(root.querySelectorAll('p, li'))
.filter((node) => node instanceof HTMLElement)
.filter((node) => !node.querySelector('.inline-citation-chip'))
.map((node) => ({ node, text: normalizeSourceText(node.textContent || '') }))
.filter((item) => item.text.length >= 24);
if (!blocks.length) return;
const used = new Set();
blocks.forEach(({ node, text }) => {
let best = null;
let bestScore = 0;
candidates.forEach((candidate) => {
if (used.has(candidate.href)) return;
const score = scoreSourceForText(text, candidate);
if (score > bestScore) {
bestScore = score;
best = candidate;
}
});
if (!best || bestScore < 2) return;
used.add(best.href);
const html = buildStructuredInlineCitationHtml([best]);
if (html) {
node.insertAdjacentHTML('beforeend', html);
}
});
if (!used.size) {
const fallback = blocks[0] && blocks[0].node;
const html = buildStructuredInlineCitationHtml([candidates[0]]);
if (fallback && html) {
fallback.insertAdjacentHTML('beforeend', html);
}
}
}
function renderBasicMarkdown(rawText) {
const text = (rawText || '').replace(/\\n/g, '\n');
const escaped = escapeHtml(text);
const htmlLinks = [];
const normalizedText = text
.replace(/<\/?span\b[^>]*>/gi, '')
.replace(/<a\b[^>]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (match, quote, href, inner) => {
const label = String(inner || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.replace(/\u2060/g, '')
.trim();
const token = `@@HTMLLINK_${htmlLinks.length}@@`;
htmlLinks.push({
href: String(href || '').trim(),
label,
hostname: getSourceHostname(href)
});
return token;
});
const escaped = escapeHtml(normalizedText);
const codeBlocks = [];
const fenced = escaped.replace(/```([a-zA-Z0-9_-]+)?\n([\s\S]*?)```/g, (match, lang, code) => {
const safeLang = lang ? escapeHtml(lang) : '';
@@ -594,7 +775,10 @@
output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => {
const safeAlt = escapeHtml(alt || 'image');
const safeUrl = escapeHtml(url || '');
return `<img src="${safeUrl}" alt="${safeAlt}" loading="lazy">`;
const caption = safeAlt && safeAlt !== 'image'
? `<figcaption class="message-image-caption">${safeAlt}</figcaption>`
: '';
return `<figure class="message-image-card"><img src="${safeUrl}" alt="${safeAlt}" loading="lazy">${caption}</figure>`;
});
output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
@@ -603,6 +787,8 @@
return `<a href="${safeUrl}" target="_blank" rel="noopener">${safeLabel}</a>`;
});
output = renderInlineCitationTokens(output, htmlLinks);
return output;
};
@@ -955,6 +1141,7 @@
contentNode,
role,
raw: content || '',
sources: null,
committed: false,
startedAt: Date.now(),
firstTokenAt: null,
@@ -1025,6 +1212,9 @@
if (node.tagName === 'IMG') {
return { items: [node], removeNode: null };
}
if (node.tagName === 'FIGURE' && node.classList.contains('message-image-card')) {
return { items: [node], removeNode: null };
}
if (isImageLink(node)) {
return { items: [node], removeNode: null };
}
@@ -1038,7 +1228,11 @@
return null;
}
if (child.nodeType === Node.ELEMENT_NODE) {
if (child.tagName === 'IMG' || isImageLink(child)) {
if (
child.tagName === 'IMG' ||
isImageLink(child) ||
(child.tagName === 'FIGURE' && child.classList.contains('message-image-card'))
) {
items.push(child);
continue;
}
@@ -1254,6 +1448,9 @@
updateThinkSummary(entry, entry.thinkElapsed);
}
if (entry.role === 'assistant' || entry.role === 'user') {
if (entry.role === 'assistant') {
applyStructuredInlineCitations(entry.contentNode, entry.sources);
}
liftThinkImages(entry.contentNode);
applyImageGrid(entry.contentNode);
enhanceBrokenImages(entry.contentNode);
@@ -1329,6 +1526,285 @@
});
}
function normalizeSourceText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function getSourceHostname(url) {
try {
return new URL(url).hostname.replace(/^www\./i, '');
} catch (e) {
return '';
}
}
function getSourceFavicon(hostname) {
if (!hostname) return '';
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=256`;
}
function cleanExtractedUrl(url) {
return String(url || '').trim().replace(/[),.;]+$/g, '');
}
function extractUrlsFromText(text) {
const raw = String(text || '');
const matches = raw.match(/https?:\/\/[^\s"'<>]+/g) || [];
return matches.map(cleanExtractedUrl).filter((url) => /^https?:\/\//i.test(url));
}
function extractAssistantSources(root) {
if (!root || !root.querySelectorAll) {
return { links: [], searches: [] };
}
const rows = Array.from(root.querySelectorAll('.think-item-row'));
const links = [];
const searches = [];
const seenLinks = new Set();
const seenSearches = new Set();
const pushLink = (item) => {
const url = String(item && item.url || '').trim();
if (!url || seenLinks.has(url)) return;
seenLinks.add(url);
links.push(item);
};
const pushSearch = (item) => {
const label = String(item && item.label || '').trim();
if (!label || seenSearches.has(label)) return;
seenSearches.add(label);
searches.push(item);
};
rows.forEach((row) => {
const typeNode = row.querySelector('.think-item-type');
const bodyNode = row.querySelector('.think-item-body');
const type = String(typeNode && typeNode.dataset && typeNode.dataset.type || '').trim().toLowerCase();
if (!bodyNode || !type) return;
if (type === 'websearch' || type === 'searchimage') {
const firstParagraph = bodyNode.querySelector('p');
const queryText = normalizeSourceText(firstParagraph ? firstParagraph.textContent : bodyNode.textContent || '');
const compactQuery = queryText.split(/\s{2,}|\n/)[0].slice(0, 140).trim();
if (compactQuery) {
pushSearch({
type: 'search',
label: compactQuery,
meta: type === 'searchimage' ? '已搜索图片' : '已搜索的网络'
});
}
}
const links = Array.from(bodyNode.querySelectorAll('a[href]'));
links.forEach((link) => {
const url = String(link.getAttribute('href') || '').trim();
if (!/^https?:\/\//i.test(url)) return;
const hostname = getSourceHostname(url);
pushLink({
type: 'visit',
label: hostname || url,
meta: '已浏览',
url,
hostname
});
});
extractUrlsFromText(bodyNode.textContent || '').forEach((url) => {
const hostname = getSourceHostname(url);
pushLink({
type: 'visit',
label: hostname || url,
meta: '已浏览',
url,
hostname
});
});
});
return { links, searches };
}
function createSourcesWidget(entry) {
const structured = entry && entry.sources && typeof entry.sources === 'object' ? entry.sources : null;
const groups = Array.isArray(structured && structured.groups) ? structured.groups : [];
const citations = Array.isArray(structured && structured.citations) ? structured.citations : [];
const data = (!groups.length && !citations.length)
? extractAssistantSources(entry && entry.contentNode)
: { links: [], searches: [] };
const sources = Array.isArray(data && data.links) ? data.links.slice() : [];
const searches = Array.isArray(data && data.searches) ? data.searches.slice() : [];
const seenLinks = new Set(sources.map((item) => String(item && item.url || '').trim()).filter(Boolean));
citations.forEach((item) => {
const url = String(item && item.url || '').trim();
if (!url || seenLinks.has(url)) return;
const hostname = getSourceHostname(url);
seenLinks.add(url);
sources.unshift({
type: 'citation',
label: hostname || url,
meta: '引用来源',
url,
hostname
});
});
groups.forEach((group) => {
const query = normalizeSourceText(group && group.query || '');
if (query) {
searches.push({
type: 'search',
label: query,
meta: group.kind === 'search_images' ? '图片搜索' : '网络搜索'
});
}
const results = Array.isArray(group && group.results) ? group.results : [];
results.forEach((item) => {
const url = String(item && item.url || '').trim();
if (!url || seenLinks.has(url)) return;
const hostname = getSourceHostname(url);
seenLinks.add(url);
sources.push({
type: 'visit',
label: normalizeSourceText(item && item.title || '') || hostname || url,
meta: hostname || '搜索结果',
url,
hostname
});
});
});
if (!sources.length && !searches.length) return null;
const wrapper = document.createElement('details');
wrapper.className = 'sources-widget';
const summary = document.createElement('summary');
summary.className = 'sources-chip';
const summaryCount = sources.length || searches.length;
const summaryLabel = sources.length ? `${summaryCount} sources` : `${summaryCount} searches`;
summary.setAttribute('aria-label', summaryLabel);
const iconStack = document.createElement('div');
iconStack.className = 'sources-icons';
const faviconHosts = [];
sources.forEach((item) => {
if (item.hostname && !faviconHosts.includes(item.hostname)) {
faviconHosts.push(item.hostname);
}
});
faviconHosts.slice(0, 3).forEach((hostname, index) => {
const badge = document.createElement('div');
badge.className = 'sources-icon-badge';
badge.style.zIndex = String(3 - index);
const img = document.createElement('img');
img.src = getSourceFavicon(hostname);
img.alt = '';
img.setAttribute('role', 'presentation');
badge.appendChild(img);
iconStack.appendChild(badge);
});
if (!iconStack.childNodes.length) {
const fallback = document.createElement('div');
fallback.className = 'sources-icon-fallback';
fallback.textContent = 'S';
iconStack.appendChild(fallback);
}
summary.appendChild(iconStack);
const label = document.createElement('div');
label.className = 'sources-chip-label';
label.textContent = summaryLabel;
summary.appendChild(label);
const panel = document.createElement('div');
panel.className = 'sources-panel';
if (sources.length) {
const title = document.createElement('div');
title.className = 'sources-section-title';
title.textContent = '可验证来源';
panel.appendChild(title);
}
sources.forEach((item) => {
const row = document.createElement(item.url ? 'a' : 'div');
row.className = 'source-row';
if (item.url) {
row.href = item.url;
row.target = '_blank';
row.rel = 'noopener noreferrer nofollow';
}
const icon = document.createElement('div');
icon.className = 'source-row-icon';
if (item.hostname) {
const img = document.createElement('img');
img.src = getSourceFavicon(item.hostname);
img.alt = '';
img.setAttribute('role', 'presentation');
icon.appendChild(img);
} else {
icon.textContent = item.type === 'search' ? 'Q' : 'L';
}
const textWrap = document.createElement('div');
textWrap.className = 'source-row-text';
const meta = document.createElement('span');
meta.className = 'source-row-meta';
meta.textContent = item.meta || '来源';
const title = document.createElement('span');
title.className = 'source-row-title';
title.textContent = item.label || item.url || '';
textWrap.appendChild(meta);
textWrap.appendChild(title);
row.appendChild(icon);
row.appendChild(textWrap);
panel.appendChild(row);
});
if (searches.length) {
const title = document.createElement('div');
title.className = 'sources-section-title';
title.textContent = '搜索轨迹';
panel.appendChild(title);
}
searches.forEach((item) => {
const row = document.createElement('div');
row.className = 'source-row is-query';
const icon = document.createElement('div');
icon.className = 'source-row-icon';
icon.textContent = 'Q';
const textWrap = document.createElement('div');
textWrap.className = 'source-row-text';
const meta = document.createElement('span');
meta.className = 'source-row-meta';
meta.textContent = item.meta || '搜索轨迹';
const title = document.createElement('span');
title.className = 'source-row-title';
title.textContent = item.label || '';
textWrap.appendChild(meta);
textWrap.appendChild(title);
row.appendChild(icon);
row.appendChild(textWrap);
panel.appendChild(row);
});
wrapper.appendChild(summary);
wrapper.appendChild(panel);
return wrapper;
}
function clearChat() {
messageHistory = [];
if (chatLog) {
@@ -1696,8 +2172,11 @@
function attachAssistantActions(entry) {
if (!entry || !entry.row) return;
const existing = entry.row.querySelector('.message-actions');
if (existing) existing.remove();
const actions = document.createElement('div');
actions.className = 'message-actions';
const sourcesWidget = createSourcesWidget(entry);
const retryBtn = createActionButton('重试', '重试上一条回答', () => retryLast());
const copyBtn = createActionButton('复制', '复制回答内容', () => copyToClipboard(entry.raw || ''));
@@ -1705,6 +2184,7 @@
window.open(feedbackUrl, '_blank', 'noopener');
});
if (sourcesWidget) actions.appendChild(sourcesWidget);
actions.appendChild(retryBtn);
actions.appendChild(copyBtn);
actions.appendChild(feedbackBtn);
@@ -1788,9 +2268,9 @@
if (!assistantEntry.committed) {
assistantEntry.committed = true;
if (retrySessionId) {
commitToSession(retrySessionId, assistantEntry.raw || '');
commitToSession(retrySessionId, assistantEntry.raw || '', assistantEntry.sources);
} else {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '', sources: assistantEntry.sources || null });
}
}
setStatus('error', '已中止');
@@ -1883,9 +2363,9 @@
if (!assistantEntry.committed) {
assistantEntry.committed = true;
if (sendSessionId) {
commitToSession(sendSessionId, assistantEntry.raw || '');
commitToSession(sendSessionId, assistantEntry.raw || '', assistantEntry.sources);
} else {
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '' });
messageHistory.push({ role: 'assistant', content: assistantEntry.raw || '', sources: assistantEntry.sources || null });
}
}
} else {
@@ -1900,11 +2380,15 @@
}
}
function commitToSession(sessionId, assistantText) {
function commitToSession(sessionId, assistantText, assistantSources = null) {
if (!sessionId || !sessionsData) return;
const session = sessionsData.sessions.find((s) => s.id === sessionId);
if (!session) return;
session.messages.push({ role: 'assistant', content: assistantText });
session.messages.push({
role: 'assistant',
content: assistantText,
sources: assistantSources || null
});
if (session.messages.length > MAX_CONTEXT_MESSAGES) {
session.messages = session.messages.slice(-MAX_CONTEXT_MESSAGES);
}
@@ -1948,14 +2432,23 @@
}
assistantEntry.committed = true;
if (targetSessionId) {
commitToSession(targetSessionId, assistantText);
commitToSession(targetSessionId, assistantText, assistantEntry.sources);
} else {
messageHistory.push({ role: 'assistant', content: assistantText });
messageHistory.push({ role: 'assistant', content: assistantText, sources: assistantEntry.sources || null });
}
return;
}
try {
const json = JSON.parse(payload);
if (json && json.sources && typeof json.sources === 'object') {
assistantEntry.sources = json.sources;
if (assistantEntry.contentNode) {
applyStructuredInlineCitations(assistantEntry.contentNode, assistantEntry.sources);
}
if (assistantEntry.row && assistantEntry.row.querySelector('.message-actions')) {
attachAssistantActions(assistantEntry);
}
}
const delta = json && json.choices && json.choices[0] && json.choices[0].delta
? json.choices[0].delta.content
: '';
@@ -1986,9 +2479,9 @@
}
assistantEntry.committed = true;
if (targetSessionId) {
commitToSession(targetSessionId, assistantText);
commitToSession(targetSessionId, assistantText, assistantEntry.sources);
} else {
messageHistory.push({ role: 'assistant', content: assistantText });
messageHistory.push({ role: 'assistant', content: assistantText, sources: assistantEntry.sources || null });
}
} finally {
activeStreamInfo = null;