chatccc 0.2.222 → 0.2.224
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.sample.json +40 -39
- package/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +33 -0
- package/src/__tests__/builtin-config.test.ts +1 -0
- package/src/__tests__/card-plain-text.test.ts +7 -6
- package/src/__tests__/cards.test.ts +179 -178
- package/src/__tests__/ccc-adapter.test.ts +20 -0
- package/src/__tests__/config-reload.test.ts +52 -52
- package/src/__tests__/config-sample.test.ts +41 -40
- package/src/__tests__/orchestrator.test.ts +836 -803
- package/src/__tests__/progress-reducer.test.ts +110 -0
- package/src/__tests__/session.test.ts +1183 -1173
- package/src/__tests__/terminal-renderer.test.ts +143 -0
- package/src/adapters/ccc-adapter.ts +1 -0
- package/src/builtin/cli.ts +42 -1
- package/src/builtin/index.ts +10 -0
- package/src/cards.ts +280 -275
- package/src/config.ts +203 -192
- package/src/progress/reducer.ts +108 -0
- package/src/progress/terminal-renderer.ts +190 -0
- package/src/progress/view.ts +77 -0
- package/src/session.ts +938 -937
- package/src/web-ui.ts +610 -607
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildBlockLines,
|
|
5
|
+
TerminalProgressRenderer,
|
|
6
|
+
} from "../progress/terminal-renderer.ts";
|
|
7
|
+
import { progressView } from "../progress/view.ts";
|
|
8
|
+
|
|
9
|
+
class FakeOut {
|
|
10
|
+
chunks: string[] = [];
|
|
11
|
+
columns = 100;
|
|
12
|
+
write(s: string): boolean {
|
|
13
|
+
this.chunks.push(s);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
get output(): string {
|
|
17
|
+
return this.chunks.join("");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 测试桩:仅实现 write 的假输出流,类型上按 WritableStream 对待 */
|
|
22
|
+
function asOut(out: FakeOut): NodeJS.WritableStream & { columns?: number } {
|
|
23
|
+
return out as unknown as NodeJS.WritableStream & { columns?: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildBlockLines", () => {
|
|
31
|
+
it("renders generating status line with header title and stop hint", () => {
|
|
32
|
+
const lines = buildBlockLines(
|
|
33
|
+
progressView({ headerTitle: "正在启动 Agent · 0秒", text: "" }),
|
|
34
|
+
100,
|
|
35
|
+
);
|
|
36
|
+
expect(lines[0]).toContain("⏳ 正在启动 Agent · 0秒");
|
|
37
|
+
expect(lines[0]).toContain("Ctrl+C 停止");
|
|
38
|
+
expect(lines[1]).toContain("等待 Agent 输出...");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders done / stopped / error status lines", () => {
|
|
42
|
+
const done = buildBlockLines(progressView({ status: "done", text: "ok" }), 100);
|
|
43
|
+
expect(done[0]).toContain("✅ 完成");
|
|
44
|
+
const stopped = buildBlockLines(progressView({ status: "stopped", text: "x" }), 100);
|
|
45
|
+
expect(stopped[0]).toContain("⏹ 已停止");
|
|
46
|
+
const error = buildBlockLines(progressView({ status: "error", text: "x" }), 100);
|
|
47
|
+
expect(error[0]).toContain("❌ 异常结束");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders tool lines with emoji and status mark", () => {
|
|
51
|
+
const lines = buildBlockLines(
|
|
52
|
+
progressView({
|
|
53
|
+
text: "",
|
|
54
|
+
tools: [
|
|
55
|
+
{ id: "t1", name: "edit_file", status: "running", detail: "edit a.ts" },
|
|
56
|
+
{ id: "t2", name: "run_command", status: "ok", summary: "npm test passed" },
|
|
57
|
+
{ id: "t3", name: "search_code", status: "error", summary: "regex error" },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
100,
|
|
61
|
+
);
|
|
62
|
+
expect(lines[1]).toContain("edit_file");
|
|
63
|
+
expect(lines[1]).toContain("…");
|
|
64
|
+
expect(lines[1]).toContain("edit a.ts");
|
|
65
|
+
expect(lines[2]).toContain("✓");
|
|
66
|
+
expect(lines[2]).toContain("npm test passed");
|
|
67
|
+
expect(lines[3]).toContain("✗");
|
|
68
|
+
expect(lines[3]).toContain("regex error");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("truncates long body to maxBodyLines", () => {
|
|
72
|
+
const text = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n");
|
|
73
|
+
const lines = buildBlockLines(progressView({ text }), 100, 5);
|
|
74
|
+
expect(lines.filter((l) => l.startsWith("line"))).toHaveLength(5);
|
|
75
|
+
expect(lines.join("\n")).toContain("...");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clips lines wider than terminal width to avoid wrapping", () => {
|
|
79
|
+
const lines = buildBlockLines(
|
|
80
|
+
progressView({ text: "x".repeat(200) }),
|
|
81
|
+
30,
|
|
82
|
+
);
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
expect(line.length).toBeLessThanOrEqual(30);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("TerminalProgressRenderer", () => {
|
|
90
|
+
it("begin writes hide-cursor and the first frame", () => {
|
|
91
|
+
const out = new FakeOut();
|
|
92
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
93
|
+
r.begin(progressView({ text: "hello" }));
|
|
94
|
+
expect(out.output.startsWith("\x1b[?25l")).toBe(true);
|
|
95
|
+
expect(out.output).toContain("hello");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("end finalizes the block and restores cursor, keeping block on screen", () => {
|
|
99
|
+
const out = new FakeOut();
|
|
100
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
101
|
+
r.begin(progressView({ text: "a" }));
|
|
102
|
+
const before = out.chunks.length;
|
|
103
|
+
r.end(progressView({ status: "done", text: "final" }));
|
|
104
|
+
const delta = out.output.slice(out.chunks.slice(0, before).join("").length);
|
|
105
|
+
expect(delta).toContain("✅ 完成");
|
|
106
|
+
expect(delta.endsWith("\x1b[?25h")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("render is frame-throttled and flush forces immediate redraw", () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
const out = new FakeOut();
|
|
112
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), frameMs: 100 });
|
|
113
|
+
|
|
114
|
+
r.begin(progressView({ text: "" }));
|
|
115
|
+
const outputAfterBegin = out.output;
|
|
116
|
+
|
|
117
|
+
r.render(progressView({ text: "one" }));
|
|
118
|
+
r.render(progressView({ text: "two" }));
|
|
119
|
+
r.render(progressView({ text: "three" }));
|
|
120
|
+
// 节流窗口内多次 render 不应产生任何输出
|
|
121
|
+
expect(out.output).toBe(outputAfterBegin);
|
|
122
|
+
|
|
123
|
+
vi.advanceTimersByTime(100);
|
|
124
|
+
// 合并后只重绘一次,且展示的是最新视图
|
|
125
|
+
expect(out.output).toContain("three");
|
|
126
|
+
expect(out.output).not.toContain("two");
|
|
127
|
+
|
|
128
|
+
r.flush();
|
|
129
|
+
// flush 立即重绘当前视图(输出继续增长且仍是三帧内容)
|
|
130
|
+
expect(out.output.length).toBeGreaterThan(outputAfterBegin.length);
|
|
131
|
+
expect(out.output).toContain("three");
|
|
132
|
+
r.dispose();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("clears leftover lines when the block shrinks", () => {
|
|
136
|
+
const out = new FakeOut();
|
|
137
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
138
|
+
r.begin(progressView({ text: "a\nb\nc\nd" }));
|
|
139
|
+
r.end(progressView({ status: "done", text: "short" }));
|
|
140
|
+
expect(out.output).toContain("\x1b[2K\n");
|
|
141
|
+
expect(out.output).toContain("\x1b[J");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -41,6 +41,7 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
|
|
|
41
41
|
...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
|
|
42
42
|
...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
|
|
43
43
|
...(options.model !== undefined ? { model: options.model } : {}),
|
|
44
|
+
...(options.effort !== undefined ? { effort: options.effort } : {}),
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
return {
|
package/src/builtin/cli.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* npx tsx src/builtin/cli.ts
|
|
6
6
|
* npx tsx src/builtin/cli.ts --model deepseek-v4-pro
|
|
7
7
|
* npx tsx src/builtin/cli.ts --stream-json --prompt "hello"
|
|
8
|
+
*
|
|
9
|
+
* 交互模式(TTY)下,单轮回复渲染为固定"过程区块"(飞书过程卡片的终端形态):
|
|
10
|
+
* 状态行 + 折叠工具行 + 原地更新正文,不再滚屏刷 JSON;完成/停止/异常后定型
|
|
11
|
+
* 留在屏幕上。非 TTY(管道/CI)回退为纯文本流式输出;--stream-json 机器接口不变。
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import * as readline from "node:readline";
|
|
@@ -15,6 +19,9 @@ import { fileURLToPath } from "node:url";
|
|
|
15
19
|
import { listBuiltinContextSessions } from "./context.js";
|
|
16
20
|
import { resolveBuiltinSession, type BuiltinResumeRequest } from "./session-select.js";
|
|
17
21
|
import { createCtrlCState } from "./sigint.js";
|
|
22
|
+
import { reduceProgress } from "../progress/reducer.ts";
|
|
23
|
+
import { TerminalProgressRenderer } from "../progress/terminal-renderer.ts";
|
|
24
|
+
import { progressView, type ProgressView } from "../progress/view.ts";
|
|
18
25
|
import type { ChatEvent, ChatSessionConfig, ChatSessionOptions } from "./index.js";
|
|
19
26
|
|
|
20
27
|
interface ParsedArgs {
|
|
@@ -60,6 +67,9 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
|
|
|
60
67
|
if (arg === "--model" && next !== undefined) {
|
|
61
68
|
config.model = next;
|
|
62
69
|
i++;
|
|
70
|
+
} else if (arg === "--effort" && next !== undefined) {
|
|
71
|
+
config.effort = next;
|
|
72
|
+
i++;
|
|
63
73
|
} else if (arg === "--base-url" && next !== undefined) {
|
|
64
74
|
config.baseURL = next;
|
|
65
75
|
i++;
|
|
@@ -110,6 +120,7 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
|
|
|
110
120
|
"",
|
|
111
121
|
"Options:",
|
|
112
122
|
` --model <name> Model name (overrides config.ccc.model, current default ${appConfig.ccc.model})`,
|
|
123
|
+
` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.ccc.effort)`,
|
|
113
124
|
` --base-url <url> API base URL (current default ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
|
|
114
125
|
" --api-key <key> API key (overrides config.ccc.DEEPSEEK_API_KEY)",
|
|
115
126
|
" --cwd <path> Working directory",
|
|
@@ -373,10 +384,28 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
373
384
|
currentAbort = new AbortController();
|
|
374
385
|
const signal = currentAbort.signal;
|
|
375
386
|
|
|
387
|
+
// TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
|
|
388
|
+
// 工具调用折叠为单行;非 TTY(管道/CI)回退为纯文本流式输出。
|
|
389
|
+
const useTerminalBlock = process.stdout.isTTY === true;
|
|
390
|
+
const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
|
|
391
|
+
let view: ProgressView | null = null;
|
|
392
|
+
if (renderer) {
|
|
393
|
+
view = progressView({ headerTitle: "生成中..." });
|
|
394
|
+
renderer.begin(view);
|
|
395
|
+
}
|
|
396
|
+
let rendererEnded = false;
|
|
397
|
+
|
|
376
398
|
try {
|
|
377
399
|
let lastAccumulated = "";
|
|
378
400
|
for await (const event of session.chat(input, signal)) {
|
|
379
|
-
if (
|
|
401
|
+
if (renderer && view) {
|
|
402
|
+
view = reduceProgress(view, event);
|
|
403
|
+
if (event.type === "text" || event.type === "compact") {
|
|
404
|
+
renderer.render(view);
|
|
405
|
+
} else {
|
|
406
|
+
renderer.flush();
|
|
407
|
+
}
|
|
408
|
+
} else if (event.type === "text") {
|
|
380
409
|
const newText = event.accumulated.slice(lastAccumulated.length);
|
|
381
410
|
process.stdout.write(newText);
|
|
382
411
|
lastAccumulated = event.accumulated;
|
|
@@ -395,8 +424,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
395
424
|
}
|
|
396
425
|
}
|
|
397
426
|
} catch (err) {
|
|
427
|
+
if (renderer && view) {
|
|
428
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
429
|
+
view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
|
|
430
|
+
renderer.end(view);
|
|
431
|
+
rendererEnded = true;
|
|
432
|
+
console.log("");
|
|
433
|
+
}
|
|
398
434
|
console.log(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
|
|
399
435
|
} finally {
|
|
436
|
+
if (renderer && view && !rendererEnded) {
|
|
437
|
+
// 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
|
|
438
|
+
renderer.end(view);
|
|
439
|
+
console.log("");
|
|
440
|
+
}
|
|
400
441
|
currentAbort = null;
|
|
401
442
|
ctrlCState.reset();
|
|
402
443
|
}
|
package/src/builtin/index.ts
CHANGED
|
@@ -100,6 +100,11 @@ export interface ChatSessionConfig {
|
|
|
100
100
|
apiKey?: string;
|
|
101
101
|
/** 模型名称;传入时覆盖 config.ccc.model */
|
|
102
102
|
model?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Reasoning effort(none/minimal/low/medium/high/xhigh/max);
|
|
105
|
+
* 传入时覆盖 config.ccc.effort,留空不传 reasoning_effort 请求字段。
|
|
106
|
+
*/
|
|
107
|
+
effort?: string;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
export interface ChatSessionOptions {
|
|
@@ -156,6 +161,7 @@ export class ChatSession {
|
|
|
156
161
|
private cwd: string;
|
|
157
162
|
private context: BuiltinContextManager;
|
|
158
163
|
private maxSteps?: number;
|
|
164
|
+
private effort: string;
|
|
159
165
|
|
|
160
166
|
constructor(
|
|
161
167
|
overrides: ChatSessionConfig = {},
|
|
@@ -170,6 +176,7 @@ export class ChatSession {
|
|
|
170
176
|
|
|
171
177
|
const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
|
|
172
178
|
const modelId = overrides.model ?? appConfig.ccc.model;
|
|
179
|
+
this.effort = (overrides.effort ?? appConfig.ccc.effort ?? "").trim();
|
|
173
180
|
|
|
174
181
|
const provider = createOpenAICompatible({
|
|
175
182
|
name: "deepseek",
|
|
@@ -260,6 +267,9 @@ export class ChatSession {
|
|
|
260
267
|
tools: createBuiltinFileTools(this.cwd),
|
|
261
268
|
stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
|
|
262
269
|
abortSignal: signal,
|
|
270
|
+
// DeepSeek OpenAI 兼容接口:providerOptions.deepseek.reasoningEffort
|
|
271
|
+
// 由 @ai-sdk/openai-compatible 自动映射为请求体 reasoning_effort 字段
|
|
272
|
+
...(this.effort ? { providerOptions: { deepseek: { reasoningEffort: this.effort } } } : {}),
|
|
263
273
|
});
|
|
264
274
|
|
|
265
275
|
const stream = result.fullStream ?? textStreamToFullStream(result.textStream);
|