feat: improve chat source rendering
This commit is contained in:
1 parent
79af9c63eb
commit
fba7a65a1a
3 files changed
+999
-32
No files matched your search
@@ -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,
|
||||
|
||||
+254
-18
@@ -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
@@ -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| |\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(/ /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;
|
||||
|
||||
Reference in new issue
Block a user