From fba7a65a1a754777aab3cff05ca797690ce4dcf7 Mon Sep 17 00:00:00 2001 From: XianYuDaXian Date: Tue, 24 Mar 2026 18:22:16 +0800 Subject: [PATCH] feat: improve chat source rendering --- app/services/grok/services/chat.py | 240 ++++++++++++- app/static/public/css/chat.css | 272 ++++++++++++++- app/static/public/js/chat.js | 519 ++++++++++++++++++++++++++++- 3 files changed, 999 insertions(+), 32 deletions(-) diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index bae64e5..e7afa90 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -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, diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css index 467605e..5378783 100644 --- a/app/static/public/css/chat.css +++ b/app/static/public/css/chat.css @@ -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%; } diff --git a/app/static/public/js/chat.js b/app/static/public/js/chat.js index 64c9f58..b83133c 100644 --- a/app/static/public/js/chat.js +++ b/app/static/public/js/chat.js @@ -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 `${label}${extraCount > 0 ? `+${extraCount}` : ''}`; + } + + function renderInlineCitationTokens(value, htmlLinks) { + return value.replace(/(?:@@HTMLLINK_\d+@@(?:\s| |\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(/]*href=(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (match, quote, href, inner) => { + const label = String(inner || '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /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 `${safeAlt}`; + const caption = safeAlt && safeAlt !== 'image' + ? `
${safeAlt}
` + : ''; + return `
${safeAlt}${caption}
`; }); output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => { @@ -603,6 +787,8 @@ return `${safeLabel}`; }); + 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;