Centralize session scope, chat projections and typed task bridges. Keep historical interaction replies authoritative and publish terminal state to subscribers. Validate Server contracts before replacing generated artifacts.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { type PermissionReply } from "../runtime/opencode.js";
|
|
import type { ResponseInputTracker } from "../chat/response-inputs.js";
|
|
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
|
|
import {
|
|
type PermissionRequestPayload,
|
|
type QuestionRequestPayload,
|
|
type TodoUpdatePayload,
|
|
} from "./chatStream.js";
|
|
|
|
export type RunStatus = "running" | "waiting_input" | "completed" | "error" | "aborted";
|
|
|
|
export type StreamSubscriber = {
|
|
write: (event: string, data: Record<string, unknown>) => void;
|
|
close: () => void;
|
|
};
|
|
|
|
export type ActiveRun = {
|
|
applyEvent?: (event: string, data: Record<string, unknown>) => void;
|
|
uiMessages?: () => unknown[];
|
|
inputs?: ResponseInputTracker;
|
|
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;
|
|
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: "",
|
|
progress: [
|
|
{
|
|
id: "request-received",
|
|
phase: "start",
|
|
status: "running",
|
|
title: "已收到请求,正在启动 Agent 分析",
|
|
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
|
startedAt: Date.now(),
|
|
elapsedMs: 0,
|
|
elapsedSnapshotAt: Date.now(),
|
|
},
|
|
],
|
|
},
|
|
];
|
|
};
|
|
|
|
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 existing = index >= 0 && isObjectRecord(next[index]) ? next[index] : undefined;
|
|
const tool = isObjectRecord(payload.tool) && typeof payload.tool.name === "string"
|
|
? { name: payload.tool.name, callId: typeof payload.tool.call_id === "string" ? payload.tool.call_id : undefined, input: isObjectRecord(payload.tool.input) ? payload.tool.input : undefined }
|
|
: existing?.tool;
|
|
const nextItem = {
|
|
id,
|
|
tool,
|
|
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 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;
|
|
};
|
|
|
|
/** Replies belong to the assistant that requested input, including earlier turns. */
|
|
export function applyInteractionResponse<T>(
|
|
messages: T[],
|
|
event: "permission_response" | "question_response",
|
|
data: Record<string, unknown>,
|
|
): T[] {
|
|
const requestId = data.request_id;
|
|
if (typeof requestId !== "string") return messages;
|
|
const permission = event === "permission_response";
|
|
const field = permission ? "permissions" : "questions";
|
|
const requestType = permission ? "data-permission_request" : "data-question_request";
|
|
let changed = false;
|
|
const next = messages.map((message) => {
|
|
if (!isObjectRecord(message) || message.role !== "assistant") return message;
|
|
const entries = Array.isArray(message[field]) ? message[field] as unknown[] : undefined;
|
|
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
|
|
const ownsRequest = entries?.some((entry) => isObjectRecord(entry) && entry.requestId === requestId)
|
|
|| parts?.some((part) => isObjectRecord(part) && part.type === requestType
|
|
&& isObjectRecord(part.data) && part.data.request_id === requestId);
|
|
if (!ownsRequest) return message;
|
|
changed = true;
|
|
const updated = entries?.map((entry) => {
|
|
if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry;
|
|
return permission ? {
|
|
...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply),
|
|
error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(),
|
|
} : {
|
|
...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered",
|
|
...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(),
|
|
};
|
|
});
|
|
if (parts && data.status !== "pending") {
|
|
const type = `data-${event}`;
|
|
const index = parts.findIndex((part) => isObjectRecord(part) && part.type === type
|
|
&& isObjectRecord(part.data) && part.data.request_id === requestId);
|
|
const previous = index >= 0 && isObjectRecord(parts[index]) ? parts[index] : undefined;
|
|
const part = { ...previous, type, data: { ...(isObjectRecord(previous?.data) ? previous.data : {}), ...data } };
|
|
if (index >= 0) parts[index] = part;
|
|
else parts.push(part);
|
|
}
|
|
return { ...message, ...(updated ? { [field]: updated } : {}), ...(parts ? { parts } : {}) };
|
|
});
|
|
return changed ? next : messages;
|
|
}
|
|
|
|
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
|
if (tool === "present_view") {
|
|
return "map";
|
|
}
|
|
return "tool";
|
|
};
|
|
|
|
const getToolArtifactTitle = (tool: string, params: Record<string, unknown>) => {
|
|
if (typeof params.title === "string" && params.title.trim()) {
|
|
return params.title.trim();
|
|
}
|
|
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),
|
|
description:
|
|
typeof payload.reason === "string" && payload.reason.trim()
|
|
? payload.reason.trim()
|
|
: undefined,
|
|
params,
|
|
});
|
|
return next;
|
|
};
|
|
|
|
export const appendBackendUiEnvelope = (
|
|
uiEnvelopes: unknown,
|
|
payload: UIEnvelopePayload,
|
|
) => {
|
|
const next = Array.isArray(uiEnvelopes) ? [...uiEnvelopes] : [];
|
|
next.push({
|
|
envelopeId: payload.envelope_id,
|
|
createdAt: payload.created_at,
|
|
envelope: payload.envelope,
|
|
renderStatus: "pending",
|
|
});
|
|
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,
|
|
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,
|
|
},
|
|
];
|