From a27017e60dbec2e63ce8fd199b08d7d11d4c7b85 Mon Sep 17 00:00:00 2001 From: aliyun8639545015 Date: Thu, 21 May 2026 21:30:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OpenCode=20=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=A7=A3=E6=9E=90=E5=B9=B6=E4=BC=98=E5=8C=96=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析 OpenCode 嵌套 part 输出,补充用量信息解析,减少重复 session 日志,并在等待生成时显示更准确的预览反馈。 --- next/src/components/preview-pane.tsx | 6 ++ next/src/lib/agents/__tests__/argv.test.ts | 93 ++++++++++++++++++++++ next/src/lib/agents/argv.ts | 36 ++++++++- next/src/lib/i18n.ts | 4 +- next/src/lib/use-convert.ts | 13 ++- 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 next/src/lib/agents/__tests__/argv.test.ts diff --git a/next/src/components/preview-pane.tsx b/next/src/components/preview-pane.tsx index ee8a531..d11adf9 100644 --- a/next/src/components/preview-pane.tsx +++ b/next/src/components/preview-pane.tsx @@ -542,7 +542,13 @@ function LogLine({ entry }: { entry: LogEntry }) { function PreviewPlaceholder({ status }: { status: string }) { const t = useT(); const isRunning = status === "running"; + const [, setTick] = useState(0); const stats = useStore((s) => selectActiveTask(s)?.stats ?? EMPTY_STATS); + useEffect(() => { + if (!isRunning) return; + const id = setInterval(() => setTick((n) => n + 1), 500); + return () => clearInterval(id); + }, [isRunning]); const secWaited = stats.startedAt ? ((Date.now() - stats.startedAt) / 1000).toFixed(1) : "0"; return (
diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts new file mode 100644 index 0000000..bd17138 --- /dev/null +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { parseLine } from "../argv"; + +describe("parseLine opencode", () => { + it("extracts text from nested part payload", () => { + const line = JSON.stringify({ + type: "text", + sessionID: "ses_test", + part: { + type: "text", + text: "ok", + }, + }); + + expect(parseLine("opencode", line)).toContainEqual({ + kind: "delta", + text: "ok", + }); + }); + + it("extracts session only from step start payload", () => { + expect( + parseLine( + "opencode", + JSON.stringify({ + type: "step_start", + sessionID: "ses_test", + part: { + type: "step-start", + }, + }), + ), + ).toContainEqual({ + kind: "meta", + key: "session", + value: "ses_test", + }); + + expect( + parseLine( + "opencode", + JSON.stringify({ + type: "text", + sessionID: "ses_test", + part: { + type: "text", + text: "ok", + }, + }), + ), + ).not.toContainEqual({ + kind: "meta", + key: "session", + value: "ses_test", + }); + }); + + it("extracts usage from step finish payload", () => { + const line = JSON.stringify({ + type: "step_finish", + part: { + type: "step-finish", + tokens: { + input: 10, + output: 2, + cache: { + read: 3, + write: 4, + }, + }, + cost: 0.01, + }, + }); + + expect(parseLine("opencode", line)).toEqual([ + { + kind: "meta", + key: "usage", + value: { + input_tokens: 10, + output_tokens: 2, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 4, + }, + }, + { + kind: "meta", + key: "cost_usd", + value: 0.01, + }, + ]); + }); +}); diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index c6cc169..ad1092b 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -362,7 +362,41 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); } - if (agent === "opencode" || agent === "qwen") { + if (agent === "opencode") { + const part = + obj.part && typeof obj.part === "object" + ? (obj.part as Record) + : null; + if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); + if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); + if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); + if (typeof part?.text === "string") out.push({ kind: "delta", text: part.text }); + if (typeof part?.content === "string") out.push({ kind: "delta", text: part.content }); + if (typeof part?.message === "string") out.push({ kind: "delta", text: part.message }); + if (obj.type === "step_start" && typeof obj.sessionID === "string") { + out.push({ kind: "meta", key: "session", value: obj.sessionID }); + } + if (part?.tokens && typeof part.tokens === "object") { + const tokens = part.tokens as { + input?: number; + output?: number; + cache?: { read?: number; write?: number }; + }; + out.push({ + kind: "meta", + key: "usage", + value: { + input_tokens: tokens.input, + output_tokens: tokens.output, + cache_read_input_tokens: tokens.cache?.read, + cache_creation_input_tokens: tokens.cache?.write, + }, + }); + } + if (typeof part?.cost === "number") out.push({ kind: "meta", key: "cost_usd", value: part.cost }); + } + + if (agent === "qwen") { if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); diff --git a/next/src/lib/i18n.ts b/next/src/lib/i18n.ts index 7b0036e..b563f74 100644 --- a/next/src/lib/i18n.ts +++ b/next/src/lib/i18n.ts @@ -558,7 +558,7 @@ const en: Dict = { "preview.placeholder.idleTitle.accent": "⚡", "preview.placeholder.idleTitle.part2": "convert", "preview.placeholder.runningDescr": - "First bytes land in a few seconds, then it streams. Waited {sec}s so far.", + "The agent is generating. Some CLIs, including OpenCode, return larger chunks instead of token streams. Waited {sec}s.", "preview.placeholder.idleDescr": "Preview · view source · one-click copy for WeChat / Twitter / Zhihu · PNG export.", "preview.placeholder.chip.article": "📖 Article", @@ -918,7 +918,7 @@ const zhCN: Dict = { "preview.placeholder.idleTitle.part1": "把内容粘到左侧, 然后", "preview.placeholder.idleTitle.accent": "⚡", "preview.placeholder.idleTitle.part2": "转换", - "preview.placeholder.runningDescr": "首字会在几秒内到达, 之后流式更新。当前已等 {sec}s。", + "preview.placeholder.runningDescr": "Agent 正在生成。有些 CLI(包括 OpenCode)会成块返回, 而不是逐字流式输出。当前已等 {sec}s。", "preview.placeholder.idleDescr": "支持预览 / 查看源码 / 一键复制公众号·推特·知乎 / 截图导出 PNG。", "preview.placeholder.chip.article": "📖 文章", "preview.placeholder.chip.deck": "🎬 PPT", diff --git a/next/src/lib/use-convert.ts b/next/src/lib/use-convert.ts index 17306c5..c82abad 100644 --- a/next/src/lib/use-convert.ts +++ b/next/src/lib/use-convert.ts @@ -187,7 +187,18 @@ function handleEvent(taskId: string, event: string, data: unknown, startedAt: nu break; } case "delta": { - if (typeof d.text === "string") store.appendHtmlFor(taskId, d.text); + if (typeof d.text === "string" && d.text) { + const task = store.tasks.find((t) => t.id === taskId); + const isFirstChunk = !task?.stats.firstByteAt; + store.appendHtmlFor(taskId, d.text); + if (isFirstChunk) { + store.pushLogFor(taskId, { + kind: "delta", + elapsed, + text: `收到首个 HTML 片段 (${formatBytes(d.text.length)})`, + }); + } + } break; } case "html": { From 6c1db8bfa2359b945ff428aed1abaa6c79cf5757 Mon Sep 17 00:00:00 2001 From: aliyun8639545015 Date: Fri, 22 May 2026 11:30:09 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OpenCode=20=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E4=BA=8B=E4=BB=B6=E9=87=8D=E5=A4=8D=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode 的 JSON 事件可能同时携带顶层 text/content/message 字段和嵌套 part.text/content/message 字段。之前解析逻辑会把所有匹配字段都作为 delta 输出,导致同一 HTML 片段被重复追加,进而污染预览内容。 本次调整为每个事件只选择一个优先文本来源:part.text、part.content、part.message、obj.text、obj.content、obj.message。这样既保留兼容性,又避免重复 delta。 同时补充回归测试,覆盖顶层文本和嵌套文本同时存在时只输出一个 delta 的场景。 --- next/src/lib/agents/__tests__/argv.test.ts | 18 ++++++++++++++++++ next/src/lib/agents/argv.ts | 21 +++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts index bd17138..2543708 100644 --- a/next/src/lib/agents/__tests__/argv.test.ts +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -18,6 +18,24 @@ describe("parseLine opencode", () => { }); }); + it("emits one delta when top-level and nested text are both present", () => { + const line = JSON.stringify({ + type: "text", + text: "ok", + part: { + type: "text", + text: "ok", + }, + }); + + expect(parseLine("opencode", line)).toEqual([ + { + kind: "delta", + text: "ok", + }, + ]); + }); + it("extracts session only from step start payload", () => { expect( parseLine( diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index ad1092b..4c87d0c 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -367,12 +367,21 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age obj.part && typeof obj.part === "object" ? (obj.part as Record) : null; - if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); - if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); - if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); - if (typeof part?.text === "string") out.push({ kind: "delta", text: part.text }); - if (typeof part?.content === "string") out.push({ kind: "delta", text: part.content }); - if (typeof part?.message === "string") out.push({ kind: "delta", text: part.message }); + const text = + typeof part?.text === "string" + ? part.text + : typeof part?.content === "string" + ? part.content + : typeof part?.message === "string" + ? part.message + : typeof obj.text === "string" + ? obj.text + : typeof obj.content === "string" + ? obj.content + : typeof obj.message === "string" + ? obj.message + : null; + if (text !== null) out.push({ kind: "delta", text }); if (obj.type === "step_start" && typeof obj.sessionID === "string") { out.push({ kind: "meta", key: "session", value: obj.sessionID }); } From ef14f704183e258f966f17b47b829cd9100b6f00 Mon Sep 17 00:00:00 2001 From: aliyun8639545015 Date: Fri, 22 May 2026 12:05:49 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OpenCode=20=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=8F=90=E5=8F=96=E9=98=BB=E6=96=AD=E5=8F=8A=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=BF=BD=E5=8A=A0=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复空字符串阻断 fallback 的边界问题:当嵌套的 part.text 为空字符串时,不再阻断解析,而是跳过空文本并继续 fallback 到顶层的有效文本字段(如 obj.content),确保有效 HTML 数据不丢失。 2. 优化每个事件只输出唯一非空 delta:对 [part.text, part.content, part.message, obj.text, obj.content, obj.message] 队列进行过滤,只选择第一个值存在且长度大于 0 的有效文本源,彻底杜绝重复 HTML 的可能性。 3. 补充 falls back 测试用例:在测试中覆盖「嵌套字段为空,顶层字段有值」的回退逻辑,确保未来的代码重构不引入此回归问题。 --- next/src/lib/agents/__tests__/argv.test.ts | 18 ++++++++++++++++++ next/src/lib/agents/argv.ts | 19 ++++--------------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts index 2543708..283bfa0 100644 --- a/next/src/lib/agents/__tests__/argv.test.ts +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -36,6 +36,24 @@ describe("parseLine opencode", () => { ]); }); + it("falls back to top-level text when nested text is empty", () => { + const line = JSON.stringify({ + type: "text", + content: "ok", + part: { + type: "text", + text: "", + }, + }); + + expect(parseLine("opencode", line)).toEqual([ + { + kind: "delta", + text: "ok", + }, + ]); + }); + it("extracts session only from step start payload", () => { expect( parseLine( diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index 4c87d0c..317ca86 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -367,21 +367,10 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age obj.part && typeof obj.part === "object" ? (obj.part as Record) : null; - const text = - typeof part?.text === "string" - ? part.text - : typeof part?.content === "string" - ? part.content - : typeof part?.message === "string" - ? part.message - : typeof obj.text === "string" - ? obj.text - : typeof obj.content === "string" - ? obj.content - : typeof obj.message === "string" - ? obj.message - : null; - if (text !== null) out.push({ kind: "delta", text }); + const text = [part?.text, part?.content, part?.message, obj.text, obj.content, obj.message].find( + (value): value is string => typeof value === "string" && value.length > 0, + ); + if (text) out.push({ kind: "delta", text }); if (obj.type === "step_start" && typeof obj.sessionID === "string") { out.push({ kind: "meta", key: "session", value: obj.sessionID }); } From 72fb684cd59449d16bb0fc0f221eed81dbdaf83b Mon Sep 17 00:00:00 2001 From: aliyun8639545015 Date: Fri, 22 May 2026 13:50:54 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=20OpenCode=20=E5=A4=9A?= =?UTF-8?q?=E6=AD=A5=20tokens=20=E5=92=8C=20cost=20=E7=9A=84=E7=B4=AF?= =?UTF-8?q?=E5=8A=A0=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 因为 step_start/step_finish 事件是 Step 级别的,之前每次都直接发射单步值覆盖 handleEvent 里的全局 stats,导致多步骤任务在前端只能显示最后一步的 tokens 和 cost。 本次修改将多步 tokens 和 cost 的计算累加任务放在 makeParser 的生命周期状态(ParseState)中,每次遇到新 step 时均进行累计相加,然后再发射累积的总额,让前端 handleEvent 的覆盖机制正确显示至今为止的总统计。 同时补充了多步骤累加的回归单测,确保多步完成时数值不会被直接覆盖。 --- next/src/lib/agents/__tests__/argv.test.ts | 43 ++++++++++++++++++++-- next/src/lib/agents/argv.ts | 27 +++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/next/src/lib/agents/__tests__/argv.test.ts b/next/src/lib/agents/__tests__/argv.test.ts index 283bfa0..f2bec15 100644 --- a/next/src/lib/agents/__tests__/argv.test.ts +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseLine } from "../argv"; +import { parseLine, makeParser } from "../argv"; describe("parseLine opencode", () => { it("extracts text from nested part payload", () => { @@ -91,8 +91,8 @@ describe("parseLine opencode", () => { }); }); - it("extracts usage from step finish payload", () => { - const line = JSON.stringify({ + it("extracts usage from step finish payload and accumulates successive steps", () => { + const line1 = JSON.stringify({ type: "step_finish", part: { type: "step-finish", @@ -108,7 +108,24 @@ describe("parseLine opencode", () => { }, }); - expect(parseLine("opencode", line)).toEqual([ + const line2 = JSON.stringify({ + type: "step_finish", + part: { + type: "step-finish", + tokens: { + input: 5, + output: 1, + cache: { + read: 1, + write: 1, + }, + }, + cost: 0.005, + }, + }); + + const parser = makeParser("opencode"); + expect(parser(line1)).toEqual([ { kind: "meta", key: "usage", @@ -125,5 +142,23 @@ describe("parseLine opencode", () => { value: 0.01, }, ]); + + expect(parser(line2)).toEqual([ + { + kind: "meta", + key: "usage", + value: { + input_tokens: 15, + output_tokens: 3, + cache_read_input_tokens: 4, + cache_creation_input_tokens: 5, + }, + }, + { + kind: "meta", + key: "cost_usd", + value: 0.015, + }, + ]); }); }); diff --git a/next/src/lib/agents/argv.ts b/next/src/lib/agents/argv.ts index 317ca86..cec5842 100644 --- a/next/src/lib/agents/argv.ts +++ b/next/src/lib/agents/argv.ts @@ -160,7 +160,14 @@ export type AgentParse = * with `--include-partial-messages` (or the equivalent) writes its output * twice. */ -export type ParseState = { sawStreamEventText?: boolean }; +export type ParseState = { + sawStreamEventText?: boolean; + opencodeAccumulatedInputTokens?: number; + opencodeAccumulatedOutputTokens?: number; + opencodeAccumulatedCacheReadTokens?: number; + opencodeAccumulatedCacheWriteTokens?: number; + opencodeAccumulatedCost?: number; +}; /** * Build a stateful per-invocation parser. Feed every stdout line through the @@ -380,18 +387,26 @@ function parseLineWithState(agent: string, line: string, state: ParseState): Age output?: number; cache?: { read?: number; write?: number }; }; + state.opencodeAccumulatedInputTokens = (state.opencodeAccumulatedInputTokens ?? 0) + (tokens.input ?? 0); + state.opencodeAccumulatedOutputTokens = (state.opencodeAccumulatedOutputTokens ?? 0) + (tokens.output ?? 0); + state.opencodeAccumulatedCacheReadTokens = (state.opencodeAccumulatedCacheReadTokens ?? 0) + (tokens.cache?.read ?? 0); + state.opencodeAccumulatedCacheWriteTokens = (state.opencodeAccumulatedCacheWriteTokens ?? 0) + (tokens.cache?.write ?? 0); + out.push({ kind: "meta", key: "usage", value: { - input_tokens: tokens.input, - output_tokens: tokens.output, - cache_read_input_tokens: tokens.cache?.read, - cache_creation_input_tokens: tokens.cache?.write, + input_tokens: state.opencodeAccumulatedInputTokens, + output_tokens: state.opencodeAccumulatedOutputTokens, + cache_read_input_tokens: state.opencodeAccumulatedCacheReadTokens, + cache_creation_input_tokens: state.opencodeAccumulatedCacheWriteTokens, }, }); } - if (typeof part?.cost === "number") out.push({ kind: "meta", key: "cost_usd", value: part.cost }); + if (typeof part?.cost === "number") { + state.opencodeAccumulatedCost = (state.opencodeAccumulatedCost ?? 0) + part.cost; + out.push({ kind: "meta", key: "cost_usd", value: state.opencodeAccumulatedCost }); + } } if (agent === "qwen") {