From f58abe80034e5351e1cc0728013731c959d39c2e Mon Sep 17 00:00:00 2001 From: Huarch Date: Wed, 13 May 2026 17:33:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=9B=E5=BA=A6=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E6=8C=81=E7=BB=AD=E6=97=B6=E9=97=B4=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++- src/routes/chat.ts | 84 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index aceadb8..ec1764f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ .opencode/node_modules/ .local.env -.vscode \ No newline at end of file +.vscode +data/ +logs/ diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 0e7a127..8896fd7 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -240,7 +240,6 @@ export const buildChatRouter = ( }), ); } - res.write(toSse("done", { session_id: clientSessionId })); } } } finally { @@ -341,6 +340,16 @@ type StreamPromptOptions = { write: (event: string, data: Record) => void; }; +type ProgressStatus = "running" | "completed" | "error"; + +type ProgressPayload = { + id: string; + phase: string; + status: ProgressStatus; + title: string; + detail?: string; +}; + const streamPromptResponse = async ({ runtime, opencodeSessionId, @@ -353,6 +362,9 @@ const streamPromptResponse = async ({ }: StreamPromptOptions): Promise<{ aborted: boolean; failed: boolean }> => { const eventStream = await runtime.subscribeEvents(); const iterator = eventStream[Symbol.asyncIterator](); + const requestStartedAt = Date.now(); + const progressStartedAtMap = new Map(); + const finalizedProgressIds = new Set(); const emittedToolParts = new Set(); const partTypes = new Map(); const pendingPartTextDeltas = new Map(); @@ -375,8 +387,48 @@ const streamPromptResponse = async ({ }) : null; - write("progress", { - session_id: clientSessionId, + 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", @@ -438,8 +490,7 @@ const streamPromptResponse = async ({ } if (event.type === "session.status") { - write("progress", { - session_id: clientSessionId, + emitProgress({ id: "session-status", phase: "session", status: event.properties.status.type === "idle" ? "completed" : "running", @@ -515,8 +566,7 @@ const streamPromptResponse = async ({ reasoningDeltas.get(part.id) ?? [], part.time.end, ); - write("progress", { - session_id: clientSessionId, + emitProgress({ id: part.id, phase: "planning", status: part.time.end ? "completed" : "running", @@ -530,8 +580,7 @@ const streamPromptResponse = async ({ const isToolFinalState = part.state.status === "completed" || part.state.status === "error"; - write("progress", { - session_id: clientSessionId, + emitProgress({ id: part.id, phase: "tool", status: normalizeToolStatus(part.state.status), @@ -587,8 +636,7 @@ const streamPromptResponse = async ({ const completed = event.properties.todos.filter( (todo) => todo.status === "completed", ).length; - write("progress", { - session_id: clientSessionId, + emitProgress({ id: "todo-progress", phase: "planning", status: completed === event.properties.todos.length ? "completed" : "running", @@ -607,6 +655,7 @@ const streamPromptResponse = async ({ ? 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; @@ -614,8 +663,7 @@ const streamPromptResponse = async ({ } if (event.type === "session.idle") { - write("progress", { - session_id: clientSessionId, + emitProgress({ id: "session-status", phase: "session", status: "completed", @@ -641,16 +689,14 @@ const streamPromptResponse = async ({ if (!emittedText) { await emitFallbackMessage(runtime, opencodeSessionId, clientSessionId, write); } - write("progress", { - session_id: clientSessionId, + emitProgress({ id: "request-received", phase: "start", status: "completed", title: "请求处理完成", detail: "本次请求的分析、工具执行和结果整理流程已经完成。", }); - write("progress", { - session_id: clientSessionId, + emitProgress({ id: "request-completed", phase: "complete", status: "completed", @@ -659,6 +705,10 @@ const streamPromptResponse = async ({ ? "最终回答已生成并推送到前端。" : "已完成分析,并通过兜底消息补发最终回答内容。", }); + write("done", { + session_id: clientSessionId, + total_duration_ms: Math.max(0, Date.now() - requestStartedAt), + }); return { aborted: false, failed: false }; } finally { await iterator.return?.(undefined);