refactor(chat): split oversized chat modules

This commit is contained in:
2026-06-08 19:23:46 +08:00
parent 865e425748
commit 36cdb1df8d
11 changed files with 3282 additions and 3150 deletions
@@ -0,0 +1,457 @@
import type {
AgentQuestionRequest,
AgentTodoUpdate,
PermissionReply,
StreamEvent,
} from "@/lib/chatStream";
import type {
AgentPermissionRequest,
ChatProgress,
LoadedChatState,
Message,
} from "../GlobalChatbox.types";
import { createId } from "../GlobalChatbox.utils";
export const createPersistedStateKey = (state: LoadedChatState) =>
JSON.stringify({
title: state.title ?? null,
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
sessionId: state.sessionId ?? null,
messages: state.messages,
});
export const upsertProgress = (
progress: ChatProgress[] | undefined,
event: StreamEvent & { type: "progress" },
) => {
const next = [...(progress ?? [])];
const index = next.findIndex((item) => item.id === event.id);
const existing = index >= 0 ? next[index] : undefined;
const now = Date.now();
const startedAt = event.startedAt ?? existing?.startedAt;
const isRunning = event.status === "running";
const endedAt = isRunning ? undefined : event.endedAt ?? existing?.endedAt ?? now;
const elapsedMs = isRunning
? event.elapsedMs ??
existing?.elapsedMs ??
(startedAt !== undefined ? Math.max(0, now - startedAt) : undefined)
: undefined;
const elapsedSnapshotAt = isRunning
? event.elapsedMs !== undefined
? now
: existing?.elapsedSnapshotAt ?? now
: undefined;
const durationMs = !isRunning
? event.durationMs ??
existing?.durationMs ??
(startedAt !== undefined && endedAt !== undefined
? Math.max(0, endedAt - startedAt)
: undefined)
: undefined;
const nextItem: ChatProgress = {
id: event.id,
phase: event.phase,
status: event.status,
title: event.title,
detail: event.detail,
startedAt,
endedAt,
elapsedMs,
elapsedSnapshotAt,
durationMs,
};
if (index >= 0) {
next[index] = nextItem;
} else {
next.push(nextItem);
}
return next;
};
export const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
progress?.map((item) => {
if (item.status !== "running") {
return item;
}
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
durationMs:
item.durationMs ??
(item.startedAt !== undefined
? Math.max(0, endedAt - item.startedAt)
: item.elapsedMs),
};
});
export const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todoUpdate
? {
...todoUpdate,
todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress"
? {
...todo,
status: "cancelled" as const,
updatedAt: Date.now(),
}
: todo,
),
}
: undefined;
export const upsertPermission = (
permissions: AgentPermissionRequest[] | undefined,
event: StreamEvent & { type: "permission_request" },
) => {
const next = [...(permissions ?? [])];
const index = next.findIndex((item) => item.requestId === event.requestId);
const nextItem: AgentPermissionRequest = {
requestId: event.requestId,
sessionId: event.sessionId,
permission: event.permission,
patterns: event.patterns,
metadata: event.metadata,
always: event.always,
tool: event.tool,
createdAt: event.createdAt,
status: "pending",
};
if (index >= 0) {
next[index] = {
...next[index],
...nextItem,
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
};
} else {
next.push(nextItem);
}
return next;
};
export const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
if (reply === "always") return "approved_always";
if (reply === "once") return "approved_once";
return "rejected";
};
export const isActionableQuestionRequest = (question: {
requestId: string;
tool?: AgentQuestionRequest["tool"];
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
export const toQuestionRequest = (
event: StreamEvent & { type: "question_request" },
status: AgentQuestionRequest["status"] = "pending",
): AgentQuestionRequest => ({
requestId: event.requestId,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool,
createdAt: event.createdAt,
status,
});
export const getQuestionContentSignature = (
questions: AgentQuestionRequest["questions"],
) =>
JSON.stringify(
questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple ?? false,
custom: question.custom !== false,
})),
);
export const isSameQuestionRequest = (
question: AgentQuestionRequest,
event: StreamEvent & { type: "question_request" },
) => {
if (question.requestId === event.requestId) return true;
if (question.tool?.callID && event.tool?.callID) {
return question.tool.callID === event.tool.callID;
}
return (
question.status === "pending" &&
question.sessionId === event.sessionId &&
getQuestionContentSignature(question.questions) ===
getQuestionContentSignature(event.questions)
);
};
export const isSameQuestionPair = (
left: AgentQuestionRequest,
right: AgentQuestionRequest,
) => {
if (left.requestId === right.requestId) return true;
if (left.tool?.callID && right.tool?.callID) {
return left.tool.callID === right.tool.callID;
}
return (
left.status === "pending" &&
right.status === "pending" &&
left.sessionId === right.sessionId &&
getQuestionContentSignature(left.questions) ===
getQuestionContentSignature(right.questions)
);
};
export const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
const seen: AgentQuestionRequest[] = [];
let changed = false;
const nextMessages = messages.map((message) => {
if (!message.questions?.length) {
return message;
}
const nextQuestions = message.questions.filter((question) => {
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
changed = true;
return false;
}
seen.push(question);
return true;
});
if (nextQuestions.length === message.questions.length) {
return message;
}
return {
...message,
questions: nextQuestions.length ? nextQuestions : undefined,
};
});
return changed ? nextMessages : messages;
};
export const upsertQuestionAcrossMessages = (
messages: Message[],
event: StreamEvent & { type: "question_request" },
assistantMessageId: string,
) => {
let existing: AgentQuestionRequest | undefined;
for (const message of messages) {
const match = message.questions?.find((question) =>
isSameQuestionRequest(question, event),
);
if (match) {
existing = match;
break;
}
}
const existingStatus: AgentQuestionRequest["status"] | undefined =
existing?.status === "submitting" ? "submitting" : undefined;
const nextQuestion =
existing &&
isActionableQuestionRequest(existing) &&
!isActionableQuestionRequest(event)
? {
...existing,
sessionId: event.sessionId,
questions: event.questions,
tool: event.tool ?? existing.tool,
createdAt: event.createdAt,
status: existingStatus ?? existing.status,
}
: toQuestionRequest(event, existingStatus ?? "pending");
const targetMessageId = existing
? messages.find((message) =>
message.questions?.some((question) => isSameQuestionRequest(question, event)),
)?.id ?? assistantMessageId
: assistantMessageId;
return messages.map((message) => {
const filteredQuestions = message.questions?.filter(
(question) => !isSameQuestionRequest(question, event),
);
if (message.id !== targetMessageId) {
return filteredQuestions?.length === message.questions?.length
? message
: {
...message,
questions: filteredQuestions?.length ? filteredQuestions : undefined,
};
}
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
return {
...message,
questions: nextQuestions,
};
});
};
export const applyQuestionResponse = (
questions: AgentQuestionRequest[] | undefined,
event: StreamEvent & { type: "question_response" },
) =>
(questions ?? []).map((question) =>
question.requestId === event.requestId
? {
...question,
status: event.rejected ? "rejected" as const : "answered" as const,
answers: event.answers ?? question.answers,
repliedAt: Date.now(),
error: undefined,
}
: question,
);
export const createTodoUpdateFromEvent = (
event: StreamEvent & { type: "todo_update" },
): AgentTodoUpdate => ({
sessionId: event.sessionId,
messageId: event.messageId,
todos: event.todos,
createdAt: event.createdAt,
});
export const normalizeSessionTodos = (
messages: Message[],
nextTodoUpdate?: AgentTodoUpdate,
targetAssistantMessageId?: string,
) => {
let latestTodoUpdate = nextTodoUpdate;
if (!latestTodoUpdate) {
for (const message of messages) {
if (message.todos) {
latestTodoUpdate = message.todos;
}
}
}
if (!latestTodoUpdate) {
return messages;
}
const targetMessageId =
targetAssistantMessageId ??
[...messages].reverse().find((message) => message.role === "assistant")?.id;
if (!targetMessageId) {
return messages;
}
let changed = false;
const nextMessages = messages.map((message) => {
if (message.id === targetMessageId) {
if (message.todos === latestTodoUpdate) {
return message;
}
changed = true;
return {
...message,
todos: latestTodoUpdate,
};
}
if (!message.todos) {
return message;
}
changed = true;
return {
...message,
todos: undefined,
};
});
return changed ? nextMessages : messages;
};
export const rejectOpenPermissionsAfterAbort = (
permissions: AgentPermissionRequest[] | undefined,
) => {
if (!permissions?.length) return permissions;
let changed = false;
const nextPermissions = permissions.map((permission) => {
if (
permission.status !== "pending" &&
permission.status !== "submitting" &&
permission.status !== "error"
) {
return permission;
}
changed = true;
return {
...permission,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextPermissions : permissions;
};
export const rejectOpenQuestionsAfterAbort = (
questions: AgentQuestionRequest[] | undefined,
) => {
if (!questions?.length) return questions;
let changed = false;
const nextQuestions = questions.map((question) => {
if (
question.status !== "pending" &&
question.status !== "submitting" &&
question.status !== "error"
) {
return question;
}
changed = true;
return {
...question,
status: "rejected" as const,
repliedAt: Date.now(),
error: undefined,
};
});
return changed ? nextQuestions : questions;
};
export const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
const completedProgress = completeRunningProgress(message.progress);
const cancelledTodos = cancelRunningTodos(message.todos);
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
const hasVisibleOutput =
message.content.trim().length > 0 ||
Boolean(message.artifacts?.length) ||
Boolean(rejectedPermissions?.length) ||
Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) ||
Boolean(cancelledTodos);
if (!hasVisibleOutput) {
return message;
}
return {
...message,
content: message.content || "⚠️ **请求已中断**",
isError: true,
progress: completedProgress,
permissions: rejectedPermissions,
questions: rejectedQuestions,
todos: cancelledTodos,
};
};
export const createUserMessage = (content: string): Message => {
const id = createId();
return {
id,
role: "user",
content,
};
};
export const createAssistantMessage = (): Message => ({
id: createId(),
role: "assistant",
content: "",
});