132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
import { inspect } from "node:util";
|
|
import type { CommandUsage } from "./openai.js";
|
|
|
|
const tty = process.stdout.isTTY;
|
|
const color = (code: number, text: string) => tty ? `\u001b[${code}m${text}\u001b[0m` : text;
|
|
const cyan = (text: string) => color(36, text);
|
|
const green = (text: string) => color(32, text);
|
|
const yellow = (text: string) => color(33, text);
|
|
const dim = (text: string) => color(2, text);
|
|
const tokenNumber = new Intl.NumberFormat("zh-CN");
|
|
const cumulativeUsage: Required<CommandUsage> = { inputTokens: 0, outputTokens: 0 };
|
|
let cumulativeUsagePrinted = false;
|
|
|
|
function tokenCount(value: unknown): number | undefined {
|
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function readUsage(value: unknown): Required<CommandUsage> | undefined {
|
|
if (typeof value !== "object" || value === null) return undefined;
|
|
const usage = value as Record<string, unknown>;
|
|
const inputTokens = tokenCount(usage.inputTokens);
|
|
const outputTokens = tokenCount(usage.outputTokens);
|
|
if (inputTokens === undefined && outputTokens === undefined) return undefined;
|
|
return {
|
|
inputTokens: inputTokens ?? 0,
|
|
outputTokens: outputTokens ?? 0,
|
|
};
|
|
}
|
|
|
|
function formatUsage(usage: Required<CommandUsage>): string {
|
|
const totalTokens = usage.inputTokens + usage.outputTokens;
|
|
return `输入 ${tokenNumber.format(usage.inputTokens)} · 输出 ${tokenNumber.format(usage.outputTokens)} · 合计 ${tokenNumber.format(totalTokens)}`;
|
|
}
|
|
|
|
export function printCumulativeUsage(): void {
|
|
if (cumulativeUsagePrinted) return;
|
|
cumulativeUsagePrinted = true;
|
|
process.stdout.write(`\n${cyan("━━━━━━━━ Token 累计 ━━━━━━━━")}\n`);
|
|
process.stdout.write(`${formatUsage(cumulativeUsage)}\n`);
|
|
process.stdout.write(`${cyan("━━━━━━━━━━━━━━━━━━━━━━━━━━")}\n`);
|
|
}
|
|
|
|
function details(event: Record<string, unknown>): string {
|
|
const copy = { ...event };
|
|
delete copy.type;
|
|
return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 });
|
|
}
|
|
|
|
export class TerminalRenderer {
|
|
private answerStarted = false;
|
|
private thinkingShown = false;
|
|
|
|
begin(model: string, effort: string, proxy?: string): void {
|
|
this.answerStarted = false;
|
|
this.thinkingShown = false;
|
|
process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`);
|
|
process.stdout.write(`${dim("模型")} ${model}\n`);
|
|
process.stdout.write(`${dim("思考深度")} ${effort}\n`);
|
|
if (proxy) process.stdout.write(`${dim("代理")} ${proxy}\n`);
|
|
}
|
|
|
|
event(event: Record<string, unknown>): void {
|
|
const type = typeof event.type === "string" ? event.type : "unknown";
|
|
|
|
switch (type) {
|
|
case "run_start":
|
|
process.stdout.write(`${dim("会话")} ${String(event.sessionId ?? "-")}\n`);
|
|
return;
|
|
case "turn_start":
|
|
process.stdout.write(`${cyan(`\n[Turn ${String(event.turnNumber ?? "?")}]`)}\n`);
|
|
return;
|
|
case "model_request_start":
|
|
process.stdout.write(`${dim("模型请求开始")} ${String(event.model ?? "")}\n`);
|
|
return;
|
|
case "thinking_start":
|
|
if (!this.thinkingShown) {
|
|
process.stdout.write(`${dim("思考中…")}\n`);
|
|
this.thinkingShown = true;
|
|
}
|
|
return;
|
|
case "thinking_delta":
|
|
case "thinking_end":
|
|
case "message_update":
|
|
case "model_trace":
|
|
case "run_end":
|
|
return;
|
|
case "text_delta": {
|
|
if (!this.answerStarted) {
|
|
process.stdout.write(`${green("\n最终回答:")}\n`);
|
|
this.answerStarted = true;
|
|
}
|
|
process.stdout.write(String(event.delta ?? ""));
|
|
return;
|
|
}
|
|
case "model_request_end":
|
|
process.stdout.write(`\n${dim(`模型请求结束 · ${String(event.stopReason ?? "unknown")}`)}\n`);
|
|
return;
|
|
case "turn_end": {
|
|
const usage = readUsage(event.usage);
|
|
if (usage) {
|
|
cumulativeUsage.inputTokens += usage.inputTokens;
|
|
cumulativeUsage.outputTokens += usage.outputTokens;
|
|
}
|
|
process.stdout.write(`${dim(`Turn ${String(event.turnNumber ?? "?")} 结束`)}\n`);
|
|
process.stdout.write(`${dim("Token 本轮")} · ${usage ? formatUsage(usage) : "不可用"}\n`);
|
|
process.stdout.write(`${dim("Token 累计")} · ${formatUsage(cumulativeUsage)}\n`);
|
|
return;
|
|
}
|
|
default:
|
|
if (type.includes("tool") || type.includes("permission") || type.includes("question")) {
|
|
process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(event)}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
stderr(chunk: string): void {
|
|
process.stderr.write(chunk);
|
|
}
|
|
|
|
finish(durationMs: number): void {
|
|
if (this.answerStarted) process.stdout.write("\n");
|
|
process.stdout.write(`${dim(`完成 · ${(durationMs / 1000).toFixed(2)}s`)}\n`);
|
|
process.stdout.write(`${cyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")}\n\n`);
|
|
}
|
|
|
|
fail(message: string): void {
|
|
process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`);
|
|
}
|
|
}
|