Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions next/src/components/preview-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center p-8" style={{ background: "var(--paper)" }}>
Expand Down
164 changes: 164 additions & 0 deletions next/src/lib/agents/__tests__/argv.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<html><body>ok</body></html>",
},
});

expect(parseLine("opencode", line)).toContainEqual({
kind: "delta",
text: "<html><body>ok</body></html>",
});
});

it("emits one delta when top-level and nested text are both present", () => {
const line = JSON.stringify({
type: "text",
text: "<html><body>ok</body></html>",
part: {
type: "text",
text: "<html><body>ok</body></html>",
},
});

expect(parseLine("opencode", line)).toEqual([
{
kind: "delta",
text: "<html><body>ok</body></html>",
},
]);
});

it("falls back to top-level text when nested text is empty", () => {
const line = JSON.stringify({
type: "text",
content: "<html>ok</html>",
part: {
type: "text",
text: "",
},
});

expect(parseLine("opencode", line)).toEqual([
{
kind: "delta",
text: "<html>ok</html>",
},
]);
});

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,
},
]);
});
});
51 changes: 49 additions & 2 deletions next/src/lib/agents/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>)
: 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 });
Expand Down
4 changes: 2 additions & 2 deletions next/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion next/src/lib/use-convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading