feat: initialize experimental agent repo

This commit is contained in:
2026-06-30 14:03:49 +08:00
commit e83ac54a9e
75 changed files with 11690 additions and 0 deletions
+956
View File
@@ -0,0 +1,956 @@
import { Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import {
agentModelOptions,
isSupportedModel,
resolveDefaultModel,
type SupportedModel,
} from "../chat/models.js";
import { config } from "../config.js";
import { type LearningOrchestrator } from "../learning/orchestrator.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { logger } from "../logger.js";
import { MemoryStore } from "../memory/store.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import {
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { type SessionRecord } from "../sessions/metadataStore.js";
import { uiRegistryResponse } from "../uiEnvelope/registry.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
extractLatestFrontendTurn,
generateSessionTitle,
shouldGenerateSessionTitle,
shouldRestoreConversationForRuntime,
} from "./chatSession.js";
import { registerChatAuxiliaryRoutes } from "./chatAuxiliaryRoutes.js";
import { registerChatInteractionRoutes } from "./chatInteractionRoutes.js";
import {
collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
type TodoUpdatePayload,
} from "./chatStream.js";
import {
type ActiveRun,
type RunStatus,
type StreamSubscriber,
appendBackendToolArtifact,
appendBackendUiEnvelope,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js";
const payloadSchema = z.object({
message: z.string().min(1).max(10000),
session_id: z.string().max(128).optional(),
model: z.string().refine(isSupportedModel, {
message: "unsupported model",
}).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"),
});
const createSessionPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
parent_session_id: z.string().max(128).optional(),
});
const forkPayloadSchema = z.object({
session_id: z.string().max(128).optional(),
keep_message_count: z.coerce.number().int().min(0),
});
const activeRuns = new Map<string, ActiveRun>();
const lastRunStatuses = new Map<string, RunStatus>();
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
});
export const buildForkedSessionUiState = (
sourceState: { messages?: unknown[] } | null | undefined,
input: {
keepMessageCount: number;
targetSessionId: string;
},
) => ({
sessionId: input.targetSessionId,
isTitleManuallyEdited: false,
messages: Array.isArray(sourceState?.messages)
? sourceState.messages.slice(0, input.keepMessageCount)
: [],
});
const getSessionRunStatus = (sessionId: string) =>
activeRuns.get(sessionId)?.status ?? lastRunStatuses.get(sessionId);
const runtimeHasConversation = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 1);
return messages.some(
(message) =>
message.info.role === "user" || message.info.role === "assistant",
);
};
export const buildChatRouter = (
sessionBridge: ChatSessionBridge,
runtime: OpencodeRuntimeAdapter,
sessionMetadataStore: SessionMetadataStore,
sessionUiStateStore: SessionUiStateStore,
memoryStore: MemoryStore,
sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver,
) => {
const chatRouter = Router();
chatRouter.get("/models", (_req, res) => {
res.json({
default_model: resolveDefaultModel(config.OPENCODE_MODEL),
models: agentModelOptions,
});
});
chatRouter.get("/ui-registry", (_req, res) => {
res.json(uiRegistryResponse);
});
chatRouter.post("/session", async (req, res) => {
const parsed = createSessionPayloadSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: parsed.data.parent_session_id,
projectId,
projectKey,
sessionId,
userId,
});
res.status(created ? 201 : 200).json({
session_id: record.sessionId,
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
title: record.title,
parent_session_id: record.parentSessionId,
});
});
chatRouter.get("/sessions", async (req, res) => {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const records = await sessionMetadataStore.list({
actorKey,
projectId,
projectKey,
userId,
});
res.json({
sessions: records.map((record) => ({
id: record.sessionId,
title: record.title ?? "新对话",
created_at: record.createdAt,
updated_at: record.updatedAt,
status: record.status,
parent_session_id: record.parentSessionId,
is_streaming: activeRuns.get(record.sessionId)?.status === "running",
run_status: getSessionRunStatus(record.sessionId),
})),
});
});
chatRouter.get("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
);
res.json({
id: sessionRecord.sessionId,
title: sessionRecord.title ?? "新对话",
is_title_manually_edited: state?.isTitleManuallyEdited ?? false,
created_at: sessionRecord.createdAt,
updated_at: sessionRecord.updatedAt,
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
messages: state?.messages ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
});
});
chatRouter.get("/session/:session_id/stream", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
res.write(
toSse("state", {
session_id: sessionRecord.sessionId,
messages: state?.messages ?? run?.messages ?? [],
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId) ?? "completed",
}),
);
if (!run || run.status !== "running") {
res.end();
return;
}
const subscriber: StreamSubscriber = {
write: (event, data) => {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
run.subscribers.add(subscriber);
const cleanup = () => {
run.subscribers.delete(subscriber);
};
req.on("close", cleanup);
res.on("close", cleanup);
});
chatRouter.patch("/session/:session_id/title", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const title =
typeof req.body?.title === "string" ? req.body.title.trim() : "";
const isTitleManuallyEdited =
typeof req.body?.is_title_manually_edited === "boolean"
? req.body.is_title_manually_edited
: undefined;
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId || !title) {
res.status(400).json({ message: "session_id and title are required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const nextSessionRecord = await sessionMetadataStore.touch(sessionRecord, { title });
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
);
if (state) {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
isTitleManuallyEdited ?? state.isTitleManuallyEdited,
},
);
}
res.json({
id: nextSessionRecord.sessionId,
title: nextSessionRecord.title,
updated_at: nextSessionRecord.updatedAt,
});
});
chatRouter.delete("/session/:session_id", async (req, res) => {
const sessionId = req.params.session_id?.trim();
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
if (!sessionId) {
res.status(400).json({ message: "session_id is required" });
return;
}
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
sessionId,
);
if (!sessionRecord) {
res.status(204).end();
return;
}
await sessionUiStateStore.remove(toSessionUiStateContext(sessionRecord));
await sessionBridge.deleteSession({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
});
activeRuns.delete(sessionRecord.sessionId);
lastRunStatuses.delete(sessionRecord.sessionId);
await sessionMetadataStore.remove(sessionRecord);
res.status(204).end();
});
registerChatAuxiliaryRoutes(chatRouter, {
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
});
registerChatInteractionRoutes(chatRouter, {
activeRuns,
runtime,
sessionMetadataStore,
sessionUiStateStore,
});
chatRouter.post("/fork", async (req, res) => {
const parsed = forkPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sourceSessionId = parsed.data.session_id?.trim();
const sourceSessionRecord = sourceSessionId
? await sessionMetadataStore.get(
{
actorKey,
projectId,
projectKey,
userId,
},
sourceSessionId,
)
: null;
const forkSession = await runtime.createSession();
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
projectId,
projectKey,
sessionId: forkSession.id,
userId,
});
const nextSessionId = targetSessionRecord.sessionId;
if (sourceSessionId) {
await sessionTranscriptStore.cloneThread(
{
actorKey,
clientSessionId: sourceSessionId,
projectKey,
sessionId: sourceSessionId,
},
{
actorKey,
clientSessionId: nextSessionId,
projectKey,
sessionId: nextSessionId,
},
parsed.data.keep_message_count,
);
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(toSessionUiStateContext(sourceSessionRecord))
: null;
const forkTitle = sourceSessionRecord?.title
? `${sourceSessionRecord.title} 副本`
: "新对话副本";
const titledTargetSessionRecord = await sessionMetadataStore.touch(
targetSessionRecord,
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
}),
);
logger.info(
{
sourceSessionId: parsed.data.session_id,
sessionId: nextSessionId,
traceId,
projectId,
keepMessageCount: parsed.data.keep_message_count,
},
"forked chat session",
);
res.status(200).json({
session_id: nextSessionId,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat fork failed");
res.status(500).json({
message: "chat fork failed",
detail,
});
}
});
chatRouter.post("/stream", async (req, res) => {
const parsed = payloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const traceId = req.header("x-trace-id") ?? undefined;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const existingSessionRecord = requestedSessionId
? await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
requestedSessionId,
)
: null;
const hadExistingRuntimeSession = Boolean(existingSessionRecord);
const { binding, requestContext, created } = await sessionBridge.resolve({
sessionId: requestedSessionId,
network: authContext.network,
projectId,
traceId,
userId,
});
const { record: ensuredSessionRecord, created: sessionCreated } =
await sessionMetadataStore.ensure({
actorKey,
projectId,
projectKey,
sessionId: binding.sessionId,
userId,
});
const activeSessionRecord = await sessionMetadataStore.touch(ensuredSessionRecord);
const hasRuntimeConversation = hadExistingRuntimeSession
? await runtimeHasConversation(runtime, binding.sessionId)
: false;
const shouldRestoreConversation = shouldRestoreConversationForRuntime({
hadExistingSessionRecord: hadExistingRuntimeSession,
runtimeHasConversation: hasRuntimeConversation,
});
const historyContext = {
actorKey: requestContext.actorKey,
clientSessionId: requestContext.clientSessionId,
projectKey: requestContext.projectKey,
sessionId: requestContext.clientSessionId,
};
const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord),
);
const persistedMessages = initialSessionState?.messages ?? [];
const baseMessages = persistedMessages;
if (activeRuns.get(activeSessionRecord.sessionId)?.status === "running") {
res.status(409).json({
message: "session is already streaming",
session_id: activeSessionRecord.sessionId,
});
return;
}
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
logger.info(
{
clientSessionId: requestContext.clientSessionId,
sessionId: binding.sessionId,
created: created || sessionCreated,
model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
},
"processing chat request",
);
res.status(200);
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
const clientSessionId = requestContext.clientSessionId;
let streamClosed = false;
const abortController = new AbortController();
sessionBridge.registerAbortController(clientSessionId, abortController);
const initialMessages = createInitialStreamingMessages(
baseMessages,
parsed.data.message,
);
const activeRun: ActiveRun = {
clientSessionId,
controller: abortController,
messages: initialMessages,
pendingPermissions: new Map(),
pendingQuestions: new Map(),
status: "running",
subscribers: new Set(),
};
activeRuns.set(clientSessionId, activeRun);
lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: initialMessages,
});
const queueSessionUiStatePersist = () => {
const snapshot = {
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: activeRun.messages,
};
persistQueue = persistQueue
.catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"failed to persist previous chat stream state",
);
})
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue;
};
const primarySubscriber: StreamSubscriber = {
write: (event, data) => {
if (!streamClosed && !res.writableEnded && !res.destroyed) {
res.write(toSse(event, data));
}
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
activeRun.subscribers.add(primarySubscriber);
const handleClientClose = () => {
streamClosed = true;
activeRun.subscribers.delete(primarySubscriber);
};
req.on("close", handleClientClose);
res.on("close", handleClientClose);
const publish = (event: string, data: Record<string, unknown>) => {
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = "completed";
lastRunStatuses.set(clientSessionId, "completed");
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && reply) {
activeRun.pendingPermissions.delete(requestId);
activeRun.messages = updateLastAssistantPermission(
activeRun.messages,
requestId,
(permission) => ({
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
}),
);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = updateLastAssistantQuestion(
activeRun.messages,
requestId,
(question) => ({
...question,
status: data.rejected === true ? "rejected" : "answered",
repliedAt: Date.now(),
answers: Array.isArray(data.answers) ? data.answers : question.answers,
error: undefined,
}),
);
}
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
}
for (const subscriber of activeRun.subscribers) {
subscriber.write(event, data);
}
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
};
try {
const preparedMessage = await buildPromptWithLearningContext(
memoryStore,
requestContext.actorKey,
requestContext.projectKey,
{
recentTurns,
persistedMessages: baseMessages,
message: parsed.data.message,
restoreConversation: shouldRestoreConversation,
},
);
const streamResult = await streamPromptResponse({
runtime,
sessionId: binding.sessionId,
clientSessionId,
message: preparedMessage,
model: parsed.data.model,
approvalMode: parsed.data.approval_mode,
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
write: (event, data) => {
publish(event, data);
},
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
if (!streamResult.aborted && !streamResult.failed) {
const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId,
)) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord),
);
const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle;
const shouldGenerateTitle = shouldGenerateSessionTitle({
recentTurnCount: recentTurns.length,
isTitleManuallyEdited:
latestSessionState?.isTitleManuallyEdited ?? false,
});
if (shouldGenerateTitle) {
sessionTitle = await generateSessionTitle(runtime, {
sessionId: binding.sessionId,
latestAssistantMessage: assistantText,
latestUserMessage: parsed.data.message,
fallbackTitle: existingSessionTitle,
});
}
const nextSessionRecord = await sessionMetadataStore.touch(latestSessionRecord, {
...(sessionTitle && sessionTitle !== existingSessionTitle
? { title: sessionTitle }
: {}),
});
if (
shouldGenerateTitle &&
sessionTitle &&
sessionTitle !== existingSessionTitle
) {
publish("session_title", {
session_id: clientSessionId,
title: sessionTitle,
});
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
}
const latestTurn = extractLatestFrontendTurn(activeRun.messages);
if (latestTurn) {
void learningOrchestrator.onTurnCompleted({
...latestTurn,
requestContext,
sessionId: clientSessionId,
}).catch((error) => {
logger.warn(
{ err: error, sessionId: clientSessionId },
"stream-completed learning failed",
);
});
}
}
} finally {
if (abortController.signal.aborted) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
});
}
await persistQueue.catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
sessionBridge.finalizeRequest(clientSessionId);
activeRun.status = abortController.signal.aborted
? activeRun.status === "aborted"
? "aborted"
: "aborted"
: activeRun.status === "running"
? "completed"
: activeRun.status;
lastRunStatuses.set(clientSessionId, activeRun.status);
for (const subscriber of activeRun.subscribers) {
subscriber.close();
}
activeRun.subscribers.clear();
if (
activeRun.pendingPermissions.size === 0 &&
activeRun.pendingQuestions.size === 0
) {
activeRuns.delete(clientSessionId);
}
streamClosed = true;
req.off("close", handleClientClose);
res.off("close", handleClientClose);
}
if (!res.writableEnded && !res.destroyed) {
res.end();
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat stream failed");
if (res.headersSent) {
if (!res.writableEnded && !res.destroyed) {
res.write(toSse("error", {
message: "chat stream failed",
detail,
}));
res.end();
}
return;
}
res.status(500).json({
message: "chat stream failed",
detail,
});
}
});
return chatRouter;
};
const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
+171
View File
@@ -0,0 +1,171 @@
import { type Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { logger } from "../logger.js";
import { type ResultReferenceResolver } from "../results/resolver.js";
import { RESULT_REFERENCE_KIND } from "../results/store.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortPayloadSchema = z.object({
session_id: z.string().max(128),
});
type RegisterAuxiliaryRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
resultReferenceResolver: ResultReferenceResolver;
sessionBridge: ChatSessionBridge;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatAuxiliaryRoutes = (
chatRouter: Router,
{
activeRuns,
lastRunStatuses,
resultReferenceResolver,
sessionBridge,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterAuxiliaryRoutesOptions,
) => {
chatRouter.get("/render-ref/:render_ref", async (req, res) => {
const renderRef = req.params.render_ref?.trim();
const authContext = getLocalAgentContext(req);
const userId = authContext.userId;
const projectId = authContext.projectId;
const clientSessionId =
typeof req.query.session_id === "string"
? req.query.session_id.trim()
: undefined;
if (!renderRef) {
res.status(400).json({
message: "render_ref is required",
});
return;
}
const result = await resultReferenceResolver.getFullAuthorized(
renderRef,
{
actorKey: toActorKey(userId),
clientSessionId,
projectId,
},
{
expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
},
);
if (!result) {
res.status(404).json({ message: "render_ref not found" });
return;
}
res.json(result);
});
chatRouter.post("/abort", async (req, res) => {
const parsed = abortPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
const binding = sessionRecord
? await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
sessionId: sessionRecord.sessionId,
})
: null;
const run = activeRuns.get(parsed.data.session_id);
if (run && run.status === "running") {
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (sessionRecord) {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
}
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
});
subscriber.close();
}
run.subscribers.clear();
}
if (!binding && !run) {
res.status(204).end();
return;
}
logger.info(
{
clientSessionId: parsed.data.session_id,
sessionId: binding?.sessionId ?? parsed.data.session_id,
},
"aborted chat session by client request",
);
res.status(202).json({
session_id: parsed.data.session_id,
aborted: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "chat abort failed");
res.status(500).json({
message: "chat abort failed",
detail,
});
}
});
};
+438
View File
@@ -0,0 +1,438 @@
import { type Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
updateLastAssistantQuestion,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
reply: z.enum(["once", "always", "reject"]),
message: z.string().max(1000).optional(),
});
const questionReplyPayloadSchema = z.object({
session_id: z.string().max(128),
answers: z.array(z.array(z.string().max(2000))).default([]),
});
const questionRejectPayloadSchema = z.object({
session_id: z.string().max(128),
});
type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>;
runtime: OpencodeRuntimeAdapter;
sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
export const registerChatInteractionRoutes = (
chatRouter: Router,
{
activeRuns,
runtime,
sessionMetadataStore,
sessionUiStateStore,
}: RegisterInteractionRoutesOptions,
) => {
chatRouter.post("/permission/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run || run.status !== "running") {
res.status(409).json({ message: "session is not waiting for permissions" });
return;
}
const pendingPermission = run.pendingPermissions.get(requestId);
if (!pendingPermission) {
res.status(404).json({ message: "permission request not found" });
return;
}
const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.replyPermission({
requestId,
sessionId: sessionRecord.sessionId,
reply: parsed.data.reply,
message: parsed.data.message,
});
} catch (error) {
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission error state",
);
});
res.status(502).json({
message: "permission reply failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "permission reply route failed");
res.status(500).json({
message: "permission reply route failed",
detail,
});
}
});
chatRouter.post("/question/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.replyQuestion({
requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reply failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "answered",
answers: parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
answers: parsed.data.answers,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reply route failed");
res.status(500).json({
message: "question reply route failed",
detail,
});
}
});
chatRouter.post("/question/:request_id/reject", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionRejectPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = run.pendingQuestions.get(requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.rejectQuestion({
requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.session_id,
request_id: requestId,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
detail,
});
}
});
};
+359
View File
@@ -0,0 +1,359 @@
import { logger } from "../logger.js";
import { type SessionTurnRecord } from "../sessions/transcriptStore.js";
import { MemoryStore } from "../memory/store.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { collectTextContent } from "./chatStream.js";
const TITLE_PROMPT_TIMEOUT_MS = 5000;
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
const RESTORE_TURN_LIMIT = 8;
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
const DEFAULT_SESSION_TITLE = "新对话";
const buildSessionTitle = (message: string) => {
const normalized = message.replace(/\s+/g, " ").trim();
if (!normalized) {
return DEFAULT_SESSION_TITLE;
}
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
};
const appendTitleContextMessage = (
lines: string[],
role: "用户" | "助手",
content: string | undefined,
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
) => {
const normalized = content?.replace(/\s+/g, " ").trim();
if (!normalized) {
return;
}
lines.push(`${role}:${normalized.slice(0, maxLength)}`);
};
const buildTitleConversationContext = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT);
const recentMessages = messages
.filter(
(message) =>
message.info.role === "user" || message.info.role === "assistant",
)
.map((message) => ({
role: message.info.role,
content: collectTextContent(message.parts)
.replace(/\s+/g, " ")
.trim()
.slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT),
}))
.filter((message) => message.content.length > 0);
if (recentMessages.length === 0) {
return "";
}
const formattedMessages = recentMessages.map(
(message) => `${message.role === "user" ? "用户" : "助手"}:${message.content}`,
);
const fullConversation = formattedMessages.join("\n");
if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) {
return fullConversation;
}
const headCount = Math.min(4, formattedMessages.length);
const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount));
const middleOmitted = formattedMessages.length > headCount + tailCount;
const summary = [
...formattedMessages.slice(0, headCount),
...(middleOmitted ? ["……(中间省略若干轮对话)"] : []),
...formattedMessages.slice(-tailCount),
].join("\n");
return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT);
};
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
const normalized = rawTitle
.replace(/\s+/g, " ")
.replace(/^标题[::]\s*/i, "")
.replace(/["'“”‘’`]/g, "")
.replace(/[。!?!?,,、;;::]+$/g, "")
.trim();
if (!normalized) {
return fallback;
}
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
};
export const shouldGenerateSessionTitle = (options: {
recentTurnCount: number;
isTitleManuallyEdited: boolean;
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
export const generateSessionTitle = async (
runtime: OpencodeRuntimeAdapter,
options: {
sessionId: string;
latestUserMessage: string;
latestAssistantMessage?: string;
fallbackTitle?: string;
},
) => {
const fallbackTitle = options.fallbackTitle?.trim();
const fallback =
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
? fallbackTitle
: buildSessionTitle(options.latestUserMessage);
let titleSessionId: string | undefined;
try {
const scopedContext: string[] = [];
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
const conversation =
scopedContext.length > 0
? scopedContext.join("\n")
: await buildTitleConversationContext(runtime, options.sessionId);
if (!conversation) {
return fallback;
}
const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`);
titleSessionId = titleSession.id;
const request = runtime
.prompt(
titleSession.id,
[
"你是会话标题生成器。",
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
"优先概括用户当前真实需求和助手最终结论。",
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
"不要直接照抄用户任一条消息原文。",
"只输出标题本身。",
"",
conversation,
].join("\n"),
)
.then(async () => {
await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS);
const messages = await runtime.messages(titleSession.id, 20);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const title = collectTextContent(assistantMessage?.parts ?? []);
return normalizeGeneratedTitle(title, fallback);
});
const timeout = new Promise<string>((resolve) => {
setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS);
});
return await Promise.race([request, timeout]);
} catch (error) {
logger.warn({ err: error }, "failed to generate session title, using fallback");
return fallback;
} finally {
if (titleSessionId) {
await runtime.abortSession(titleSessionId).catch((error) => {
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
});
}
}
};
export const getConversationTurnStats = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 12);
return messages.reduce(
(stats, message) => {
if (message.info.role === "user") {
stats.userMessageCount += 1;
} else if (message.info.role === "assistant") {
stats.assistantMessageCount += 1;
}
return stats;
},
{
userMessageCount: 0,
assistantMessageCount: 0,
},
);
};
export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore,
actorKey: string,
projectKey: string,
options: {
recentTurns: SessionTurnRecord[];
persistedMessages?: unknown[];
message: string;
restoreConversation?: boolean;
},
) => {
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
const restoredConversation = options.restoreConversation === false
? ""
: buildRestoredConversationFromMessages(options.persistedMessages) ||
buildRestoredConversationContext(options.recentTurns);
if (!snapshot && !restoredConversation) {
return options.message;
}
return [snapshot, restoredConversation, `[Current user request]\n${options.message}`]
.filter(Boolean)
.join("\n\n");
};
export const shouldRestoreConversationForRuntime = (options: {
hadExistingSessionRecord: boolean;
runtimeHasConversation: boolean;
}) => !options.hadExistingSessionRecord || !options.runtimeHasConversation;
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
const formattedTurns = recentTurns
.slice(-RESTORE_TURN_LIMIT)
.flatMap((turn) => [
`用户:${compactMessage(turn.userMessage)}`,
`助手:${compactMessage(turn.assistantMessage)}`,
])
.filter((entry) => entry.length > 0);
if (formattedTurns.length === 0) {
return "";
}
const conversation = formattedTurns.join("\n");
const trimmedConversation =
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
: conversation;
return [
"[Previous conversation context]",
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
trimmedConversation,
].join("\n");
};
const compactMessage = (value: string) => {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
: normalized;
};
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isSyntheticAssistantError = (content: string) =>
/^⚠️\s*\*\*(请求已中断|错误[::]?)/.test(content);
export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return null;
}
for (let index = messages.length - 1; index >= 0; index -= 1) {
const assistant = messages[index];
if (!isObjectRecord(assistant) || assistant.role !== "assistant") {
continue;
}
const assistantMessage =
typeof assistant.content === "string"
? assistant.content.replace(/\s+/g, " ").trim()
: "";
if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) {
continue;
}
const user = messages
.slice(0, index)
.reverse()
.find((message) => isObjectRecord(message) && message.role === "user");
if (!isObjectRecord(user) || typeof user.content !== "string") {
continue;
}
const userMessage = user.content.replace(/\s+/g, " ").trim();
if (!userMessage) {
continue;
}
return {
assistantMessage,
toolCallCount: estimateFrontendToolCallCount(assistant),
userMessage,
};
}
return null;
};
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
if (!Array.isArray(messages) || messages.length === 0) {
return "";
}
const formattedMessages = messages
.slice(-(RESTORE_TURN_LIMIT * 2 + 2))
.flatMap((message) => {
if (!isObjectRecord(message)) {
return [];
}
const role = message.role;
const content = message.content;
if ((role !== "user" && role !== "assistant") || typeof content !== "string") {
return [];
}
const normalizedContent = compactMessage(content);
if (!normalizedContent) {
return [];
}
if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) {
return [];
}
return [`${role === "user" ? "用户" : "助手"}:${normalizedContent}`];
});
if (formattedMessages.length === 0) {
return "";
}
const conversation = formattedMessages.join("\n");
const trimmedConversation =
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
: conversation;
return [
"[Previous conversation context]",
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
trimmedConversation,
].join("\n");
};
const estimateFrontendToolCallCount = (assistant: Record<string, unknown>) => {
const progress = Array.isArray(assistant.progress) ? assistant.progress : [];
const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : [];
const toolProgressCount = progress.filter(
(item) =>
isObjectRecord(item) &&
(item.phase === "tool" ||
(typeof item.id === "string" && item.id.startsWith("tool-"))),
).length;
return Math.max(toolProgressCount, artifacts.length);
};
+991
View File
@@ -0,0 +1,991 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
import { type SupportedModel } from "../chat/models.js";
import { logger } from "../logger.js";
import {
type PermissionReply,
type OpencodeRuntimeAdapter,
} from "../runtime/opencode.js";
import { toUiEnvelopeFromToolCall } from "../uiEnvelope/fromToolCall.js";
import { createEnvelopeId } from "../uiEnvelope/ids.js";
import {
buildPermissionDetail,
buildPermissionV2Detail,
buildReasoningProgressDetail,
buildSessionStatusDetail,
buildToolProgressDetail,
collectTextContent,
extractRequestReason,
extractSkillAuditInfo,
getErrorMessage,
getToolProgressTitle,
getUnknownErrorMessage,
hasToolParams,
isPermissionAskedEvent,
isPermissionRepliedEvent,
isPermissionV2AskedEvent,
isPermissionV2RepliedEvent,
isQuestionAskedEvent,
isObjectRecord,
isQuestionRejectedEvent,
isQuestionRepliedEvent,
isQuestionV2AskedEvent,
isQuestionV2RejectedEvent,
isQuestionV2RepliedEvent,
isSessionEvent,
isSkillEvent,
logDevelopmentDebug,
normalizeQuestionAnswers,
normalizeQuestionPayload,
normalizeQuestionToolPayload,
normalizeTodoPriority,
normalizeTodoStatus,
normalizeToolParams,
normalizeToolStatus,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export {
collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
type TodoItemPayload,
type TodoUpdatePayload,
} from "./chatStreamEvents.js";
export type ApprovalMode = "request" | "always";
type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter;
sessionId: string;
clientSessionId: string;
message: string;
model?: SupportedModel;
approvalMode?: ApprovalMode;
traceId?: string;
projectId?: string;
signal?: AbortSignal;
write: (event: string, data: Record<string, unknown>) => void;
};
type ProgressStatus = "running" | "completed" | "error";
type ProgressPayload = {
id: string;
phase: string;
status: ProgressStatus;
title: string;
detail?: string;
};
const getPermissionTarget = (metadata: unknown) => {
if (!isObjectRecord(metadata)) {
return undefined;
}
for (const key of ["command", "path", "file", "filepath", "directory"]) {
const value = metadata[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
};
const toRuntimeModel = (model?: SupportedModel) => {
if (!model) {
return undefined;
}
const [providerID, modelID] = model.split("/");
if (!providerID || !modelID) {
return undefined;
}
return {
providerID,
modelID,
};
};
const emitFallbackMessage = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
clientSessionId: string,
write: (event: string, data: Record<string, unknown>) => void,
) => {
const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const parts = assistantMessage?.parts ?? [];
const text = collectTextContent(parts);
if (text) {
write("token", {
session_id: clientSessionId,
content: text,
});
}
};
export const streamPromptResponse = async ({
runtime,
sessionId,
clientSessionId,
message,
model,
approvalMode = "request",
traceId,
projectId,
signal,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
toolCallCount: number;
}> => {
const eventStream = await runtime.subscribeEvents();
const iterator = eventStream[Symbol.asyncIterator]();
const requestStartedAt = Date.now();
const promptStartedAt = Date.now();
const progressStartedAtMap = new Map<string, number>();
const finalizedProgressIds = new Set<string>();
const emittedToolParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>();
const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">();
const toolStatuses = new Map<string, string>();
let firstSessionEventLogged = false;
let firstNonStatusEventLogged = false;
let firstTokenLogged = false;
let firstReasoningLogged = false;
let firstToolEventLogged = false;
let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null;
let sawResponseActivity = false;
let emittedText = false;
let toolCallCount = 0;
let done = false;
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
const debugContext = {
sessionId,
clientSessionId,
traceId,
projectId,
model: model ?? null,
};
logDevelopmentDebug("chat stream started", {
...debugContext,
messageChars: message.length,
});
const abortPromise = signal
? new Promise<{ type: "abort" }>((resolve) => {
if (signal.aborted) {
resolve({ type: "abort" });
return;
}
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
once: true,
});
})
: null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
const now = Date.now();
const startedAt = progressStartedAtMap.get(id) ?? now;
if (!progressStartedAtMap.has(id)) {
progressStartedAtMap.set(id, startedAt);
}
if (status === "running") {
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
return;
}
const durationMs = Math.max(0, now - startedAt);
finalizedProgressIds.add(id);
progressStartedAtMap.delete(id);
write("progress", {
session_id: clientSessionId,
id,
phase,
status,
title,
detail,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
});
};
emitProgress({
id: "request-received",
phase: "start",
status: "running",
title: "已收到请求,正在启动 Agent 分析",
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
});
const promptPromise = runtime
.prompt(sessionId, message, toRuntimeModel(model))
.then(() => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt resolved", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
});
})
.catch((error: unknown) => {
promptSettled = true;
logDevelopmentDebug("runtime.prompt failed", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
error: getUnknownErrorMessage(error),
});
throw error;
});
logDevelopmentDebug("runtime.prompt dispatched", {
...debugContext,
});
try {
while (!done) {
if (signal?.aborted) {
aborted = true;
logDevelopmentDebug("chat stream noticed abort signal", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
break;
}
const nextEvent = iterator
.next()
.then((result) => ({ type: "event" as const, result }));
const nextPrompt = promptSettled
? null
: promptPromise.then(
() => ({ type: "prompt" as const }),
(error: unknown) => ({ type: "prompt-error" as const, error }),
);
const next = await Promise.race(
[
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
...(abortPromise ? [abortPromise] : []),
],
);
if (next.type === "abort") {
aborted = true;
break;
}
if (next.type === "prompt-error") {
throw next.error;
}
if (next.type === "prompt") {
continue;
}
if (next.result.done) {
break;
}
const event = next.result.value as OpencodeEvent;
if (!isSessionEvent(event, sessionId)) {
continue;
}
if (!firstSessionEventLogged) {
firstSessionEventLogged = true;
logDevelopmentDebug("first session event received", {
...debugContext,
eventType: event.type,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
if (event.type === "session.status") {
const nextStatus = event.properties.status.type;
const nextStatusMessage =
"message" in event.properties.status &&
typeof event.properties.status.message === "string"
? event.properties.status.message
: null;
if (
nextStatus !== lastSessionStatus ||
nextStatusMessage !== lastSessionStatusMessage
) {
lastSessionStatus = nextStatus;
lastSessionStatusMessage = nextStatusMessage;
logDevelopmentDebug("session status updated", {
...debugContext,
status: nextStatus,
statusMessage: nextStatusMessage,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
emitProgress({
id: "session-status",
phase: "session",
status: event.properties.status.type === "idle" ? "completed" : "running",
title:
event.properties.status.type === "retry"
? `模型请求重试中:${event.properties.status.message}`
: event.properties.status.type === "busy"
? "Agent 正在处理请求"
: "Agent 已空闲",
detail: buildSessionStatusDetail(event.properties.status),
});
continue;
}
if (!firstNonStatusEventLogged) {
firstNonStatusEventLogged = true;
logDevelopmentDebug("first non-status session event received", {
...debugContext,
eventType: event.type,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
if (isPermissionAskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission request received", {
...debugContext,
requestId: event.properties.id,
permission: event.properties.permission,
patterns: event.properties.patterns,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
});
if (approvalMode === "always") {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
});
continue;
}
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.permission,
patterns: event.properties.patterns,
target: getPermissionTarget(event.properties.metadata),
always: event.properties.always,
tool: event.properties.tool,
created_at: Date.now(),
} satisfies PermissionRequestPayload);
continue;
}
if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission v2 request received", {
...debugContext,
requestId: event.properties.id,
action: event.properties.action,
resources: event.properties.resources,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: approvalMode === "always" ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
detail:
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
});
if (approvalMode === "always") {
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "always",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "always" satisfies PermissionReply,
});
continue;
}
write("permission_request", {
session_id: clientSessionId,
request_id: event.properties.id,
permission: event.properties.action,
patterns: event.properties.resources,
target: getPermissionTarget(event.properties.metadata),
always: event.properties.save ?? [],
tool: undefined,
created_at: Date.now(),
} satisfies PermissionRequestPayload);
continue;
}
if (isPermissionRepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission request replied", {
...debugContext,
requestId: event.properties.requestID,
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
reply: event.properties.reply satisfies PermissionReply,
});
continue;
}
if (isPermissionV2RepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("permission v2 request replied", {
...debugContext,
requestId: event.properties.requestID,
reply: event.properties.reply,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `permission-${event.properties.requestID}`,
phase: "permission",
status: event.properties.reply === "reject" ? "error" : "completed",
title:
event.properties.reply === "reject"
? "权限请求已拒绝"
: "权限请求已允许",
detail:
event.properties.reply === "always"
? "已允许本次请求,并记住同类权限。"
: event.properties.reply === "once"
? "已允许本次请求。"
: "已拒绝本次请求。",
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
reply: event.properties.reply satisfies PermissionReply,
});
continue;
}
if (isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request received", {
...debugContext,
requestId: event.properties.id,
questionCount: event.properties.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: event.properties.questions
.map((question) => question.question)
.join("\n"),
});
const payload = normalizeQuestionPayload(event, clientSessionId);
emittedQuestionRequestIds.add(payload.request_id);
write("question_request", payload);
continue;
}
if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request replied", {
...debugContext,
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已收到补充信息",
detail: normalizeQuestionAnswers(event.properties.answers)
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("\n"),
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
answers: normalizeQuestionAnswers(event.properties.answers),
});
continue;
}
if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) {
sawResponseActivity = true;
logDevelopmentDebug("question request rejected", {
...debugContext,
requestId: event.properties.requestID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${event.properties.requestID}`,
phase: "question",
status: "completed",
title: "已跳过补充信息",
detail: "用户选择跳过本次补充信息。",
});
write("question_response", {
session_id: clientSessionId,
request_id: event.properties.requestID,
rejected: true,
});
continue;
}
if (isSkillEvent(event)) {
sawResponseActivity = true;
const { name, reason, payload } = extractSkillAuditInfo(event);
logDevelopmentDebug("skill event received", {
...debugContext,
skill: name,
reason: reason || null,
payloadKeys: Object.keys(payload).slice(0, 8),
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
void writeLlmRequestAuditLog({
kind: "skill",
sessionId: sessionId,
clientSessionId,
traceId,
projectId,
target: name,
reason,
reasonProvided: Boolean(reason),
payload,
}).catch((error) => {
logger.warn({ err: error }, "failed to write skill audit log");
});
}
if (event.type === "message.updated") {
if (event.properties.info.role === "assistant") {
sawResponseActivity = true;
}
continue;
}
if (event.type === "message.part.delta" && event.properties.field === "text") {
sawResponseActivity = true;
const partType = partTypes.get(event.properties.partID);
if (partType === "text") {
if (!firstTokenLogged) {
firstTokenLogged = true;
logDevelopmentDebug("first response token emitted", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
emittedText = true;
write("token", {
session_id: clientSessionId,
content: event.properties.delta,
});
} else if (partType === "reasoning") {
if (!firstReasoningLogged) {
firstReasoningLogged = true;
logDevelopmentDebug("first reasoning delta received", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
reasoningDeltas.set(event.properties.partID, pending);
} else if (!partType) {
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
pendingPartTextDeltas.set(event.properties.partID, pending);
}
continue;
}
if (event.type === "message.part.updated") {
sawResponseActivity = true;
const part = event.properties.part;
partTypes.set(part.id, part.type);
if (part.type === "text") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
pendingPartTextDeltas.delete(part.id);
for (const content of pending) {
emittedText = true;
write("token", {
session_id: clientSessionId,
content,
});
}
} else if (part.type === "reasoning") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
if (pending.length > 0) {
const existing = reasoningDeltas.get(part.id) ?? [];
reasoningDeltas.set(part.id, existing.concat(pending));
}
pendingPartTextDeltas.delete(part.id);
const reasoningStatus = part.time.end ? "completed" : "running";
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
reasoningStatuses.set(part.id, reasoningStatus);
logDevelopmentDebug("reasoning part status changed", {
...debugContext,
partId: part.id,
status: reasoningStatus,
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
const reasoningDetail = buildReasoningProgressDetail(
reasoningDeltas.get(part.id) ?? [],
part.time.end,
);
emitProgress({
id: part.id,
phase: "planning",
status: part.time.end ? "completed" : "running",
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
detail: reasoningDetail,
});
}
if (part.type === "tool") {
if (!firstToolEventLogged) {
firstToolEventLogged = true;
logDevelopmentDebug("first tool event received", {
...debugContext,
partId: part.id,
tool: part.tool,
status: part.state.status,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const toolParams = normalizeToolParams(part.state.input);
const reason = extractRequestReason(toolParams);
const isToolFinalState =
part.state.status === "completed" || part.state.status === "error";
const nextToolStatus = String(part.state.status);
if (toolStatuses.get(part.id) !== nextToolStatus) {
toolStatuses.set(part.id, nextToolStatus);
logDevelopmentDebug("tool part status changed", {
...debugContext,
partId: part.id,
tool: part.tool,
status: nextToolStatus,
reason: reason || null,
inputKeys: Object.keys(toolParams).slice(0, 8),
error:
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
}
const questionToolPayload = normalizeQuestionToolPayload(
part,
toolParams,
clientSessionId,
);
if (questionToolPayload) {
if (!emittedQuestionToolParts.has(part.id)) {
emittedQuestionToolParts.add(part.id);
emittedQuestionRequestIds.add(questionToolPayload.request_id);
logDevelopmentDebug("question tool request received", {
...debugContext,
requestId: questionToolPayload.request_id,
tool: part.tool,
questionCount: questionToolPayload.questions.length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: `question-${questionToolPayload.request_id}`,
phase: "question",
status: "running",
title: "等待用户补充信息",
detail: questionToolPayload.questions
.map((question) => question.question)
.join("\n"),
});
write("question_request", questionToolPayload);
}
continue;
}
emitProgress({
id: part.id,
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
detail: buildToolProgressDetail(
part.tool,
part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
),
});
if (
!emittedToolParts.has(part.id) &&
(hasToolParams(toolParams) || isToolFinalState)
) {
emittedToolParts.add(part.id);
toolCallCount += 1;
if (!reason) {
logger.warn(
{
tool: part.tool,
sessionId: sessionId,
clientSessionId,
},
"llm tool request missing reason",
);
}
void writeLlmRequestAuditLog({
kind: "tool",
sessionId: sessionId,
clientSessionId,
traceId,
projectId,
target: part.tool,
reason,
reasonProvided: Boolean(reason),
payload: toolParams,
}).catch((error) => {
logger.warn({ err: error }, "failed to write tool audit log");
});
write("tool_call", {
session_id: clientSessionId,
tool: part.tool,
params: toolParams,
reason,
});
const envelope = toUiEnvelopeFromToolCall({
tool: part.tool,
params: toolParams,
reason,
});
if (envelope) {
write("ui_envelope", {
session_id: clientSessionId,
envelope_id: createEnvelopeId(part.tool),
created_at: Date.now(),
envelope,
});
}
}
}
continue;
}
if (event.type === "todo.updated") {
sawResponseActivity = true;
const todos = event.properties.todos as Array<{
content: string;
status: string;
priority: string;
}>;
const normalizedTodos = todos.map((todo, index) => ({
id: `todo-${index}-${todo.content.slice(0, 24)}`,
content: todo.content,
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
updated_at: Date.now(),
}));
const completed = todos.filter(
(todo) => todo.status === "completed",
).length;
emitProgress({
id: "todo-progress",
phase: "planning",
status: completed === todos.length ? "completed" : "running",
title: `计划进度 ${completed}/${todos.length}`,
detail: todos
.map((todo) => `${todo.status}: ${todo.content}`)
.join("\n"),
});
write("todo_update", {
session_id: clientSessionId,
todos: normalizedTodos,
created_at: Date.now(),
} satisfies TodoUpdatePayload);
continue;
}
if (event.type === "session.error") {
sawResponseActivity = true;
logDevelopmentDebug("session error received", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
error: event.properties.error
? getErrorMessage(event.properties.error)
: "opencode session error",
});
write("error", {
session_id: clientSessionId,
message: event.properties.error
? getErrorMessage(event.properties.error)
: "opencode session error",
detail: event.properties.error?.name,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
});
failed = true;
done = true;
continue;
}
if (event.type === "session.idle") {
if (!sawResponseActivity) {
logDevelopmentDebug("ignoring session idle before response activity", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
continue;
}
logDevelopmentDebug("session idle received", {
...debugContext,
emittedText,
toolCallCount,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
emitProgress({
id: "session-status",
phase: "session",
status: "completed",
title: "Agent 已完成处理",
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
});
done = true;
}
}
if (aborted) {
logDevelopmentDebug("chat stream aborting session", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await runtime.abortSession(sessionId).catch((error) => {
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
});
await runtime.waitForSessionIdle(sessionId).catch((error) => {
logger.warn(
{ sessionId: sessionId, err: error },
"failed while waiting for aborted opencode session to become idle",
);
});
return { aborted: true, failed: false, toolCallCount };
}
if (failed) {
return { aborted: false, failed: true, toolCallCount };
}
await promptPromise;
if (!emittedText) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
});
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
}
emitProgress({
id: "request-received",
phase: "start",
status: "completed",
title: "请求处理完成",
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
});
emitProgress({
id: "request-completed",
phase: "complete",
status: "completed",
title: "分析完成",
detail: emittedText
? "最终回答已生成并推送到前端。"
: "已完成分析,并通过兜底消息补发最终回答内容。",
});
write("done", {
session_id: clientSessionId,
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
});
logDevelopmentDebug("chat stream completed", {
...debugContext,
emittedText,
toolCallCount,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
});
return { aborted: false, failed: false, toolCallCount };
} finally {
await iterator.return?.(undefined);
if (!promptSettled && !aborted) {
await promptPromise.catch(() => undefined);
} else if (!promptSettled) {
void promptPromise.catch(() => undefined);
}
logDevelopmentDebug("chat stream cleanup finished", {
...debugContext,
promptSettled,
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
});
}
};
+460
View File
@@ -0,0 +1,460 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { logger } from "../logger.js";
import { type QuestionAnswers } from "../runtime/opencode.js";
export type PermissionRequestPayload = {
session_id: string;
request_id: string;
permission: string;
patterns: string[];
target?: string;
always: string[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
type QuestionOptionPayload = {
label: string;
description: string;
};
type QuestionInfoPayload = {
header: string;
question: string;
options: QuestionOptionPayload[];
multiple?: boolean;
custom?: boolean;
};
export type QuestionRequestPayload = {
session_id: string;
request_id: string;
questions: QuestionInfoPayload[];
tool?: {
messageID: string;
callID: string;
};
created_at: number;
};
export type TodoItemPayload = {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high";
created_at?: number;
updated_at?: number;
};
export type TodoUpdatePayload = {
session_id: string;
message_id?: string;
todos: TodoItemPayload[];
created_at: number;
};
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
memory_manager: "记忆写入",
geocode: "地理编码",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
web_search: "网页搜索",
locate_features: "地图定位",
zoom_to_map: "地图缩放",
view_history: "历史数据面板",
view_scada: "SCADA 面板",
show_chart: "图表渲染",
render_junctions: "节点渲染",
};
export const logDevelopmentDebug = (
message: string,
metadata: Record<string, unknown>,
) => {
if (!isDevelopmentDebugLoggingEnabled) {
return;
}
logger.info(metadata, message);
};
export const getErrorMessage = (error: {
name: string;
data?: { message?: string };
}) => error.data?.message ?? error.name;
export const getUnknownErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"name" in error &&
typeof error.name === "string"
) {
const maybeData = "data" in error ? error.data : undefined;
return getErrorMessage({
name: error.name,
data:
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
: undefined,
});
}
return error instanceof Error ? error.message : String(error);
};
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
if (isObjectRecord(value)) {
return value;
}
if (typeof value === "string") {
try {
const parsed = JSON.parse(value) as unknown;
return isObjectRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
};
export const extractRequestReason = (params: Record<string, unknown>) => {
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
for (const key of candidates) {
const value = params[key];
if (typeof value === "string") {
const normalized = value.trim();
if (normalized) {
return normalized;
}
}
}
return "";
};
export const isSkillEvent = (event: OpencodeEvent) =>
event.type.toLowerCase().includes("skill");
export const extractSkillAuditInfo = (event: OpencodeEvent) => {
const payload = isObjectRecord(event.properties)
? (event.properties as Record<string, unknown>)
: {};
const candidateName =
typeof payload.skill === "string"
? payload.skill
: typeof payload.skillName === "string"
? payload.skillName
: typeof payload.name === "string"
? payload.name
: event.type;
const reason = extractRequestReason(payload);
return {
name: candidateName,
reason,
payload,
};
};
export const hasToolParams = (params: Record<string, unknown>) =>
Object.keys(params).length > 0;
export const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
"properties" in event &&
typeof event.properties === "object" &&
event.properties !== null &&
"sessionID" in event.properties &&
event.properties.sessionID === sessionId;
export const isPermissionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
event.type === "permission.asked";
export const isPermissionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
event.type === "permission.v2.asked";
export const isPermissionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
event.type === "permission.replied";
export const isPermissionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
event.type === "permission.v2.replied";
export const isQuestionAskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.asked" }> =>
event.type === "question.asked";
export const isQuestionV2AskedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.asked" }> =>
event.type === "question.v2.asked";
export const isQuestionRepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.replied" }> =>
event.type === "question.replied";
export const isQuestionV2RepliedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.replied" }> =>
event.type === "question.v2.replied";
export const isQuestionRejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.rejected" }> =>
event.type === "question.rejected";
export const isQuestionV2RejectedEvent = (
event: OpencodeEvent,
): event is Extract<OpencodeEvent, { type: "question.v2.rejected" }> =>
event.type === "question.v2.rejected";
export const buildPermissionDetail = (
event: Extract<OpencodeEvent, { type: "permission.asked" }>,
) => {
const patterns = event.properties.patterns.length
? event.properties.patterns.join(", ")
: event.properties.permission;
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
};
export const buildPermissionV2Detail = (
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
) => {
const resources = event.properties.resources.length
? event.properties.resources.join(", ")
: event.properties.action;
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
};
export const normalizeQuestionPayload = (
event: Extract<OpencodeEvent, { type: "question.asked" | "question.v2.asked" }>,
clientSessionId: string,
): QuestionRequestPayload => ({
session_id: clientSessionId,
request_id: event.properties.id,
questions: event.properties.questions.map((question) => ({
header: question.header,
question: question.question,
options: question.options.map((option) => ({
label: option.label,
description: option.description,
})),
multiple: question.multiple,
custom: question.custom,
})),
tool: event.properties.tool,
created_at: Date.now(),
});
export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) =>
Array.isArray(answers)
? answers.map((answer) =>
Array.isArray(answer)
? answer.filter((item): item is string => typeof item === "string")
: [],
)
: [];
const questionToolNames = new Set(["question", "request_user_input"]);
const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] =>
Array.isArray(value)
? value.filter(isObjectRecord).map((option) => ({
label: typeof option.label === "string" ? option.label : "",
description:
typeof option.description === "string" ? option.description : "",
})).filter((option) => option.label.trim().length > 0)
: [];
const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => {
if (!isObjectRecord(value) || typeof value.question !== "string") {
return undefined;
}
const question = value.question.trim();
if (!question) {
return undefined;
}
return {
header:
typeof value.header === "string" && value.header.trim()
? value.header
: "补充信息",
question,
options: normalizeQuestionOptions(value.options),
multiple: typeof value.multiple === "boolean" ? value.multiple : undefined,
custom: typeof value.custom === "boolean" ? value.custom : undefined,
};
};
export const normalizeQuestionToolPayload = (
part: Extract<Part, { type: "tool" }>,
params: Record<string, unknown>,
clientSessionId: string,
): QuestionRequestPayload | undefined => {
if (!questionToolNames.has(part.tool)) {
return undefined;
}
const questions = Array.isArray(params.questions)
? params.questions
.map(normalizeToolQuestionInfo)
.filter((question): question is QuestionInfoPayload => Boolean(question))
: [];
if (questions.length === 0) {
return undefined;
}
return {
session_id: clientSessionId,
request_id: part.callID || part.id,
questions,
tool: {
messageID: part.messageID,
callID: part.callID,
},
created_at: Date.now(),
};
};
export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => {
if (status === "in_progress" || status === "completed" || status === "cancelled") {
return status;
}
return "pending";
};
export const normalizeTodoPriority = (
priority: string,
): TodoItemPayload["priority"] | undefined => {
if (priority === "low" || priority === "medium" || priority === "high") {
return priority;
}
return undefined;
};
export const collectTextContent = (parts: Part[]) =>
parts
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("");
export const normalizeToolStatus = (status: string) => {
if (status === "completed") return "completed";
if (status === "error") return "error";
return "running";
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const normalizeProgressText = (chunks: string[]) =>
chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") {
return status.message
? `模型请求需要重试,原因:${status.message}`
: "模型请求正在重试,等待下一次响应。";
}
if (status.type === "busy") {
return status.message
? `Agent 正在处理中:${status.message}`
: "Agent 正在执行推理、工具调用或结果整理。";
}
if (status.type === "idle") {
return status.message
? `Agent 已空闲:${status.message}`
: "当前会话暂时没有待处理任务。";
}
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
};
export const buildReasoningProgressDetail = (
chunks: string[],
ended?: string | number | Date | null,
) => {
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
export const buildToolProgressDetail = (
tool: string,
status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`;
}
if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
}
return `${toolName} 正在执行${reasonText}${paramsText}`;
};
export const getToolProgressTitle = (tool: string, status: string) => {
const toolName = toolLabels[tool] ?? tool;
if (status === "completed") return `${toolName} 已完成`;
if (status === "error") return `${toolName} 执行失败`;
if (status === "pending") return `准备调用 ${toolName}`;
return `正在调用 ${toolName}`;
};
+365
View File
@@ -0,0 +1,365 @@
import { type PermissionReply } from "../runtime/opencode.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import {
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;
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 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 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),
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,
},
];