chatccc 0.2.187 → 0.2.188
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 +14 -9
- package/package.json +1 -1
- package/src/__tests__/builtin-config.test.ts +33 -0
- package/src/__tests__/chrome-devtools-guard.test.ts +40 -0
- package/src/__tests__/config-reload.test.ts +4 -3
- package/src/__tests__/config-sample.test.ts +17 -6
- package/src/__tests__/feishu-avatar.test.ts +180 -115
- package/src/__tests__/orchestrator.test.ts +165 -17
- package/src/__tests__/session.test.ts +17 -7
- package/src/adapters/codex-adapter.ts +26 -21
- package/src/builtin/cli.ts +8 -16
- package/src/builtin/index.ts +12 -11
- package/src/cards.ts +48 -7
- package/src/chrome-devtools-guard.ts +78 -2
- package/src/config.ts +86 -48
- package/src/feishu-api.ts +219 -190
- package/src/index.ts +2 -2
- package/src/orchestrator.ts +211 -19
- package/src/platform-adapter.ts +9 -1
- package/src/session.ts +92 -64
|
@@ -77,10 +77,11 @@ import {
|
|
|
77
77
|
_setAdapterForToolForTest,
|
|
78
78
|
_setSessionRegistryFileForTest,
|
|
79
79
|
_setSessionToolsFileForTest,
|
|
80
|
-
loadSessionRegistryForBinding,
|
|
81
|
-
recordSessionRegistry,
|
|
82
|
-
resetState,
|
|
83
|
-
|
|
80
|
+
loadSessionRegistryForBinding,
|
|
81
|
+
recordSessionRegistry,
|
|
82
|
+
resetState,
|
|
83
|
+
sessionInfoMap,
|
|
84
|
+
} from "../session.ts";
|
|
84
85
|
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
85
86
|
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
86
87
|
|
|
@@ -362,11 +363,11 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
362
363
|
expect(userText).not.toContain("/abd");
|
|
363
364
|
});
|
|
364
365
|
|
|
365
|
-
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
366
|
-
const platform = mockPlatform("feishu");
|
|
367
|
-
await recordSessionRegistry({
|
|
368
|
-
chatId: "feishu-p2p",
|
|
369
|
-
sessionId: "stale-sid",
|
|
366
|
+
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
367
|
+
const platform = mockPlatform("feishu");
|
|
368
|
+
await recordSessionRegistry({
|
|
369
|
+
chatId: "feishu-p2p",
|
|
370
|
+
sessionId: "stale-sid",
|
|
370
371
|
tool: "claude",
|
|
371
372
|
chatName: "旧私聊绑定",
|
|
372
373
|
running: false,
|
|
@@ -376,13 +377,109 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
376
377
|
|
|
377
378
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
378
379
|
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
379
|
-
const registry = await loadSessionRegistryForBinding();
|
|
380
|
-
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("
|
|
384
|
-
const platform = mockPlatform("feishu");
|
|
385
|
-
|
|
380
|
+
const registry = await loadSessionRegistryForBinding();
|
|
381
|
+
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("shows Claude effort switch card in an active Feishu session", async () => {
|
|
385
|
+
const platform = mockPlatform("feishu");
|
|
386
|
+
vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "claude-session", description: "Claude Session: sid-claude-effort" });
|
|
387
|
+
vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-claude-effort", tool: "claude" });
|
|
388
|
+
await recordSessionRegistry({
|
|
389
|
+
chatId: "feishu-chat",
|
|
390
|
+
sessionId: "sid-claude-effort",
|
|
391
|
+
tool: "claude",
|
|
392
|
+
chatName: "claude-session",
|
|
393
|
+
running: false,
|
|
394
|
+
});
|
|
395
|
+
sessionInfoMap.set("feishu-chat", {
|
|
396
|
+
sessionId: "sid-claude-effort",
|
|
397
|
+
tool: "claude",
|
|
398
|
+
turnCount: 0,
|
|
399
|
+
lastContextTokens: 0,
|
|
400
|
+
startTime: Date.now(),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await handleCommand(platform, "/effort", "feishu-chat", "ou-user", Date.now(), "group");
|
|
404
|
+
|
|
405
|
+
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
406
|
+
const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
|
|
407
|
+
const raw = JSON.stringify(card);
|
|
408
|
+
expect(raw).toContain("/effort low");
|
|
409
|
+
expect(raw).toContain("/effort xhigh");
|
|
410
|
+
expect(raw).toContain("/effort max");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("switches Codex effort for the current session and reflects it in /state", async () => {
|
|
414
|
+
const platform = mockPlatform("feishu");
|
|
415
|
+
vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "codex-session", description: "Codex Session: sid-codex-effort" });
|
|
416
|
+
vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-codex-effort", tool: "codex" });
|
|
417
|
+
_setAdapterForToolForTest("codex", mockAdapter("sid-codex-effort"));
|
|
418
|
+
await recordSessionRegistry({
|
|
419
|
+
chatId: "codex-chat",
|
|
420
|
+
sessionId: "sid-codex-effort",
|
|
421
|
+
tool: "codex",
|
|
422
|
+
chatName: "codex-session",
|
|
423
|
+
running: false,
|
|
424
|
+
});
|
|
425
|
+
sessionInfoMap.set("codex-chat", {
|
|
426
|
+
sessionId: "sid-codex-effort",
|
|
427
|
+
tool: "codex",
|
|
428
|
+
turnCount: 0,
|
|
429
|
+
lastContextTokens: 0,
|
|
430
|
+
startTime: Date.now(),
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
await handleCommand(platform, "/effort xhigh", "codex-chat", "ou-user", Date.now(), "group");
|
|
434
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
435
|
+
"codex-chat",
|
|
436
|
+
"Effort 切换",
|
|
437
|
+
expect.stringContaining("xhigh"),
|
|
438
|
+
"green",
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
vi.mocked(platform.sendCard).mockClear();
|
|
442
|
+
vi.mocked(platform.sendRawCard).mockClear();
|
|
443
|
+
await handleCommand(platform, "/state", "codex-chat", "ou-user", Date.now() + 1, "group");
|
|
444
|
+
|
|
445
|
+
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
446
|
+
const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
|
|
447
|
+
expect(JSON.stringify(card)).toContain("xhigh");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("rejects /effort in Cursor sessions", async () => {
|
|
451
|
+
const platform = mockPlatform("feishu");
|
|
452
|
+
vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "cursor-session", description: "Cursor Session: sid-cursor-effort" });
|
|
453
|
+
vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-cursor-effort", tool: "cursor" });
|
|
454
|
+
_setAdapterForToolForTest("cursor", mockAdapter("sid-cursor-effort"));
|
|
455
|
+
await recordSessionRegistry({
|
|
456
|
+
chatId: "cursor-chat",
|
|
457
|
+
sessionId: "sid-cursor-effort",
|
|
458
|
+
tool: "cursor",
|
|
459
|
+
chatName: "cursor-session",
|
|
460
|
+
running: false,
|
|
461
|
+
});
|
|
462
|
+
sessionInfoMap.set("cursor-chat", {
|
|
463
|
+
sessionId: "sid-cursor-effort",
|
|
464
|
+
tool: "cursor",
|
|
465
|
+
turnCount: 0,
|
|
466
|
+
lastContextTokens: 0,
|
|
467
|
+
startTime: Date.now(),
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await handleCommand(platform, "/effort high", "cursor-chat", "ou-user", Date.now(), "group");
|
|
471
|
+
|
|
472
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
473
|
+
"cursor-chat",
|
|
474
|
+
"Effort 切换",
|
|
475
|
+
expect.stringContaining("不支持 effort"),
|
|
476
|
+
"red",
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("handles /usage without creating a new Feishu group", async () => {
|
|
481
|
+
const platform = mockPlatform("feishu");
|
|
482
|
+
const usage = {
|
|
386
483
|
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
387
484
|
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
388
485
|
rateLimitResetCreditsAvailable: 2,
|
|
@@ -390,7 +487,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
390
487
|
{ grantedAt: "2026-06-12T04:01:47.770016Z", expiresAt: "2026-07-12T04:01:47.770016Z" },
|
|
391
488
|
{ grantedAt: "2026-06-18T00:44:23.904386Z", expiresAt: "2026-07-18T00:44:23.904386Z" },
|
|
392
489
|
],
|
|
393
|
-
}
|
|
490
|
+
};
|
|
491
|
+
mockGetCodexUsageSummary.mockResolvedValue(usage);
|
|
394
492
|
|
|
395
493
|
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
396
494
|
|
|
@@ -411,6 +509,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
411
509
|
expect(card.elements[0].text.content).toContain("[██░░░░░░░░░░░░░░░░░░]");
|
|
412
510
|
expect(card.elements[2].actions[0].text.content).toBe("发起重置");
|
|
413
511
|
expect(card.elements[2].actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
|
|
512
|
+
expect(platform.setChatAvatar).toHaveBeenCalledWith("feishu-p2p", "codex", "idle", { codexUsage: usage });
|
|
414
513
|
});
|
|
415
514
|
|
|
416
515
|
it("adds ChatGPT subscription expiry to Codex /usage when CDP lookup succeeds", async () => {
|
|
@@ -445,6 +544,24 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
445
544
|
expect(card.elements[0].text.content).toContain("- 自动续费: 否");
|
|
446
545
|
});
|
|
447
546
|
|
|
547
|
+
it("shows an actionable ChatGPT subscription failure reason when Chrome CDP is enabled", async () => {
|
|
548
|
+
const platform = mockPlatform("feishu");
|
|
549
|
+
mockGetChatGptSubscriptionStatus.mockResolvedValue({
|
|
550
|
+
ok: false,
|
|
551
|
+
code: "chatgpt_session_missing",
|
|
552
|
+
reason: "ChatGPT browser session has no access token.",
|
|
553
|
+
chromeCdp: { enabled: true, port: 15166, status: "healthy" },
|
|
554
|
+
chatgpt: { sessionOk: true },
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
558
|
+
|
|
559
|
+
const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
|
|
560
|
+
expect(card.elements[0].text.content).toContain("**ChatGPT 订阅查询失败:**");
|
|
561
|
+
expect(card.elements[0].text.content).toContain("请在 15166 端口对应的 Chrome 浏览器中登录 ChatGPT");
|
|
562
|
+
expect(card.elements[0].text.content).toContain("ChatGPT browser session has no access token.");
|
|
563
|
+
});
|
|
564
|
+
|
|
448
565
|
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
449
566
|
const platform = mockPlatform("feishu");
|
|
450
567
|
await recordSessionRegistry({
|
|
@@ -472,6 +589,37 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
472
589
|
expect.stringContaining("Pool remaining: $171417.76"),
|
|
473
590
|
"blue",
|
|
474
591
|
);
|
|
592
|
+
expect(platform.setChatAvatar).toHaveBeenCalledWith(
|
|
593
|
+
"cursor-chat",
|
|
594
|
+
"cursor",
|
|
595
|
+
"idle",
|
|
596
|
+
{ cursorUsage: expect.objectContaining({ displayMessage: "You've hit your usage limit" }) },
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("keeps the busy avatar status when /usage runs for an active Cursor session", async () => {
|
|
601
|
+
const platform = mockPlatform("feishu");
|
|
602
|
+
await recordSessionRegistry({
|
|
603
|
+
chatId: "cursor-chat",
|
|
604
|
+
sessionId: "sid-cursor",
|
|
605
|
+
tool: "cursor",
|
|
606
|
+
chatName: "cursor-session",
|
|
607
|
+
running: true,
|
|
608
|
+
});
|
|
609
|
+
activePrompts.set("sid-cursor", {
|
|
610
|
+
controller: new AbortController(),
|
|
611
|
+
stopped: false,
|
|
612
|
+
startTime: Date.now(),
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await handleCommand(platform, "/usage", "cursor-chat", "ou-user", Date.now(), "group");
|
|
616
|
+
|
|
617
|
+
expect(platform.setChatAvatar).toHaveBeenCalledWith(
|
|
618
|
+
"cursor-chat",
|
|
619
|
+
"cursor",
|
|
620
|
+
"busy",
|
|
621
|
+
{ cursorUsage: expect.objectContaining({ displayMessage: "You've hit your usage limit" }) },
|
|
622
|
+
);
|
|
475
623
|
});
|
|
476
624
|
|
|
477
625
|
it("advertises /usage in new Codex and Cursor session ready messages", async () => {
|
|
@@ -91,11 +91,12 @@ import {
|
|
|
91
91
|
stopSession,
|
|
92
92
|
startUnifiedDisplayLoop,
|
|
93
93
|
stopUnifiedDisplayLoop,
|
|
94
|
-
_setProcessAliveForTest,
|
|
95
|
-
_resetProcessAliveForTest,
|
|
96
|
-
_setProcessMonitorIntervalForTest,
|
|
97
|
-
_resetProcessMonitorIntervalForTest,
|
|
98
|
-
|
|
94
|
+
_setProcessAliveForTest,
|
|
95
|
+
_resetProcessAliveForTest,
|
|
96
|
+
_setProcessMonitorIntervalForTest,
|
|
97
|
+
_resetProcessMonitorIntervalForTest,
|
|
98
|
+
setSessionEffortOverride,
|
|
99
|
+
} from "../session.ts";
|
|
99
100
|
import {
|
|
100
101
|
activePrompts,
|
|
101
102
|
bindChatToSession,
|
|
@@ -895,8 +896,17 @@ describe("getSessionStatus", () => {
|
|
|
895
896
|
expect(status!.effort).not.toBeNull();
|
|
896
897
|
// model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
|
|
897
898
|
expect(typeof status!.model).toBe("string");
|
|
898
|
-
expect(status!.model.length).toBeGreaterThan(0);
|
|
899
|
-
});
|
|
899
|
+
expect(status!.model.length).toBeGreaterThan(0);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("Codex session status uses the per-session effort override", async () => {
|
|
903
|
+
mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
|
|
904
|
+
setSessionEffortOverride("sid-codex-effort", "xhigh");
|
|
905
|
+
|
|
906
|
+
const status = await getSessionStatus("chat-codex");
|
|
907
|
+
|
|
908
|
+
expect(status!.effort).toBe("xhigh");
|
|
909
|
+
});
|
|
900
910
|
|
|
901
911
|
it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
|
|
902
912
|
mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
|
|
@@ -184,12 +184,13 @@ export function normalizeCodexMessage(
|
|
|
184
184
|
// 子进程辅助函数
|
|
185
185
|
// ---------------------------------------------------------------------------
|
|
186
186
|
|
|
187
|
-
function spawnCodex(
|
|
188
|
-
args: string[],
|
|
189
|
-
cwd?: string,
|
|
190
|
-
stdinText?: string,
|
|
191
|
-
modelOverride?: string,
|
|
192
|
-
|
|
187
|
+
function spawnCodex(
|
|
188
|
+
args: string[],
|
|
189
|
+
cwd?: string,
|
|
190
|
+
stdinText?: string,
|
|
191
|
+
modelOverride?: string,
|
|
192
|
+
effortOverride?: string,
|
|
193
|
+
): ChildProcess {
|
|
193
194
|
const allArgs = [...args];
|
|
194
195
|
const model = modelOverride ?? resolveCodexModel();
|
|
195
196
|
if (model) {
|
|
@@ -197,7 +198,7 @@ function spawnCodex(
|
|
|
197
198
|
const execIdx = allArgs.indexOf("exec");
|
|
198
199
|
allArgs.splice(execIdx + 1, 0, "-m", model);
|
|
199
200
|
}
|
|
200
|
-
const effort = resolveCodexEffort();
|
|
201
|
+
const effort = effortOverride ?? resolveCodexEffort();
|
|
201
202
|
if (effort) {
|
|
202
203
|
allArgs.push("-c", `model_reasoning_effort="${effort}"`);
|
|
203
204
|
}
|
|
@@ -258,15 +259,17 @@ async function* readJsonLines(
|
|
|
258
259
|
// ---------------------------------------------------------------------------
|
|
259
260
|
|
|
260
261
|
class CodexAdapter implements ToolAdapter {
|
|
261
|
-
readonly displayName = "Codex";
|
|
262
|
-
readonly sessionDescPrefix = "Codex Session:";
|
|
263
|
-
private metaStore: CodexSessionMetaStore;
|
|
264
|
-
private modelOverride: string | undefined;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.
|
|
269
|
-
|
|
262
|
+
readonly displayName = "Codex";
|
|
263
|
+
readonly sessionDescPrefix = "Codex Session:";
|
|
264
|
+
private metaStore: CodexSessionMetaStore;
|
|
265
|
+
private modelOverride: string | undefined;
|
|
266
|
+
private effortOverride: string | undefined;
|
|
267
|
+
|
|
268
|
+
constructor(metaStore: CodexSessionMetaStore, modelOverride?: string, effortOverride?: string) {
|
|
269
|
+
this.metaStore = metaStore;
|
|
270
|
+
this.modelOverride = modelOverride;
|
|
271
|
+
this.effortOverride = effortOverride;
|
|
272
|
+
}
|
|
270
273
|
|
|
271
274
|
// createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
|
|
272
275
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
@@ -296,7 +299,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
296
299
|
? [...baseArgs, "-C", cwd, "-"]
|
|
297
300
|
: [...baseArgs, "resume", threadId, "-"];
|
|
298
301
|
|
|
299
|
-
const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride);
|
|
302
|
+
const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride);
|
|
300
303
|
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
301
304
|
|
|
302
305
|
// 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
|
|
@@ -350,7 +353,8 @@ class CodexAdapter implements ToolAdapter {
|
|
|
350
353
|
export interface CreateCodexAdapterOptions {
|
|
351
354
|
metaStore?: CodexSessionMetaStore;
|
|
352
355
|
/** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
|
|
353
|
-
model?: string;
|
|
356
|
+
model?: string;
|
|
357
|
+
effort?: string;
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
export function createCodexAdapter(
|
|
@@ -358,6 +362,7 @@ export function createCodexAdapter(
|
|
|
358
362
|
): ToolAdapter {
|
|
359
363
|
return new CodexAdapter(
|
|
360
364
|
options.metaStore ?? defaultCodexSessionMetaStore,
|
|
361
|
-
options.model,
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
options.model,
|
|
366
|
+
options.effort,
|
|
367
|
+
);
|
|
368
|
+
}
|
package/src/builtin/cli.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
* npx tsx src/builtin/cli.ts
|
|
6
6
|
* npx tsx src/builtin/cli.ts --model deepseek-chat
|
|
7
7
|
* npx tsx src/builtin/cli.ts --cwd /path/to/project
|
|
8
|
-
*
|
|
9
|
-
* 环境变量:
|
|
10
|
-
* DEEPSEEK_API_KEY — DeepSeek API Key(必需)
|
|
11
|
-
* DEEPSEEK_BASE_URL — API 地址(可选,默认 https://api.deepseek.com/v1)
|
|
12
8
|
*/
|
|
13
9
|
|
|
14
10
|
import * as readline from "node:readline";
|
|
15
11
|
import * as process from "node:process";
|
|
12
|
+
import { config as appConfig } from "../config.ts";
|
|
16
13
|
import { ChatSession, type ChatSessionConfig, type ChatSessionOptions } from "./index.js";
|
|
17
14
|
|
|
18
15
|
// ---------------------------------------------------------------------------
|
|
@@ -55,15 +52,14 @@ function printHelp(): void {
|
|
|
55
52
|
"用法: npx tsx src/builtin/cli.ts [选项]",
|
|
56
53
|
"",
|
|
57
54
|
"选项:",
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
" --api-key <key> API Key
|
|
55
|
+
` --model <name> 模型名称(覆盖 config.ccc.model,当前默认 ${appConfig.ccc.model})`,
|
|
56
|
+
` --base-url <url> API 地址(覆盖 config.ccc.DEEPSEEK_BASE_URL,当前默认 ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
|
|
57
|
+
" --api-key <key> API Key(覆盖 config.ccc.DEEPSEEK_API_KEY)",
|
|
61
58
|
" --cwd <path> 工作目录",
|
|
62
59
|
" --help, -h 显示帮助",
|
|
63
60
|
"",
|
|
64
|
-
"
|
|
65
|
-
" DEEPSEEK_API_KEY
|
|
66
|
-
" DEEPSEEK_BASE_URL API 地址",
|
|
61
|
+
"默认配置来源:",
|
|
62
|
+
" ~/.chatccc/config.json 的 ccc.DEEPSEEK_API_KEY / ccc.DEEPSEEK_BASE_URL / ccc.model",
|
|
67
63
|
"",
|
|
68
64
|
].join("\n"));
|
|
69
65
|
}
|
|
@@ -87,12 +83,8 @@ const C = {
|
|
|
87
83
|
async function main(): Promise<void> {
|
|
88
84
|
const { config, options } = parseArgs();
|
|
89
85
|
|
|
90
|
-
// 环境变量回退
|
|
91
|
-
if (!config.baseURL) config.baseURL = process.env.DEEPSEEK_BASE_URL;
|
|
92
|
-
if (!config.apiKey) config.apiKey = process.env.DEEPSEEK_API_KEY;
|
|
93
|
-
|
|
94
86
|
console.log(`${C.dim}ChatCCC 内置 Agent 原型${C.reset}`);
|
|
95
|
-
console.log(`${C.dim}模型: ${config.model ??
|
|
87
|
+
console.log(`${C.dim}模型: ${config.model ?? appConfig.ccc.model}${C.reset}`);
|
|
96
88
|
if (options.cwd) {
|
|
97
89
|
console.log(`${C.dim}目录: ${options.cwd}${C.reset}`);
|
|
98
90
|
}
|
|
@@ -194,4 +186,4 @@ async function main(): Promise<void> {
|
|
|
194
186
|
main().catch((err) => {
|
|
195
187
|
console.error(`${C.yellow}启动失败: ${(err as Error).message}${C.reset}`);
|
|
196
188
|
process.exit(1);
|
|
197
|
-
});
|
|
189
|
+
});
|
package/src/builtin/index.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
7
8
|
import { streamText } from "ai";
|
|
8
9
|
|
|
10
|
+
import { config as appConfig } from "../config.ts";
|
|
11
|
+
|
|
9
12
|
// ---------------------------------------------------------------------------
|
|
10
13
|
// 系统提示词 — 编译期冻结常量
|
|
11
14
|
// ---------------------------------------------------------------------------
|
|
@@ -25,11 +28,11 @@ const SYSTEM_PROMPT = [
|
|
|
25
28
|
// ---------------------------------------------------------------------------
|
|
26
29
|
|
|
27
30
|
export interface ChatSessionConfig {
|
|
28
|
-
/** DeepSeek API
|
|
31
|
+
/** DeepSeek API 兼容的服务地址;传入时覆盖 config.ccc.DEEPSEEK_BASE_URL */
|
|
29
32
|
baseURL?: string;
|
|
30
|
-
/** API Key
|
|
33
|
+
/** API Key;传入时覆盖 config.ccc.DEEPSEEK_API_KEY */
|
|
31
34
|
apiKey?: string;
|
|
32
|
-
/**
|
|
35
|
+
/** 模型名称;传入时覆盖 config.ccc.model */
|
|
33
36
|
model?: string;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -66,21 +69,19 @@ export class ChatSession {
|
|
|
66
69
|
private messages: ChatMessage[];
|
|
67
70
|
|
|
68
71
|
constructor(
|
|
69
|
-
|
|
72
|
+
overrides: ChatSessionConfig = {},
|
|
70
73
|
options: ChatSessionOptions = {},
|
|
71
74
|
) {
|
|
72
|
-
const apiKey =
|
|
75
|
+
const apiKey = overrides.apiKey ?? appConfig.ccc.DEEPSEEK_API_KEY;
|
|
73
76
|
if (!apiKey) {
|
|
74
77
|
throw new Error(
|
|
75
|
-
"DEEPSEEK_API_KEY
|
|
78
|
+
"ccc.DEEPSEEK_API_KEY 未设置。请在 config.json 中配置,或通过 --api-key 临时传入",
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
const baseURL =
|
|
80
|
-
const modelId =
|
|
82
|
+
const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
|
|
83
|
+
const modelId = overrides.model ?? appConfig.ccc.model;
|
|
81
84
|
|
|
82
|
-
// 动态 import 以支持 ESM 包在 CJS 环境下的加载(tsx 处理)
|
|
83
|
-
const { createOpenAICompatible } = require("@ai-sdk/openai-compatible");
|
|
84
85
|
const provider = createOpenAICompatible({
|
|
85
86
|
name: "deepseek",
|
|
86
87
|
baseURL,
|
|
@@ -164,4 +165,4 @@ export class ChatSession {
|
|
|
164
165
|
const system = this.messages[0];
|
|
165
166
|
this.messages = [system];
|
|
166
167
|
}
|
|
167
|
-
}
|
|
168
|
+
}
|
package/src/cards.ts
CHANGED
|
@@ -498,10 +498,10 @@ export function buildCodexResetConfirmCard(params: {
|
|
|
498
498
|
|
|
499
499
|
/** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
|
|
500
500
|
export function buildModelCard(
|
|
501
|
-
currentModel: string,
|
|
502
|
-
models: string[],
|
|
503
|
-
tool?: string,
|
|
504
|
-
): string {
|
|
501
|
+
currentModel: string,
|
|
502
|
+
models: string[],
|
|
503
|
+
tool?: string,
|
|
504
|
+
): string {
|
|
505
505
|
const toolLabel = tool ? ` (${tool})` : "";
|
|
506
506
|
const currentLine = currentModel
|
|
507
507
|
? `**当前模型:** \`${currentModel}\``
|
|
@@ -535,7 +535,48 @@ export function buildModelCard(
|
|
|
535
535
|
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
536
536
|
{ tag: "hr" },
|
|
537
537
|
buildButtons(buttons),
|
|
538
|
-
],
|
|
539
|
-
});
|
|
540
|
-
}
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function buildEffortCard(
|
|
543
|
+
currentEffort: string,
|
|
544
|
+
efforts: string[],
|
|
545
|
+
tool?: string,
|
|
546
|
+
): string {
|
|
547
|
+
const toolLabel = tool ? ` (${tool})` : "";
|
|
548
|
+
const currentLine = currentEffort
|
|
549
|
+
? `**当前 effort:** \`${currentEffort}\``
|
|
550
|
+
: "**当前 effort:** 未指定";
|
|
551
|
+
|
|
552
|
+
const lines: string[] = [currentLine];
|
|
553
|
+
if (efforts.length > 0) {
|
|
554
|
+
lines.push("", "**可切换 effort**");
|
|
555
|
+
for (const effort of efforts) {
|
|
556
|
+
lines.push(`- \`${effort}\``);
|
|
557
|
+
}
|
|
558
|
+
lines.push("", "点击按钮切换 effort,或输入 `/effort clear` 恢复默认");
|
|
559
|
+
} else {
|
|
560
|
+
lines.push("", "当前 agent 不支持 effort 切换。");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const buttons: ButtonDef[] = [];
|
|
564
|
+
for (const effort of efforts.slice(0, 20)) {
|
|
565
|
+
buttons.push({
|
|
566
|
+
text: `/effort ${effort}`,
|
|
567
|
+
value: JSON.stringify({ cmd: `/effort ${effort}` }),
|
|
568
|
+
type: "primary",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return JSON.stringify({
|
|
573
|
+
config: { wide_screen_mode: true },
|
|
574
|
+
header: { template: "blue", title: { content: `Effort 切换${toolLabel}`, tag: "plain_text" } },
|
|
575
|
+
elements: [
|
|
576
|
+
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
577
|
+
{ tag: "hr" },
|
|
578
|
+
buildButtons(buttons),
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
}
|
|
541
582
|
|
|
@@ -33,6 +33,18 @@ export interface ChromeCdpEnsureResult {
|
|
|
33
33
|
|
|
34
34
|
export type ChromeCdpProbeStatus = "healthy" | "occupied" | "unreachable";
|
|
35
35
|
|
|
36
|
+
interface ChromeCdpPage {
|
|
37
|
+
id?: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EnsureChatcccPageResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
opened: boolean;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
let guardTimer: ReturnType<typeof setInterval> | null = null;
|
|
37
49
|
let ensureInFlight: Promise<ChromeCdpEnsureResult> | null = null;
|
|
38
50
|
|
|
@@ -81,11 +93,11 @@ export function resolveChromeUserDataDir(port: number, env: NodeJS.ProcessEnv =
|
|
|
81
93
|
return join(root, `chrome-cdp-${port}`);
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise<Response> {
|
|
96
|
+
async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number, init: RequestInit = {}): Promise<Response> {
|
|
85
97
|
const controller = new AbortController();
|
|
86
98
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
87
99
|
try {
|
|
88
|
-
return await fetchImpl(url, { signal: controller.signal });
|
|
100
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
89
101
|
} finally {
|
|
90
102
|
clearTimeout(timer);
|
|
91
103
|
}
|
|
@@ -163,6 +175,61 @@ function sleep(ms: number): Promise<void> {
|
|
|
163
175
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
function cdpBaseUrl(port: number): string {
|
|
179
|
+
return `http://${CDP_HOST}:${port}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isChatcccPageUrl(value: string | undefined, chatcccPort: number): boolean {
|
|
183
|
+
if (!value) return false;
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(value);
|
|
186
|
+
return url.protocol === "http:" &&
|
|
187
|
+
(url.hostname === "localhost" || url.hostname === CDP_HOST) &&
|
|
188
|
+
url.port === String(chatcccPort);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function ensureChatcccPageOpen(
|
|
195
|
+
cdpPort: number,
|
|
196
|
+
chatcccPort: number,
|
|
197
|
+
deps: Pick<ChromeDevtoolsGuardDeps, "fetchImpl"> = {},
|
|
198
|
+
): Promise<EnsureChatcccPageResult> {
|
|
199
|
+
const normalizedCdpPort = normalizePort(cdpPort);
|
|
200
|
+
const normalizedChatcccPort = normalizePort(chatcccPort);
|
|
201
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const listResponse = await fetchWithTimeout(
|
|
205
|
+
`${cdpBaseUrl(normalizedCdpPort)}/json/list`,
|
|
206
|
+
fetchImpl,
|
|
207
|
+
HEALTH_TIMEOUT_MS,
|
|
208
|
+
);
|
|
209
|
+
if (!listResponse.ok) {
|
|
210
|
+
return { ok: false, opened: false, error: `Cannot list Chrome CDP pages: HTTP ${listResponse.status}` };
|
|
211
|
+
}
|
|
212
|
+
const pages = await listResponse.json() as unknown;
|
|
213
|
+
if (Array.isArray(pages) && pages.some((page: ChromeCdpPage) => isChatcccPageUrl(page?.url, normalizedChatcccPort))) {
|
|
214
|
+
return { ok: true, opened: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const targetUrl = `http://localhost:${normalizedChatcccPort}/`;
|
|
218
|
+
const openResponse = await fetchWithTimeout(
|
|
219
|
+
`${cdpBaseUrl(normalizedCdpPort)}/json/new?${encodeURIComponent(targetUrl)}`,
|
|
220
|
+
fetchImpl,
|
|
221
|
+
HEALTH_TIMEOUT_MS,
|
|
222
|
+
{ method: "PUT" },
|
|
223
|
+
);
|
|
224
|
+
if (!openResponse.ok) {
|
|
225
|
+
return { ok: false, opened: false, error: `Cannot open ${targetUrl}: HTTP ${openResponse.status}` };
|
|
226
|
+
}
|
|
227
|
+
return { ok: true, opened: true };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { ok: false, opened: false, error: (err as Error).message };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
export async function ensureChromeCdpRunning(
|
|
167
234
|
cfg: ChromeDevtoolsConfig = config.chromeDevtools,
|
|
168
235
|
deps: ChromeDevtoolsGuardDeps = {},
|
|
@@ -202,6 +269,9 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
202
269
|
ensureInFlight = ensureChromeCdpRunning(config.chromeDevtools, deps);
|
|
203
270
|
try {
|
|
204
271
|
const result = await ensureInFlight;
|
|
272
|
+
const chatcccPage = config.chromeDevtools.enabled && result.ok
|
|
273
|
+
? await ensureChatcccPageOpen(result.port, config.port, deps)
|
|
274
|
+
: null;
|
|
205
275
|
appendStartupTrace("chrome-devtools-guard: ensure result", {
|
|
206
276
|
reason,
|
|
207
277
|
enabled: config.chromeDevtools.enabled,
|
|
@@ -209,6 +279,7 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
209
279
|
ok: result.ok,
|
|
210
280
|
started: result.started,
|
|
211
281
|
error: result.error,
|
|
282
|
+
chatcccPage,
|
|
212
283
|
});
|
|
213
284
|
if (!config.chromeDevtools.enabled) return;
|
|
214
285
|
if (result.ok && result.started) {
|
|
@@ -216,6 +287,11 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
216
287
|
} else if (!result.ok) {
|
|
217
288
|
log(`[${ts()}] [Chrome CDP] Guard failed: ${result.error}`);
|
|
218
289
|
}
|
|
290
|
+
if (chatcccPage?.opened) {
|
|
291
|
+
log(`[${ts()}] [Chrome CDP] Opened ChatCCC page http://localhost:${config.port}/`);
|
|
292
|
+
} else if (chatcccPage && !chatcccPage.ok) {
|
|
293
|
+
log(`[${ts()}] [Chrome CDP] Failed to ensure ChatCCC page: ${chatcccPage.error}`);
|
|
294
|
+
}
|
|
219
295
|
} finally {
|
|
220
296
|
ensureInFlight = null;
|
|
221
297
|
}
|