Files
next-tjwater-frontend/src/features/workbench/hooks/agent-session-message-state.ts
T
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

538 lines
16 KiB
TypeScript

import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react";
import {
parseAnswerUiBlock,
type AnswerUiPart,
applyPermissionResponse,
agentTaskContextReferenceSchema,
applyQuestionResponse,
toTodoUpdate,
upsertPermission,
upsertProgress,
upsertQuestion,
type AgentChatMessage,
type AgentPermissionReply,
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
} from "@/features/agent";
type AgentUiDataParts = {
session_created: Record<string, unknown>;
agent_message: Record<string, unknown>;
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>;
credential_refresh_failed: Record<string, unknown>;
todo_update: Record<string, unknown>;
permission_request: Record<string, unknown>;
permission_response: Record<string, unknown>;
question_request: Record<string, unknown>;
question_response: Record<string, unknown>;
stream_token: Record<string, unknown>;
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>;
user_input: Record<string, unknown>;
};
export type AgentUiMessage = UIMessage<unknown, AgentUiDataParts>;
export type AgentDataPart = Extract<AgentUiMessage["parts"][number], { type: `data-${string}` }>;
export type PermissionOverride = {
status: AgentPermissionStatus;
error?: string;
};
export type QuestionOverride = {
status: AgentQuestionRequest["status"];
answers?: string[][];
error?: string;
};
type StreamRenderStateSetter = Dispatch<SetStateAction<AgentStreamRenderState>>;
export function markStreamRenderPending(
data: Record<string, unknown>,
messages: AgentUiMessage[],
setStreamRenderState: StreamRenderStateSetter
) {
const text = getDataString(data, "content");
if (!text) {
return;
}
const messageId = getDataString(data, "message_id") ?? getLastAssistantUiMessageId(messages);
if (!messageId) {
return;
}
setStreamRenderState((current) => {
if (current[messageId]?.done === false) {
return current;
}
return {
...current,
[messageId]: { done: false }
};
});
}
export function createCompletedStreamRenderState(messages: AgentUiMessage[]): AgentStreamRenderState {
return messages.reduce<AgentStreamRenderState>((next, message) => {
if (message.role === "assistant") {
next[message.id] = { done: true };
}
return next;
}, {});
}
export function markLastAssistantStreamDone(
messages: AgentUiMessage[],
setStreamRenderState: StreamRenderStateSetter
) {
const messageId = getLastAssistantUiMessageId(messages);
setStreamRenderState((current) => {
if (!messageId) {
return Object.fromEntries(
Object.entries(current).map(([id, state]) => [id, { ...state, done: true }])
);
}
return {
...current,
[messageId]: { done: true }
};
});
}
export function toAgentChatMessages(
messages: AgentUiMessage[],
permissionOverrides: Record<string, PermissionOverride>,
questionOverrides: Record<string, QuestionOverride>
): AgentChatMessage[] {
return messages.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") {
return [];
}
const answerParts = collectAnswerParts(message);
let next: AgentChatMessage = {
id: message.id,
role: message.role,
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) {
if (!isAgentDataPart(part)) {
continue;
}
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,
progress: upsertProgress(next.progress, part.data)
};
} else if (part.type === "data-todo_update") {
const todoUpdate = toTodoUpdate(part.data);
if (todoUpdate) {
next = {
...next,
todos: todoUpdate
};
}
} else if (part.type === "data-permission_request") {
next = {
...next,
permissions: upsertPermission(next.permissions, part.data)
};
} else if (part.type === "data-permission_response") {
next = {
...next,
permissions: applyPermissionResponse(next.permissions, part.data)
};
} else if (part.type === "data-question_request") {
next = {
...next,
questions: upsertQuestion(next.questions, part.data)
};
} else if (part.type === "data-question_response") {
next = {
...next,
questions: applyQuestionResponse(next.questions, part.data)
};
}
}
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)];
});
}
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
return messages.flatMap((message) => {
if (isAgentUiMessage(message)) {
return [message];
}
const legacyMessage = toAgentUiMessageFromLegacy(message);
return legacyMessage ? [legacyMessage] : [];
});
}
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
if (event.type === "state") {
return toAgentUiMessages(event.messages);
}
if (event.type === "user_input") {
const id = getDataString(event.data, "id");
const text = getDataString(event.data, "text");
if (event.data.status !== "delivered" || !id || !text || messages.some((message) => message.id === id)) return messages;
const input: AgentUiMessage = { id, role: "user", parts: [{ type: "text", text }] };
const tail = messages.at(-1);
return tail?.role === "assistant" ? [...messages.slice(0, -1), input, tail] : [...messages, input];
}
if (event.type === "token") {
const token = getDataString(event.data, "content");
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
}
if (
event.type === "final_answer" ||
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
event.type === "question_request" ||
event.type === "question_response" ||
event.type === "answer_ui" ||
event.type === "ui_envelope" ||
event.type === "session_title"
) {
return updateLastAssistantUiMessage(messages, (message) =>
upsertUiDataPart(message, event.type, event.data)
);
}
if (event.type === "error") {
const message = getDataString(event.data, "message") ?? "Agent stream failed";
return updateLastAssistantUiMessage(messages, (item) =>
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
);
}
return messages;
}
export function toPermissionStatus(reply: AgentPermissionReply): AgentPermissionStatus {
if (reply === "always") {
return "approved_always";
}
if (reply === "once") {
return "approved_once";
}
return "rejected";
}
export function getDataString(data: unknown, key: string) {
if (typeof data !== "object" || data === null) {
return undefined;
}
const value = (data as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
}
export function readBodyString(body: unknown, key: string) {
if (typeof body !== "object" || body === null) {
return undefined;
}
const value = (body as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
}
function getLastAssistantUiMessageId(messages: AgentUiMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "assistant") {
return messages[index].id;
}
}
return undefined;
}
function isAgentUiMessage(value: unknown): value is AgentUiMessage {
if (typeof value !== "object" || value === null) {
return false;
}
const message = value as Record<string, unknown>;
return (
typeof message.id === "string" &&
(message.role === "user" || message.role === "assistant") &&
Array.isArray(message.parts)
);
}
function toAgentUiMessageFromLegacy(value: unknown): AgentUiMessage | null {
if (typeof value !== "object" || value === null) {
return null;
}
const message = value as Record<string, unknown>;
if (
typeof message.id !== "string" ||
(message.role !== "user" && message.role !== "assistant")
) {
return null;
}
const parts: AgentUiMessage["parts"] = [];
if (typeof message.content === "string" && message.content) {
parts.push({ type: "text", text: message.content });
}
appendLegacyDataParts(parts, "progress", message.progress);
appendLegacyDataParts(parts, "permission_request", message.permissions);
appendLegacyDataParts(parts, "question_request", message.questions);
appendLegacyDataParts(parts, "todo_update", message.todos);
appendLegacyDataParts(parts, "execution_plan", message.executionPlans);
return {
id: message.id,
role: message.role,
parts
} as AgentUiMessage;
}
function appendLegacyDataParts(
parts: AgentUiMessage["parts"],
eventType: string,
value: unknown
) {
if (!value) {
return;
}
const values = Array.isArray(value) ? value : [value];
values.forEach((item, index) => {
if (typeof item !== "object" || item === null) {
return;
}
const data = normalizeLegacyDataPart(eventType, item as Record<string, unknown>);
const id =
getDataString(data, "id") ??
getDataString(data, "request_id") ??
`${eventType}-${index}`;
parts.push({
type: `data-${eventType}`,
id,
data
} as AgentDataPart);
});
}
function normalizeLegacyDataPart(eventType: string, value: Record<string, unknown>) {
if (eventType === "progress") {
return {
...value,
started_at: value.started_at ?? value.startedAt,
ended_at: value.ended_at ?? value.endedAt,
elapsed_ms: value.elapsed_ms ?? value.elapsedMs,
duration_ms: value.duration_ms ?? value.durationMs
};
}
if (eventType === "permission_request" || eventType === "question_request") {
return {
...value,
session_id: value.session_id ?? value.sessionId,
request_id: value.request_id ?? value.requestId,
created_at: value.created_at ?? value.createdAt
};
}
if (eventType === "todo_update") {
return {
...value,
session_id: value.session_id ?? value.sessionId,
message_id: value.message_id ?? value.messageId,
created_at: value.created_at ?? value.createdAt
};
}
return value;
}
function updateLastAssistantUiMessage(
messages: AgentUiMessage[],
updater: (message: AgentUiMessage) => AgentUiMessage
) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (messages[index].role === "assistant") {
const next = [...messages];
next[index] = updater(messages[index]);
return next;
}
}
return [...messages, updater(createAssistantUiMessage())];
}
function createAssistantUiMessage(): AgentUiMessage {
return {
id: `assistant-${Date.now().toString(36)}`,
role: "assistant",
parts: []
} as AgentUiMessage;
}
function appendTextToUiMessage(message: AgentUiMessage, text: string): AgentUiMessage {
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: [...message.parts, { type: "text", text }]
};
}
return {
...message,
parts: message.parts.map((part, index) =>
index === textIndex && part.type === "text"
? { ...part, text: `${part.text}${text}` }
: part
)
};
}
function upsertUiDataPart(
message: AgentUiMessage,
eventType: string,
data: Record<string, unknown>
): AgentUiMessage {
const type = `data-${eventType}`;
const id =
getDataString(data, "id") ??
getDataString(data, "request_id") ??
getDataString(data, "envelope_id") ??
getDataString(data, "block_id");
const existingIndex =
id === undefined
? -1
: message.parts.findIndex((part) => part.type === type && "id" in part && part.id === id);
const part = (id ? { type, id, data } : { type, data }) as AgentDataPart;
if (existingIndex === -1) {
return {
...message,
parts: [...message.parts, part]
};
}
return {
...message,
parts: message.parts.map((item, index) => (index === existingIndex ? part : item))
};
}
function applyPermissionOverrides(
message: AgentChatMessage,
permissionOverrides: Record<string, PermissionOverride>
) {
if (!message.permissions?.length) {
return message;
}
return {
...message,
permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId];
return override
? {
...permission,
status: override.status,
error: override.error,
repliedAt: override.status === "submitting" || override.status === "error" ? permission.repliedAt : Date.now()
}
: permission;
})
};
}
function applyQuestionOverrides(
message: AgentChatMessage,
questionOverrides: Record<string, QuestionOverride>
) {
if (!message.questions?.length) {
return message;
}
return {
...message,
questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId];
return override
? {
...question,
status: override.status,
answers: override.answers ?? question.answers,
error: override.error,
repliedAt: override.status === "submitting" || override.status === "error" ? question.repliedAt : Date.now()
}
: question;
})
};
}
function collectMessageText(message: AgentUiMessage) {
return message.parts
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
}
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;
}