feat(agent): render local interactive answers and fold completed process

This commit is contained in:
2026-10-12 18:54:18 +08:00
parent e0eb6a721e
commit 2c5bf15cce
30 changed files with 1610 additions and 75 deletions
@@ -1,6 +1,8 @@
import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
parseAnswerUiBlock,
type AnswerUiPart,
applyPermissionResponse,
agentTaskContextReferenceSchema,
applyQuestionResponse,
@@ -20,6 +22,7 @@ type AgentUiDataParts = {
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
final_answer: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
permission_response: Record<string, unknown>;
@@ -29,6 +32,7 @@ type AgentUiDataParts = {
tool_call: Record<string, unknown>;
frontend_action: Record<string, unknown>;
frontend_action_result: Record<string, unknown>;
answer_ui: Record<string, unknown>;
ui_envelope: Record<string, unknown>;
session_title: Record<string, unknown>;
};
@@ -115,10 +119,12 @@ export function toAgentChatMessages(
return [];
}
const answerParts = collectAnswerParts(message);
let next: AgentChatMessage = {
id: message.id,
role: message.role,
content: collectMessageText(message)
content: answerParts ? answerParts.map((part) => part.type === "text" ? part.text : part.block.fallback_text).join("\n\n") : collectMessageText(message),
...(answerParts ? { answerParts } : {})
};
for (const part of message.parts) {
@@ -128,6 +134,13 @@ export function toAgentChatMessages(
if (part.type === "data-task_context") {
const parsed = agentTaskContextReferenceSchema.safeParse(part.data);
if (parsed.success) next = { ...next, taskContext: parsed.data };
} else if (part.type === "data-final_answer") {
const content = getDataString(part.data, "content");
if (content?.trim()) {
const refs = part.data?.ui_block_ids;
const uiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string"))].slice(0, 48) : [];
next = { ...next, finalAnswer: { content, uiBlockIds } };
}
} else if (part.type === "data-progress") {
next = {
...next,
@@ -164,6 +177,10 @@ export function toAgentChatMessages(
}
}
if (next.finalAnswer) {
const selected = new Set(next.finalAnswer.uiBlockIds);
next.content = [next.finalAnswer.content, ...(next.answerParts ?? []).flatMap((part) => part.type === "answer-ui" && selected.has(part.block.block_id) ? [part.block.fallback_text] : [])].join("\n\n");
}
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
});
}
@@ -189,6 +206,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
}
if (
event.type === "final_answer" ||
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
@@ -196,6 +214,7 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
event.type === "permission_response" ||
event.type === "question_request" ||
event.type === "question_response" ||
event.type === "answer_ui" ||
event.type === "ui_envelope" ||
event.type === "session_title"
) {
@@ -374,11 +393,15 @@ function createAssistantUiMessage(): AgentUiMessage {
}
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
const textIndex = message.parts.findIndex((part) => part.type === "text");
let textIndex = -1;
for (let index = message.parts.length - 1; index >= 0; index -= 1) {
if (message.parts[index].type === "text") { textIndex = index; break; }
}
if (message.parts.slice(textIndex + 1).some((part) => part.type === "data-answer_ui")) textIndex = -1;
if (textIndex === -1) {
return {
...message,
parts: [{ type: "text", text }, ...message.parts]
parts: [...message.parts, { type: "text", text }]
};
}
@@ -401,7 +424,8 @@ function upsertUiDataPart(
const id =
getDataString(data, "id") ??
getDataString(data, "request_id") ??
getDataString(data, "envelope_id");
getDataString(data, "envelope_id") ??
getDataString(data, "block_id");
const existingIndex =
id === undefined
? -1
@@ -479,3 +503,20 @@ function collectMessageText(message: AgentUiMessage) {
function isAgentDataPart(part: AgentUiMessage["parts"][number]): part is AgentDataPart {
return typeof part.type === "string" && part.type.startsWith("data-") && "data" in part;
}
function collectAnswerParts(message: AgentUiMessage): AnswerUiPart[] | undefined {
if (!message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer")) return undefined;
const result: AnswerUiPart[] = [];
for (const part of message.parts) {
if (part.type === "text" && part.text) result.push({ type: "text", text: part.text });
if (part.type === "data-answer_ui") {
const data = typeof part.data === "object" && part.data !== null ? part.data : {};
const { version, block_id, fallback_text, spec } = data;
const block = parseAnswerUiBlock({ version, block_id, fallback_text, spec });
if (block) result.push({ type: "answer-ui", block });
else result.push({ type: "text", text: typeof fallback_text === "string" ? fallback_text.slice(0, 4000) : "这段结构化内容无法显示。" });
}
}
return result;
}