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"\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 `
`;
+ const caption = safeAlt && safeAlt !== 'image'
+ ? `${safeAlt}`
+ : '';
+ return `
${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;