feat(chat): stream and persist local answer UI with final report references

This commit is contained in:
2026-10-12 18:54:18 +08:00
parent a31b76a793
commit f5c3cde2c2
20 changed files with 827 additions and 105 deletions
+57
View File
@@ -0,0 +1,57 @@
import type { UIMessageChunk } from "ai";
/** Canonical parts are projected independently of an attached browser response. */
export function createAnswerStreamProjection(options: {
messageId: string;
sessionId: string;
write: (chunk: UIMessageChunk) => void;
onChange: () => void;
}) {
const parts: Record<string, unknown>[] = [];
let segment = 0;
let textId = "answer";
let currentText: Record<string, unknown> | undefined;
let finished = false;
const endText = () => {
if (!currentText) return;
currentText.state = "done";
options.write({ type: "text-end", id: textId });
currentText = undefined;
textId = `answer-${++segment}`;
};
const appendText = (delta: string) => {
if (!delta || finished) return;
if (!currentText) {
currentText = { type: "text", text: "", state: "streaming" };
parts.push(currentText);
options.write({ type: "text-start", id: textId });
}
currentText.text = `${String(currentText.text)}${delta}`;
options.write({ type: "text-delta", id: textId, delta });
options.write({ type: "data-stream_token", transient: true, data: {
session_id: options.sessionId, message_id: options.messageId, content: delta,
} } as UIMessageChunk);
options.onChange();
};
const appendData = (event: string, data: Record<string, unknown>) => {
if (finished) return;
if (event === "answer_ui") endText();
const type = `data-${event}`;
const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string");
const existing = id === undefined ? -1 : parts.findIndex((part) => part.type === type && part.id === id);
const part = id ? { type, id, data } : { type, data };
if (existing >= 0) parts[existing] = part;
else parts.push(part);
options.write(part as UIMessageChunk);
options.onChange();
};
const finish = (finishReason: "stop" | "error" = "stop") => {
if (finished) return;
if (!parts.length) appendText("Agent 已完成处理。");
endText();
finished = true;
options.onChange();
options.write({ type: "finish", finishReason });
};
return { parts, appendText, appendData, finish, get finished() { return finished; } };
}