fix: unify chat process and reply presentation

This commit is contained in:
2026-10-29 17:54:34 +08:00
parent 7621bf0f86
commit 9474788db6
46 changed files with 3160 additions and 500 deletions
@@ -1,25 +1,103 @@
import type { AgentChatMessage } from "../types";
import type { AnswerUiPart } from "./contract";
/** Short notes and map confirmations stay inline; dense data and reports can expand. */
export function canExpandAnswer(parts: AnswerUiPart[]): boolean {
let textLength = 0;
for (const part of parts) {
if (part.type === "text") {
textLength += part.text.replace(/\[\[ui:[^\]\r\n]*\]\]/g, "").length;
} else {
for (const node of Object.values(part.block.spec.elements)) {
if (["Table", "Chart", "FollowUpForm"].includes(node.type)) return true;
if (node.type === "Text" && typeof node.props.text === "string") textLength += node.props.text.length;
if (node.type === "Evidence" && typeof node.props.summary === "string") textLength += node.props.summary.length;
if (node.type === "Result" && typeof node.props.summary === "string") textLength += node.props.summary.length;
}
}
}
return textLength >= 1200;
}
/** Readable copy/TTS text for the same ordered parts shown in the answer. */
export function getAnswerPartsTranscript(parts: AnswerUiPart[]): string {
return parts.reduce((text, part) => {
const next = part.type === "text" ? part.text : part.block.fallback_text;
if (!text || !next) return text + next;
return text + (text.endsWith("\n") || next.startsWith("\n") ? "" : "\n\n") + next;
}, "");
}
/** Final report transcript excludes process blocks and resolves authorized anchors. */
export function getFinalAnswerTranscript(message: AgentChatMessage): string {
return getAnswerPartsTranscript(getAnswerPresentation(message).report);
}
function splitAnchors(content: string): Array<{ text: string } | { id: string }> {
const result: Array<{ text: string } | { id: string }> = [];
let buffer = "";
let fence: { character: string; length: number } | undefined;
for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
const bare = line.replace(/\r?\n$/, "");
const delimiter = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(bare);
if (fence) {
buffer += line;
if (delimiter && delimiter[1][0] === fence.character && delimiter[1].length >= fence.length && !delimiter[2].trim()) fence = undefined;
continue;
}
if (delimiter) {
fence = { character: delimiter[1][0], length: delimiter[1].length };
buffer += line;
continue;
}
const anchor = /^ {0,3}\[\[ui:([^\]\r\n]*)\]\][ \t]*$/.exec(bare);
if (!anchor) { buffer += line; continue; }
if (buffer.trim()) result.push({ text: buffer });
buffer = "";
result.push({ id: anchor[1] });
}
if (buffer.trim()) result.push({ text: buffer });
return result;
}
export function getAnswerPresentation(message: AgentChatMessage): {
report: AnswerUiPart[];
process: AnswerUiPart[];
final: boolean;
} {
const parts =
message.answerParts ??
const parts = message.answerParts ??
(message.content ? [{ type: "text" as const, text: message.content }] : []);
if (!message.finalAnswer) return { report: parts, process: [], final: false };
const selected = new Set(message.finalAnswer.uiBlockIds);
const report: AnswerUiPart[] = [{ type: "text", text: message.finalAnswer.content }];
for (const id of selected) {
const part = parts.find((item) => item.type === "answer-ui" && item.block.block_id === id);
if (part) report.push(part);
if (!message.finalAnswer) {
const isProcess = (part: AnswerUiPart) => part.type === "answer-ui" && part.block.phase === "process";
return { report: parts.filter((part) => !isProcess(part)), process: parts.filter(isProcess), final: false };
}
const process = parts.filter((part) =>
part.type === "answer-ui"
? !selected.has(part.block.block_id)
: part.text.trim() !== message.finalAnswer!.content.trim()
);
const selected = new Set(message.finalAnswer.uiBlockIds);
const available = new Map(parts.flatMap((part) =>
part.type === "answer-ui" ? [[part.block.block_id, part] as const] : []));
const mounted = new Set<string>();
const anchored = new Set<string>();
const report: AnswerUiPart[] = [];
for (const segment of splitAnchors(message.finalAnswer.content)) {
if ("text" in segment) { report.push({ type: "text", text: segment.text }); continue; }
anchored.add(segment.id);
const part = available.get(segment.id);
if (!selected.has(segment.id) || !part || part.block.phase === "process") {
report.push({ type: "text", text: "交互内容暂不可用。\n" });
} else if (!mounted.has(segment.id)) {
mounted.add(segment.id);
report.push(part);
}
}
// Old reports carried references without positional anchors.
for (const id of selected) {
const part = available.get(id);
if (part && part.block.phase !== "process" && !mounted.has(id) && !anchored.has(id)) {
mounted.add(id);
report.push(part);
}
}
const process = parts.filter((part) => part.type === "answer-ui"
? !mounted.has(part.block.block_id)
: part.text.trim() !== message.finalAnswer!.content.trim());
return { report, process, final: true };
}