diff --git a/next/src/components/preview-pane.tsx b/next/src/components/preview-pane.tsx index ee8a531f..d11adf9d 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 00000000..f2bec15f --- /dev/null +++ b/next/src/lib/agents/__tests__/argv.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { parseLine, makeParser } 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("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("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( + "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 and accumulates successive steps", () => { + const line1 = JSON.stringify({ + type: "step_finish", + part: { + type: "step-finish", + tokens: { + input: 10, + output: 2, + cache: { + read: 3, + write: 4, + }, + }, + cost: 0.01, + }, + }); + + 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", + 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, + }, + ]); + + 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 c6cc169d..cec5842c 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 @@ -362,7 +369,47 @@ 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; + 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 }); + } + if (part?.tokens && typeof part.tokens === "object") { + const tokens = part.tokens as { + input?: number; + 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: state.opencodeAccumulatedInputTokens, + output_tokens: state.opencodeAccumulatedOutputTokens, + cache_read_input_tokens: state.opencodeAccumulatedCacheReadTokens, + cache_creation_input_tokens: state.opencodeAccumulatedCacheWriteTokens, + }, + }); + } + 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") { 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 7b0036ee..b563f749 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 17306c5a..c82abad6 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": {