chatccc 0.2.57 → 0.2.58
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/README.md +7 -7
- package/package.json +1 -1
- package/src/__tests__/orchestrator.test.ts +168 -0
- package/src/orchestrator.ts +31 -19
- package/src/wechat-platform.ts +3 -15
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ ChatCCC 把本地 AI 编程工具接入即时通讯软件。你可以在手机
|
|
|
32
32
|
| 会话形态 | 群聊,一群一会话 | 私聊,一对一 |
|
|
33
33
|
| 消息展示 | CardKit 卡片,流式更新 | 纯文本,增量推送 |
|
|
34
34
|
| `/new` | 自动创建新群并绑定新会话 | 在当前私聊里创建新会话 |
|
|
35
|
-
| 多会话并行 | 直接切换不同群 |
|
|
35
|
+
| 多会话并行 | 直接切换不同群 | 支持并行,使用切换指令后未完成的任务会继续在后台进行,但不如飞书直观方便 |
|
|
36
36
|
| 群管理 | 支持创建、重命名、解散、头像 | 不支持 |
|
|
37
37
|
| 接入成本 | 需要配置飞书应用 | 启动后扫码登录 |
|
|
38
38
|
|
|
@@ -158,9 +158,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
158
158
|
"claude": {
|
|
159
159
|
"enabled": false,
|
|
160
160
|
"defaultAgent": true,
|
|
161
|
-
"model": "claude-sonnet-4-6",
|
|
162
|
-
"subagentModel": "",
|
|
163
|
-
"effort": "",
|
|
161
|
+
"model": "claude-sonnet-4-6",
|
|
162
|
+
"subagentModel": "",
|
|
163
|
+
"effort": "",
|
|
164
164
|
"apiKey": "",
|
|
165
165
|
"baseUrl": ""
|
|
166
166
|
},
|
|
@@ -189,9 +189,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
189
189
|
| `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
|
|
190
190
|
| `*.enabled` | 是否启用对应 AI Agent |
|
|
191
191
|
| `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
|
|
192
|
-
| `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
|
|
193
|
-
| `claude.model` / `claude.subagentModel` | Claude Code 主模型 / subagent 模型;`subagentModel` 仅在第三方 API 模式下注入 `CLAUDE_CODE_SUBAGENT_MODEL` |
|
|
194
|
-
| `claude.apiKey` / `claude.baseUrl` | 第三方 Anthropic 兼容网关配置;官方 Claude 用户留空 |
|
|
192
|
+
| `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
|
|
193
|
+
| `claude.model` / `claude.subagentModel` | Claude Code 主模型 / subagent 模型;`subagentModel` 仅在第三方 API 模式下注入 `CLAUDE_CODE_SUBAGENT_MODEL` |
|
|
194
|
+
| `claude.apiKey` / `claude.baseUrl` | 第三方 Anthropic 兼容网关配置;官方 Claude 用户留空 |
|
|
195
195
|
|
|
196
196
|
> 当前 ChatCCC 以 `bypassPermissions` 模式运行,会跳过 Agent 操作确认。请只在可信环境中使用。
|
|
197
197
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
|
+
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
|
+
|
|
9
|
+
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
+
|
|
11
|
+
vi.mock("../im-skills.ts", () => ({
|
|
12
|
+
buildImSkillsPrompt: async () => "",
|
|
13
|
+
exportSkillSubDocs: async () => {},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("../stream-state.ts", () => ({
|
|
17
|
+
readStreamState: async (sessionId: string) => {
|
|
18
|
+
const state = mockStreamStates.get(sessionId);
|
|
19
|
+
if (!state) return null;
|
|
20
|
+
return {
|
|
21
|
+
sessionId,
|
|
22
|
+
status: state.status,
|
|
23
|
+
accumulatedContent: "",
|
|
24
|
+
finalReply: state.finalReply,
|
|
25
|
+
chunkCount: 0,
|
|
26
|
+
turnCount: 1,
|
|
27
|
+
contextTokens: 0,
|
|
28
|
+
updatedAt: Date.now(),
|
|
29
|
+
cwd: "F:\\repo",
|
|
30
|
+
tool: "claude",
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
writeStreamState: async (state: { sessionId: string; status: "running" | "done" | "stopped"; finalReply: string }) => {
|
|
34
|
+
mockStreamStates.set(state.sessionId, {
|
|
35
|
+
status: state.status,
|
|
36
|
+
finalReply: state.finalReply,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
createEmptyStreamState: (sessionId: string, cwd: string, tool: string, turnCount: number) => ({
|
|
40
|
+
sessionId,
|
|
41
|
+
status: "running" as const,
|
|
42
|
+
accumulatedContent: "",
|
|
43
|
+
finalReply: "",
|
|
44
|
+
chunkCount: 0,
|
|
45
|
+
turnCount,
|
|
46
|
+
contextTokens: 0,
|
|
47
|
+
updatedAt: Date.now(),
|
|
48
|
+
cwd,
|
|
49
|
+
tool,
|
|
50
|
+
}),
|
|
51
|
+
fixStaleStreamStates: async () => {},
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import { handleCommand } from "../orchestrator.ts";
|
|
55
|
+
import {
|
|
56
|
+
_clearAdapterCacheForTest,
|
|
57
|
+
_resetSessionRegistryFileForTest,
|
|
58
|
+
_resetSessionToolsFileForTest,
|
|
59
|
+
_setAdapterForToolForTest,
|
|
60
|
+
_setSessionRegistryFileForTest,
|
|
61
|
+
_setSessionToolsFileForTest,
|
|
62
|
+
recordSessionRegistry,
|
|
63
|
+
resetState,
|
|
64
|
+
} from "../session.ts";
|
|
65
|
+
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
66
|
+
|
|
67
|
+
function mockPlatform(): PlatformAdapter {
|
|
68
|
+
return {
|
|
69
|
+
kind: "wechat",
|
|
70
|
+
sendText: vi.fn(async () => true),
|
|
71
|
+
sendCard: vi.fn(async () => true),
|
|
72
|
+
sendRawCard: vi.fn(async () => true),
|
|
73
|
+
createGroup: vi.fn(async () => "unused-group"),
|
|
74
|
+
updateChatInfo: vi.fn(async () => {}),
|
|
75
|
+
getChatInfo: vi.fn(async () => ({ name: "微信会话", description: "" })),
|
|
76
|
+
disbandChat: vi.fn(async () => {}),
|
|
77
|
+
setChatAvatar: vi.fn(async () => {}),
|
|
78
|
+
extractSessionInfo: vi.fn(() => null),
|
|
79
|
+
cardCreate: vi.fn(async () => "card-id"),
|
|
80
|
+
cardSend: vi.fn(async () => "message-id"),
|
|
81
|
+
cardUpdate: vi.fn(async () => {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function mockAdapter(): ToolAdapter {
|
|
86
|
+
return {
|
|
87
|
+
displayName: "Claude",
|
|
88
|
+
sessionDescPrefix: "Claude Session:",
|
|
89
|
+
createSession: async () => ({ sessionId: "sid-wechat" }),
|
|
90
|
+
prompt: async function* () {
|
|
91
|
+
yield {
|
|
92
|
+
type: "assistant",
|
|
93
|
+
blocks: [{ type: "text", text: "done" }],
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
97
|
+
sessionId,
|
|
98
|
+
cwd: "F:\\repo",
|
|
99
|
+
}),
|
|
100
|
+
closeSession: async () => {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("handleCommand WeChat processing ack", () => {
|
|
105
|
+
let tempDir: string;
|
|
106
|
+
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
vi.useFakeTimers();
|
|
109
|
+
tempDir = await mkdtemp(join(tmpdir(), "chatccc-orchestrator-"));
|
|
110
|
+
_setSessionRegistryFileForTest(join(tempDir, "session-registry.json"));
|
|
111
|
+
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
112
|
+
resetState();
|
|
113
|
+
resetBindingState();
|
|
114
|
+
mockStreamStates.clear();
|
|
115
|
+
_setAdapterForToolForTest("claude", mockAdapter());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
resetState();
|
|
120
|
+
resetBindingState();
|
|
121
|
+
_clearAdapterCacheForTest();
|
|
122
|
+
_resetSessionRegistryFileForTest();
|
|
123
|
+
_resetSessionToolsFileForTest();
|
|
124
|
+
vi.useRealTimers();
|
|
125
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("does not send the WeChat processing ack when the session is already running", async () => {
|
|
129
|
+
const platform = mockPlatform();
|
|
130
|
+
await recordSessionRegistry({
|
|
131
|
+
chatId: "wx-chat",
|
|
132
|
+
sessionId: "sid-wechat",
|
|
133
|
+
tool: "claude",
|
|
134
|
+
chatName: "busy-session",
|
|
135
|
+
running: true,
|
|
136
|
+
});
|
|
137
|
+
activePrompts.set("sid-wechat", {
|
|
138
|
+
controller: new AbortController(),
|
|
139
|
+
stopped: false,
|
|
140
|
+
startTime: Date.now(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
144
|
+
|
|
145
|
+
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
146
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
147
|
+
"wx-chat",
|
|
148
|
+
"生成中",
|
|
149
|
+
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
150
|
+
"yellow",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
155
|
+
const platform = mockPlatform();
|
|
156
|
+
await recordSessionRegistry({
|
|
157
|
+
chatId: "wx-chat",
|
|
158
|
+
sessionId: "sid-wechat",
|
|
159
|
+
tool: "claude",
|
|
160
|
+
chatName: "ready-session",
|
|
161
|
+
running: false,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
165
|
+
|
|
166
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -72,13 +72,21 @@ export function cwdDisplayName(cwd: string): string {
|
|
|
72
72
|
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
-
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
-
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
-
}
|
|
75
|
+
export function sessionChatName(left: string, cwd: string): string {
|
|
76
|
+
return `${left}-${cwdDisplayName(cwd)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isUntitledSessionChatName(name: string): boolean {
|
|
80
|
+
return name === "新会话" || name.startsWith("新会话-");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function shouldSendWechatProcessingAck(
|
|
84
|
+
platform: PlatformAdapter,
|
|
85
|
+
textLower: string,
|
|
86
|
+
chatType: string,
|
|
87
|
+
): boolean {
|
|
88
|
+
return platform.kind === "wechat" && chatType === "p2p" && !textLower.startsWith("/");
|
|
89
|
+
}
|
|
82
90
|
|
|
83
91
|
// ---------------------------------------------------------------------------
|
|
84
92
|
// handleCommand — 平台无关的命令分发
|
|
@@ -971,11 +979,11 @@ export async function handleCommand(
|
|
|
971
979
|
}
|
|
972
980
|
|
|
973
981
|
// 并发检查:同一 session 只能有一个活跃 prompt
|
|
974
|
-
if (isSessionRunning(sessionId)) {
|
|
975
|
-
logTrace(tid, "BLOCKED", {
|
|
976
|
-
outcome: "session_busy",
|
|
977
|
-
sessionId,
|
|
978
|
-
});
|
|
982
|
+
if (isSessionRunning(sessionId)) {
|
|
983
|
+
logTrace(tid, "BLOCKED", {
|
|
984
|
+
outcome: "session_busy",
|
|
985
|
+
sessionId,
|
|
986
|
+
});
|
|
979
987
|
console.log(
|
|
980
988
|
`[${ts()}] [BLOCKED] Session ${sessionId} is already generating, rejecting message from chat ${chatId}`,
|
|
981
989
|
);
|
|
@@ -984,13 +992,17 @@ export async function handleCommand(
|
|
|
984
992
|
"生成中",
|
|
985
993
|
"该会话正在生成回复中,请等待完成后再发送新消息。",
|
|
986
994
|
"yellow",
|
|
987
|
-
);
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
995
|
+
);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (shouldSendWechatProcessingAck(platform, textLower, chatType)) {
|
|
1000
|
+
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1005
|
+
await resumeAndPrompt(
|
|
994
1006
|
sessionId,
|
|
995
1007
|
text,
|
|
996
1008
|
platform,
|
package/src/wechat-platform.ts
CHANGED
|
@@ -500,21 +500,9 @@ async function handleWechatMessage(
|
|
|
500
500
|
// 用户回复,重置 claw 连发计数
|
|
501
501
|
consecutiveSendCount.set(chatId, 0);
|
|
502
502
|
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if (ctxToken) {
|
|
507
|
-
ilinkWire.sendText(chatId, "生成中...", ctxToken).catch(() => {});
|
|
508
|
-
} else {
|
|
509
|
-
ilinkWire.push(chatId, "生成中...").catch(() => {});
|
|
510
|
-
}
|
|
511
|
-
// "生成中..." 计为第1条连发消息
|
|
512
|
-
consecutiveSendCount.set(chatId, 1);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
516
|
-
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
517
|
-
handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|
|
503
|
+
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
504
|
+
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
505
|
+
handler(text, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|
|
518
506
|
platformLog(`消息处理失败: ${(err as Error).stack ?? (err as Error).message}`);
|
|
519
507
|
});
|
|
520
508
|
}
|