fix(chat): fold tool_call main-channel text and fix aggregate row loss

- _buildRenderRows: include assistant content for event_type=tool_call as
  assistant_text items with assistantEventType=tool_call (was omitted).
- Tag other assistant_text slices with assistantEventType for clarity.
- _buildAggregatedAssistantBubble: for tool_call slices, add non-redundant
  main-channel text to the reasoning fold under reasoning.processNotes;
  skip when already covered by reasoning blocks (whitespace-normalized
  substring). Inline markdown for those process lines is omitted to avoid
  duplicate display. Bump chat.js cache query.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-13 00:02:18 +08:00
parent 5efac74f87
commit 4ea26a022b
2 changed files with 48 additions and 5 deletions

View file

@ -574,7 +574,7 @@
<script defer src="/admin/assets/admin-theme.js"></script>
<script
defer
src="/admin/assets/chat.js?v=20260511-1"
src="/admin/assets/chat.js?v=20260511-2"
onerror="(function(){var s=document.getElementById('chat-boot-splash');if(s){s.querySelector('.chat-boot-splash__title span:last-child').textContent='无法加载 chat.js';s.querySelector('.chat-boot-splash__muted').textContent='请确认网关已启动且 /admin/assets/chat.js 可访问。';}})()"
></script>
</head>

View file

@ -57,6 +57,7 @@ const I18N = {
"chat.marker.reclaimed": "本轮回收 turn 标记 {n}",
"chat.historyTruncated": "仅显示最近 {shown} 条,共 {total} 条",
"reasoning.summary": "模型推理片段",
"reasoning.processNotes": "主通道过程说明",
"tool.streamTitle": "工具与进度(本轮)",
"auth.login": "登录",
"auth.logout": "退出登录",
@ -235,6 +236,7 @@ const I18N = {
"chat.marker.reclaimed": "Turn markers reclaimed {n}",
"chat.historyTruncated": "Showing last {shown} of {total} messages",
"reasoning.summary": "Model reasoning",
"reasoning.processNotes": "Main-channel notes",
"tool.streamTitle": "Tools & progress (this turn)",
"auth.login": "Login",
"auth.logout": "Logout",
@ -482,6 +484,25 @@ function _appendAssistantTextSegments(inner, rawText, collapsedItems) {
}
}
function _normFoldDedupText(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/** True when ``processText`` is already covered by text already queued for the reasoning fold. */
function _foldProcessTextRedundant(processText, collapsedItems) {
const p = _normFoldDedupText(processText);
if (!p) return true;
const hay = (Array.isArray(collapsedItems) ? collapsedItems : [])
.map((x) => _normFoldDedupText((x && x.text) || ""))
.join("\n\n");
if (!hay) return false;
if (hay.includes(p)) return true;
return false;
}
async function _buildAggregatedAssistantBubble(tsIso, items) {
const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" });
const collapsedItems = [];
@ -498,9 +519,19 @@ async function _buildAggregatedAssistantBubble(tsIso, items) {
continue;
}
if (kind === "assistant_text") {
_appendAssistantTextSegments(inner, text, collapsedItems);
const attsAssistant = await renderAttachmentsEl(it && it.attachments);
if (attsAssistant) inner.appendChild(attsAssistant);
const aet = String((it && it.assistantEventType) || "assistant_text").toLowerCase();
if (aet === "tool_call" && adminChatShowToolOutput) {
const t0 = String(text || "").trim();
if (t0 && !_foldProcessTextRedundant(t0, collapsedItems)) {
collapsedItems.push({ title: t("reasoning.processNotes"), text: t0 });
}
const attsAssistant = await renderAttachmentsEl(it && it.attachments);
if (attsAssistant) inner.appendChild(attsAssistant);
} else {
_appendAssistantTextSegments(inner, text, collapsedItems);
const attsAssistant = await renderAttachmentsEl(it && it.attachments);
if (attsAssistant) inner.appendChild(attsAssistant);
}
} else if (kind === "reasoning") {
if (adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text });
} else if (kind === "tool_call") {
@ -605,11 +636,23 @@ function _buildRenderRows(msgs) {
if (String(content || "").trim()) {
agg._items.push({ kind: "reasoning", text: content });
}
} else if (eventType === "tool_call") {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = { kind: "assistant_text", text: content, assistantEventType: "tool_call" };
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
} else if (eventType === "assistant_text" || eventType === "assistant" || !eventType) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
const piece = { kind: "assistant_text", text: content };
const piece = {
kind: "assistant_text",
text: content,
assistantEventType: eventType || "assistant_text",
};
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}