461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import { type PermissionReply } from "../runtime/opencode.js";
|
|
import {
|
|
type ActivityPayload,
|
|
initialActivity,
|
|
type PermissionRequestPayload,
|
|
type QuestionRequestPayload,
|
|
type TodoUpdatePayload,
|
|
} from "./chatStream.js";
|
|
|
|
export type RunStatus = "running" | "completed" | "error" | "aborted";
|
|
|
|
export type StreamSubscriber = {
|
|
write: (event: string, data: Record<string, unknown>) => void;
|
|
close: () => void;
|
|
};
|
|
|
|
export type ActiveRun = {
|
|
clientSessionId: string;
|
|
controller: AbortController;
|
|
messages: unknown[];
|
|
pendingPermissions: Map<string, PermissionRequestPayload>;
|
|
pendingQuestions: Map<string, QuestionRequestPayload>;
|
|
status: RunStatus;
|
|
subscribers: Set<StreamSubscriber>;
|
|
};
|
|
|
|
type ToolArtifactKind = "chart" | "map" | "panel" | "tool";
|
|
|
|
type ToolCallPayload = {
|
|
session_id?: string;
|
|
tool?: string;
|
|
params?: unknown;
|
|
/** Legacy payload field. New tool calls inherit purpose from their Activity. */
|
|
reason?: string;
|
|
};
|
|
|
|
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const createFrontendMessageId = () =>
|
|
`msg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
export const createInitialStreamingMessages = (
|
|
existingMessages: unknown[],
|
|
userContent: string,
|
|
) => {
|
|
const userMessage = {
|
|
id: createFrontendMessageId(),
|
|
role: "user",
|
|
content: userContent,
|
|
};
|
|
return [
|
|
...existingMessages,
|
|
{
|
|
...userMessage,
|
|
branchRootId: userMessage.id,
|
|
},
|
|
{
|
|
id: createFrontendMessageId(),
|
|
role: "assistant",
|
|
content: "",
|
|
activities: [
|
|
{
|
|
id: initialActivity.id,
|
|
status: "running",
|
|
title: initialActivity.title,
|
|
reason: initialActivity.reason,
|
|
actions: [],
|
|
startedAt: Date.now(),
|
|
elapsedMs: 0,
|
|
elapsedSnapshotAt: Date.now(),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
};
|
|
|
|
const toFrontendActivityAction = (
|
|
action: ActivityPayload["actions"][number],
|
|
) => ({
|
|
id: action.id,
|
|
tool: action.tool,
|
|
title: action.title,
|
|
status: action.status,
|
|
target: action.target,
|
|
error: action.error,
|
|
startedAt: action.started_at,
|
|
endedAt: action.ended_at,
|
|
elapsedMs: action.elapsed_ms,
|
|
elapsedSnapshotAt: action.elapsed_ms === undefined ? undefined : Date.now(),
|
|
durationMs: action.duration_ms,
|
|
});
|
|
|
|
const toFrontendActivity = (activity: ActivityPayload) => ({
|
|
id: activity.id,
|
|
title: activity.title,
|
|
reason: activity.reason,
|
|
status: activity.status,
|
|
actions: activity.actions.map(toFrontendActivityAction),
|
|
startedAt: activity.started_at,
|
|
endedAt: activity.ended_at,
|
|
elapsedMs: activity.elapsed_ms,
|
|
elapsedSnapshotAt: activity.elapsed_ms === undefined ? undefined : Date.now(),
|
|
durationMs: activity.duration_ms,
|
|
});
|
|
|
|
export const upsertBackendActivity = (
|
|
activities: unknown,
|
|
activity: ActivityPayload,
|
|
) => {
|
|
const next = Array.isArray(activities) ? [...activities] : [];
|
|
const index = next.findIndex(
|
|
(item) => isObjectRecord(item) && item.id === activity.id,
|
|
);
|
|
const nextItem = toFrontendActivity(activity);
|
|
if (index >= 0) next[index] = nextItem;
|
|
else next.push(nextItem);
|
|
return next;
|
|
};
|
|
|
|
export const completeBackendActivities = (
|
|
activities: unknown,
|
|
status: "completed" | "error" | "cancelled" = "completed",
|
|
) => Array.isArray(activities)
|
|
? activities.map((activity) => {
|
|
if (!isObjectRecord(activity) || activity.status !== "running") return activity;
|
|
const endedAt = Date.now();
|
|
const startedAt = typeof activity.startedAt === "number"
|
|
? activity.startedAt
|
|
: endedAt;
|
|
const actions = Array.isArray(activity.actions)
|
|
? activity.actions.map((action) => {
|
|
if (!isObjectRecord(action) || action.status !== "running") return action;
|
|
const actionStartedAt = typeof action.startedAt === "number"
|
|
? action.startedAt
|
|
: endedAt;
|
|
return {
|
|
...action,
|
|
status: status === "error" ? "error" : "completed",
|
|
endedAt,
|
|
elapsedMs: undefined,
|
|
elapsedSnapshotAt: undefined,
|
|
durationMs: Math.max(0, endedAt - actionStartedAt),
|
|
...(status === "error"
|
|
? { error: action.error ?? "活动执行失败" }
|
|
: {}),
|
|
};
|
|
})
|
|
: activity.actions;
|
|
return {
|
|
...activity,
|
|
status,
|
|
actions,
|
|
endedAt,
|
|
elapsedMs: undefined,
|
|
elapsedSnapshotAt: undefined,
|
|
durationMs: Math.max(0, endedAt - startedAt),
|
|
};
|
|
})
|
|
: activities;
|
|
|
|
export const upsertBackendProgress = (
|
|
progress: unknown,
|
|
payload: Record<string, unknown>,
|
|
) => {
|
|
const next = Array.isArray(progress) ? [...progress] : [];
|
|
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
|
|
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
|
|
const nextItem = {
|
|
id,
|
|
phase: typeof payload.phase === "string" ? payload.phase : "progress",
|
|
status:
|
|
payload.status === "completed" || payload.status === "error"
|
|
? payload.status
|
|
: "running",
|
|
title: typeof payload.title === "string" ? payload.title : "正在处理",
|
|
detail: typeof payload.detail === "string" ? payload.detail : undefined,
|
|
startedAt: typeof payload.started_at === "number" ? payload.started_at : undefined,
|
|
endedAt: typeof payload.ended_at === "number" ? payload.ended_at : undefined,
|
|
elapsedMs: typeof payload.elapsed_ms === "number" ? payload.elapsed_ms : undefined,
|
|
elapsedSnapshotAt:
|
|
typeof payload.elapsed_ms === "number" ? Date.now() : undefined,
|
|
durationMs: typeof payload.duration_ms === "number" ? payload.duration_ms : undefined,
|
|
};
|
|
if (index >= 0) {
|
|
next[index] = nextItem;
|
|
} else {
|
|
next.push(nextItem);
|
|
}
|
|
return next;
|
|
};
|
|
|
|
export const completeBackendProgress = (progress: unknown) =>
|
|
Array.isArray(progress)
|
|
? progress.map((item) => {
|
|
if (!isObjectRecord(item) || item.status !== "running") {
|
|
return item;
|
|
}
|
|
const endedAt = Date.now();
|
|
const startedAt = typeof item.startedAt === "number" ? item.startedAt : undefined;
|
|
return {
|
|
...item,
|
|
status: "completed",
|
|
endedAt,
|
|
elapsedMs: undefined,
|
|
elapsedSnapshotAt: undefined,
|
|
durationMs:
|
|
typeof item.durationMs === "number"
|
|
? item.durationMs
|
|
: startedAt !== undefined
|
|
? Math.max(0, endedAt - startedAt)
|
|
: item.elapsedMs,
|
|
};
|
|
})
|
|
: progress;
|
|
|
|
export const cancelBackendTodos = (todos: unknown) =>
|
|
Array.isArray(todos)
|
|
? todos.map((todoUpdate) => {
|
|
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
|
|
return todoUpdate;
|
|
}
|
|
return {
|
|
...todoUpdate,
|
|
todos: todoUpdate.todos.map((todo) => {
|
|
if (!isObjectRecord(todo)) {
|
|
return todo;
|
|
}
|
|
if (todo.status !== "pending" && todo.status !== "in_progress") {
|
|
return todo;
|
|
}
|
|
return {
|
|
...todo,
|
|
status: "cancelled",
|
|
updatedAt: Date.now(),
|
|
};
|
|
}),
|
|
};
|
|
})
|
|
: todos;
|
|
|
|
export const completeBackendTodos = (todos: unknown) =>
|
|
Array.isArray(todos)
|
|
? todos.map((todoUpdate) => {
|
|
if (!isObjectRecord(todoUpdate) || !Array.isArray(todoUpdate.todos)) {
|
|
return todoUpdate;
|
|
}
|
|
return {
|
|
...todoUpdate,
|
|
todos: todoUpdate.todos.map((todo) => {
|
|
if (!isObjectRecord(todo)) {
|
|
return todo;
|
|
}
|
|
if (todo.status !== "pending" && todo.status !== "in_progress") {
|
|
return todo;
|
|
}
|
|
return {
|
|
...todo,
|
|
status: "completed",
|
|
updatedAt: Date.now(),
|
|
};
|
|
}),
|
|
};
|
|
})
|
|
: todos;
|
|
|
|
export const updateLastAssistantMessage = (
|
|
messages: unknown[],
|
|
updater: (message: Record<string, unknown>) => Record<string, unknown>,
|
|
) => {
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index];
|
|
if (isObjectRecord(message) && message.role === "assistant") {
|
|
const next = [...messages];
|
|
next[index] = updater(message);
|
|
return next;
|
|
}
|
|
}
|
|
return messages;
|
|
};
|
|
|
|
export const updateLastAssistantPermission = (
|
|
messages: unknown[],
|
|
requestId: string,
|
|
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
|
|
) =>
|
|
updateLastAssistantMessage(messages, (message) => {
|
|
const permissions = Array.isArray(message.permissions)
|
|
? message.permissions
|
|
: [];
|
|
return {
|
|
...message,
|
|
permissions: permissions.map((permission) =>
|
|
isObjectRecord(permission) && permission.requestId === requestId
|
|
? updater(permission)
|
|
: permission,
|
|
),
|
|
};
|
|
});
|
|
|
|
export const updateLastAssistantQuestion = (
|
|
messages: unknown[],
|
|
requestId: string,
|
|
updater: (question: Record<string, unknown>) => Record<string, unknown>,
|
|
) =>
|
|
updateLastAssistantMessage(messages, (message) => {
|
|
const questions = Array.isArray(message.questions)
|
|
? message.questions
|
|
: [];
|
|
return {
|
|
...message,
|
|
questions: questions.map((question) =>
|
|
isObjectRecord(question) && question.requestId === requestId
|
|
? updater(question)
|
|
: question,
|
|
),
|
|
};
|
|
});
|
|
|
|
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
|
if (tool === "show_chart" || tool === "chart") return "chart";
|
|
if (
|
|
tool === "locate_features" ||
|
|
tool === "zoom_to_map" ||
|
|
tool === "render_junctions" ||
|
|
tool === "apply_layer_style" ||
|
|
tool.startsWith("locate_")
|
|
) {
|
|
return "map";
|
|
}
|
|
if (tool === "view_history" || tool === "view_scada") return "panel";
|
|
return "tool";
|
|
};
|
|
|
|
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
|
|
if (typeof params.title === "string" && params.title.trim()) {
|
|
return params.title.trim();
|
|
}
|
|
if (tool === "show_chart" || tool === "chart") return "生成图表";
|
|
if (tool === "zoom_to_map") return "缩放到地图坐标";
|
|
if (tool === "render_junctions") return "渲染节点分区";
|
|
if (tool === "view_history") return "打开计算结果曲线";
|
|
if (tool === "view_scada") return "打开 SCADA 数据面板";
|
|
if (tool === "apply_layer_style") return "应用图层样式";
|
|
if (tool === "locate_features" || tool.startsWith("locate_")) return "地图定位";
|
|
return tool || "工具调用";
|
|
};
|
|
|
|
export const appendBackendToolArtifact = (
|
|
artifacts: unknown,
|
|
payload: ToolCallPayload,
|
|
) => {
|
|
const tool = typeof payload.tool === "string" ? payload.tool.trim() : "";
|
|
if (!tool) {
|
|
return artifacts;
|
|
}
|
|
const params = isObjectRecord(payload.params) ? payload.params : {};
|
|
const next = Array.isArray(artifacts) ? [...artifacts] : [];
|
|
next.push({
|
|
id: `${tool}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
tool,
|
|
kind: getToolArtifactKind(tool),
|
|
title: getToolArtifactTitle(tool, params),
|
|
params,
|
|
});
|
|
return next;
|
|
};
|
|
|
|
export const toFrontendPermission = (
|
|
payload: PermissionRequestPayload,
|
|
status: "pending" | "approved_once" | "approved_always" | "rejected" | "error" = "pending",
|
|
) => ({
|
|
requestId: payload.request_id,
|
|
sessionId: payload.session_id,
|
|
permission: payload.permission,
|
|
patterns: payload.patterns,
|
|
target: payload.target,
|
|
activityId: payload.activity_id,
|
|
reason: payload.reason,
|
|
always: payload.always,
|
|
tool: payload.tool,
|
|
createdAt: payload.created_at,
|
|
status,
|
|
});
|
|
|
|
const toFrontendQuestion = (
|
|
payload: QuestionRequestPayload,
|
|
status: "pending" | "submitting" | "answered" | "rejected" | "error" = "pending",
|
|
) => ({
|
|
requestId: payload.request_id,
|
|
sessionId: payload.session_id,
|
|
questions: payload.questions,
|
|
tool: payload.tool,
|
|
createdAt: payload.created_at,
|
|
status,
|
|
});
|
|
|
|
export const toPermissionStatus = (reply: PermissionReply) => {
|
|
if (reply === "always") return "approved_always";
|
|
if (reply === "once") return "approved_once";
|
|
return "rejected";
|
|
};
|
|
|
|
export const upsertBackendQuestion = (
|
|
questions: unknown,
|
|
payload: QuestionRequestPayload,
|
|
) => {
|
|
const next = Array.isArray(questions) ? [...questions] : [];
|
|
const index = next.findIndex((item) => {
|
|
if (!isObjectRecord(item)) return false;
|
|
if (item.requestId === payload.request_id) return true;
|
|
const tool = isObjectRecord(item.tool) ? item.tool : undefined;
|
|
return Boolean(
|
|
payload.tool?.callID &&
|
|
tool?.callID === payload.tool.callID,
|
|
);
|
|
});
|
|
const nextItem = toFrontendQuestion(payload);
|
|
if (index >= 0) {
|
|
const current = next[index];
|
|
const currentRequestId = isObjectRecord(current) ? current.requestId : undefined;
|
|
const currentTool = isObjectRecord(current) && isObjectRecord(current.tool)
|
|
? current.tool
|
|
: undefined;
|
|
const currentIsActionable =
|
|
typeof currentRequestId === "string" &&
|
|
currentRequestId !== currentTool?.callID;
|
|
const payloadIsToolPlaceholder =
|
|
Boolean(payload.tool?.callID) && payload.request_id === payload.tool?.callID;
|
|
next[index] = {
|
|
...(isObjectRecord(current) ? current : {}),
|
|
...(currentIsActionable && payloadIsToolPlaceholder
|
|
? {
|
|
questions: nextItem.questions,
|
|
tool: nextItem.tool,
|
|
createdAt: nextItem.createdAt,
|
|
}
|
|
: nextItem),
|
|
status:
|
|
isObjectRecord(current) && current.status === "submitting"
|
|
? "submitting"
|
|
: nextItem.status,
|
|
};
|
|
} else {
|
|
next.push(nextItem);
|
|
}
|
|
return next;
|
|
};
|
|
|
|
export const upsertBackendTodoUpdate = (
|
|
_todos: unknown,
|
|
payload: TodoUpdatePayload,
|
|
) => [
|
|
{
|
|
sessionId: payload.session_id,
|
|
messageId: payload.message_id,
|
|
todos: payload.todos,
|
|
createdAt: payload.created_at,
|
|
},
|
|
];
|