457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
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,
|
|
target: event.target,
|
|
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 abortOpenPermissionsAfterAbort = (
|
|
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: "aborted" 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 abortedPermissions = abortOpenPermissionsAfterAbort(message.permissions);
|
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
|
const hasVisibleOutput =
|
|
message.content.trim().length > 0 ||
|
|
Boolean(message.artifacts?.length) ||
|
|
Boolean(abortedPermissions?.length) ||
|
|
Boolean(rejectedQuestions?.length) ||
|
|
Boolean(completedProgress?.length) ||
|
|
Boolean(cancelledTodos);
|
|
|
|
if (!hasVisibleOutput) {
|
|
return message;
|
|
}
|
|
|
|
return {
|
|
...message,
|
|
content: message.content || "⚠️ **请求已中断**",
|
|
isError: true,
|
|
progress: completedProgress,
|
|
permissions: abortedPermissions,
|
|
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: "",
|
|
});
|