fix: unify chat process and reply presentation
This commit is contained in:
@@ -23,7 +23,7 @@ export function AnswerPartsView({
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-answer-parts>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "answer-ui" ? (
|
||||
<AnswerUiView
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentChatMessage } from "../types";
|
||||
import { getAnswerPresentation } from "./answer-presentation";
|
||||
import { canExpandAnswer, getAnswerPresentation, getFinalAnswerTranscript } from "./answer-presentation";
|
||||
import type { AnswerUiPart } from "./contract";
|
||||
|
||||
const ui = (id: string): AnswerUiPart => ({
|
||||
@@ -28,6 +28,17 @@ const message: AgentChatMessage = {
|
||||
answerParts: parts
|
||||
};
|
||||
describe("final report and collapsible process", () => {
|
||||
it("keeps short warnings and location confirmations inline while retaining report readers", () => {
|
||||
const note = ui("note");
|
||||
if (note.type !== "answer-ui") throw new Error("invalid fixture");
|
||||
note.block.spec.elements.text.props = { text: "当前工具不能确认高亮。", tone: "warning" };
|
||||
expect(canExpandAnswer([{ type: "text", text: "已定位管道。" }, note])).toBe(false);
|
||||
expect(canExpandAnswer([{ type: "text", text: "完整报告".repeat(300) }])).toBe(true);
|
||||
for (const type of ["Table", "Chart", "FollowUpForm"]) {
|
||||
note.block.spec.elements.text.type = type;
|
||||
expect(canExpandAnswer([note])).toBe(true);
|
||||
}
|
||||
});
|
||||
it("shows all incremental content while waiting for final_answer", () => {
|
||||
expect(getAnswerPresentation(message)).toEqual({ report: parts, process: [], final: false });
|
||||
});
|
||||
@@ -52,3 +63,68 @@ describe("final report and collapsible process", () => {
|
||||
expect(view.process).toEqual(parts);
|
||||
});
|
||||
});
|
||||
|
||||
function phasedUi(id: string, phase: "process" | "final"): AnswerUiPart {
|
||||
const part = ui(id);
|
||||
if (part.type === "answer-ui") {
|
||||
part.block.phase = phase;
|
||||
part.block.fallback_text = `摘要 ${id}`;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
describe("final UI anchors", () => {
|
||||
const chart = phasedUi("chart", "final");
|
||||
const progress = phasedUi("progress", "process");
|
||||
it("separates explicit process blocks from the report before a final answer arrives", () => {
|
||||
const view = getAnswerPresentation({ ...message, answerParts: [parts[0], progress, chart, parts[3]] });
|
||||
expect(view.report).toEqual([parts[0], chart, parts[3]]);
|
||||
expect(view.process).toEqual([progress]);
|
||||
expect(view.final).toBe(false);
|
||||
});
|
||||
const anchoredMessage = (content: string, uiBlockIds = ["chart", "progress"]): AgentChatMessage => ({
|
||||
...message,
|
||||
answerParts: [parts[0], progress, chart, parts[3]],
|
||||
finalAnswer: { content, uiBlockIds }
|
||||
});
|
||||
|
||||
it("places final UI between the original Markdown sections and mounts duplicates once", () => {
|
||||
const content = "# 分析\n\n- 第一项\n\n[[ui:chart]]\n\n## 建议\n保留换行\n[[ui:chart]]\n";
|
||||
const view = getAnswerPresentation(anchoredMessage(content));
|
||||
expect(view.report).toEqual([
|
||||
{ type: "text", text: "# 分析\n\n- 第一项\n\n" },
|
||||
chart,
|
||||
{ type: "text", text: "\n## 建议\n保留换行\n" }
|
||||
]);
|
||||
expect(view.process).toEqual([parts[0], progress, parts[3]]);
|
||||
expect(getFinalAnswerTranscript(anchoredMessage(content))).toBe("# 分析\n\n- 第一项\n\n摘要 chart\n## 建议\n保留换行\n");
|
||||
});
|
||||
|
||||
it("never includes process UI even when referenced and anchored", () => {
|
||||
const value = anchoredMessage("结论\n[[ui:progress]]\n[[ui:missing]]\n[[ui:chart]]", ["progress", "missing"]);
|
||||
const view = getAnswerPresentation(value);
|
||||
expect(view.report.every((part) => part.type === "text")).toBe(true);
|
||||
expect(view.process).toEqual([parts[0], progress, chart, parts[3]]);
|
||||
const transcript = getFinalAnswerTranscript(value);
|
||||
expect(transcript).not.toContain("progress");
|
||||
expect(transcript).not.toContain("missing");
|
||||
expect(transcript).not.toContain("chart");
|
||||
expect(transcript).toContain("暂不可用");
|
||||
});
|
||||
|
||||
it("preserves fenced, indented, and quoted markers as literal Markdown", () => {
|
||||
const literal = "```md\n[[ui:chart]]\n```\n\n~~~\n[[ui:chart]]\n~~~\n\n [[ui:chart]]\n\n> [[ui:chart]]\n";
|
||||
const view = getAnswerPresentation(anchoredMessage(literal, []));
|
||||
expect(view.report).toEqual([{ type: "text", text: literal }]);
|
||||
});
|
||||
|
||||
it("retains legacy references and appends final references without anchors", () => {
|
||||
const view = getAnswerPresentation(anchoredMessage("报告"));
|
||||
expect(view.report).toEqual([{ type: "text", text: "报告" }, chart]);
|
||||
expect(getFinalAnswerTranscript(anchoredMessage("报告"))).toBe("报告\n\n摘要 chart");
|
||||
});
|
||||
|
||||
it("does not expand a short answer because of marker identifiers", () => {
|
||||
expect(canExpandAnswer([{ type: "text", text: `结论\n[[ui:${"x".repeat(1500)}]]` }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const payloadSchema = z
|
||||
.object({
|
||||
version: z.literal(ANSWER_UI_VERSION),
|
||||
block_id: key,
|
||||
phase: z.enum(["process", "final"]).optional(),
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: specSchema
|
||||
})
|
||||
@@ -20,6 +21,7 @@ const payloadSchema = z
|
||||
export const storedAnswerUiBlockSchema = z.object({
|
||||
version: z.string(),
|
||||
block_id: key,
|
||||
phase: z.enum(["process", "final"]).optional(),
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: z.unknown()
|
||||
});
|
||||
@@ -32,6 +34,7 @@ export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
|
||||
export type AnswerUiBlock = {
|
||||
version: typeof ANSWER_UI_VERSION;
|
||||
block_id: string;
|
||||
phase?: "process" | "final";
|
||||
fallback_text: string;
|
||||
spec: Spec;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user