chatccc 0.2.179 → 0.2.180
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 +69 -51
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +5 -4
- package/src/__tests__/cards.test.ts +22 -13
- package/src/__tests__/orchestrator.test.ts +81 -13
- package/src/__tests__/shared-prefix.test.ts +36 -0
- package/src/cards.ts +12 -9
- package/src/orchestrator.ts +52 -48
- package/src/shared-prefix.ts +29 -0
package/README.md
CHANGED
|
@@ -144,7 +144,12 @@ npm install -g chatccc
|
|
|
144
144
|
chatccc
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc`
|
|
147
|
+
要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc` 即可启动。配置、日志和状态文件会保存在用户目录的 `.chatccc` 下:
|
|
148
|
+
|
|
149
|
+
- Windows:`C:\Users\<用户名>\.chatccc\`
|
|
150
|
+
- macOS / Linux:`~/.chatccc/`
|
|
151
|
+
|
|
152
|
+
旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
|
|
148
153
|
|
|
149
154
|
首次启动时,如果还没有有效配置,ChatCCC 会自动打开本地 Web 配置向导(默认 `http://127.0.0.1:18080`)。
|
|
150
155
|
|
|
@@ -197,7 +202,7 @@ chatccc
|
|
|
197
202
|
# 在微信里找到机器人,发送 /new 开始对话
|
|
198
203
|
```
|
|
199
204
|
|
|
200
|
-
微信登录信息会保存到
|
|
205
|
+
微信登录信息会保存到 `~/.chatccc/state/ilink-auth.json`。token 过期后重新扫码即可。
|
|
201
206
|
|
|
202
207
|
### 3. AI 工具配置
|
|
203
208
|
|
|
@@ -234,7 +239,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
234
239
|
|
|
235
240
|
### 4. `config.json`
|
|
236
241
|
|
|
237
|
-
|
|
242
|
+
`~/.chatccc/config.json` 不存在时,ChatCCC 会从 `config.sample.json` 复制一份。常用结构如下:
|
|
238
243
|
|
|
239
244
|
```json
|
|
240
245
|
{
|
|
@@ -242,28 +247,32 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
242
247
|
"appId": "cli_xxxxxxxxxxxx",
|
|
243
248
|
"appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
244
249
|
},
|
|
245
|
-
"platforms": {
|
|
246
|
-
"feishu": { "enabled": true },
|
|
247
|
-
"ilink": { "enabled": true }
|
|
248
|
-
},
|
|
249
|
-
"port": 18080,
|
|
250
|
-
"gitTimeoutSeconds": 180,
|
|
251
|
-
"
|
|
252
|
-
|
|
253
|
-
"
|
|
254
|
-
"
|
|
255
|
-
"
|
|
256
|
-
"
|
|
257
|
-
"
|
|
258
|
-
"
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"
|
|
264
|
-
"
|
|
265
|
-
|
|
266
|
-
|
|
250
|
+
"platforms": {
|
|
251
|
+
"feishu": { "enabled": true, "platformType": "feishu" },
|
|
252
|
+
"ilink": { "enabled": true, "reuseTokenOnStart": true }
|
|
253
|
+
},
|
|
254
|
+
"port": 18080,
|
|
255
|
+
"gitTimeoutSeconds": 180,
|
|
256
|
+
"allowInterrupt": false,
|
|
257
|
+
"claude": {
|
|
258
|
+
"enabled": false,
|
|
259
|
+
"defaultAgent": true,
|
|
260
|
+
"model": "claude-sonnet-4-6",
|
|
261
|
+
"subagentModel": "",
|
|
262
|
+
"effort": "",
|
|
263
|
+
"apiKey": "",
|
|
264
|
+
"baseUrl": "",
|
|
265
|
+
"maxTurn": 0
|
|
266
|
+
},
|
|
267
|
+
"cursor": {
|
|
268
|
+
"enabled": false,
|
|
269
|
+
"defaultAgent": false,
|
|
270
|
+
"path": "",
|
|
271
|
+
"model": "",
|
|
272
|
+
"avatarBatteryMode": "apiPercent",
|
|
273
|
+
"onDemandMonthlyBudget": 1000
|
|
274
|
+
},
|
|
275
|
+
"codex": {
|
|
267
276
|
"enabled": false,
|
|
268
277
|
"defaultAgent": false,
|
|
269
278
|
"path": "",
|
|
@@ -276,16 +285,21 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
276
285
|
| 字段 | 说明 |
|
|
277
286
|
| --- | --- |
|
|
278
287
|
| `feishu.appId` / `feishu.appSecret` | 飞书应用凭证 |
|
|
279
|
-
| `platforms.feishu.enabled` | 是否启用飞书 |
|
|
280
|
-
| `platforms.
|
|
281
|
-
| `
|
|
282
|
-
| `
|
|
283
|
-
|
|
|
284
|
-
|
|
|
285
|
-
| `
|
|
286
|
-
| `
|
|
287
|
-
| `
|
|
288
|
-
| `
|
|
288
|
+
| `platforms.feishu.enabled` | 是否启用飞书 |
|
|
289
|
+
| `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
|
|
290
|
+
| `platforms.ilink.enabled` | 是否启用微信 iLink |
|
|
291
|
+
| `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
|
|
292
|
+
| `port` | 本地 Web 配置面板和中继服务端口 |
|
|
293
|
+
| `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
|
|
294
|
+
| `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
|
|
295
|
+
| `*.enabled` | 是否启用对应 AI Agent |
|
|
296
|
+
| `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
|
|
297
|
+
| `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
|
|
298
|
+
| `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
|
|
299
|
+
| `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
|
|
300
|
+
| `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
301
|
+
| `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
|
|
302
|
+
| `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
|
|
289
303
|
|
|
290
304
|
> **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
|
|
291
305
|
|
|
@@ -301,22 +315,26 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
301
315
|
| --- | --- |
|
|
302
316
|
| `/new` | 使用默认 Agent 创建新会话 |
|
|
303
317
|
| `/new claude` | 创建 Claude Code 会话 |
|
|
304
|
-
| `/new cursor` | 创建 Cursor 会话 |
|
|
305
|
-
| `/new codex` | 创建 Codex 会话 |
|
|
306
|
-
| `/newh` | 重置当前会话,保留工作目录 |
|
|
307
|
-
| `/model` | 查看或切换当前会话的模型 |
|
|
308
|
-
| `/stop` | 停止当前回复 |
|
|
309
|
-
| `/
|
|
310
|
-
| `/
|
|
311
|
-
| `/
|
|
312
|
-
| `/
|
|
313
|
-
| `/
|
|
314
|
-
| `/
|
|
315
|
-
| `/
|
|
316
|
-
| `/
|
|
317
|
-
| `/
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
| `/new cursor` | 创建 Cursor 会话 |
|
|
319
|
+
| `/new codex` | 创建 Codex 会话 |
|
|
320
|
+
| `/newh` | 重置当前会话,保留工作目录 |
|
|
321
|
+
| `/model` | 查看或切换当前会话的模型 |
|
|
322
|
+
| `/stop` | 停止当前回复 |
|
|
323
|
+
| `/cancel` | 取消当前会话里排队等待处理的消息 |
|
|
324
|
+
| `/state` | 查看当前会话状态 |
|
|
325
|
+
| `/cd` | 查看或设置当前会话工作目录 |
|
|
326
|
+
| `/sessions` | 查看所有会话状态 |
|
|
327
|
+
| `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话 |
|
|
328
|
+
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/周用量,Cursor 显示当前周期用量 |
|
|
329
|
+
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
330
|
+
| `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
|
|
331
|
+
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
332
|
+
| `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
|
|
333
|
+
| `/restart` | 重启机器人进程 |
|
|
334
|
+
| `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
|
|
335
|
+
| `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
|
|
336
|
+
|
|
337
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
|
|
320
338
|
|
|
321
339
|
---
|
|
322
340
|
|
package/package.json
CHANGED
|
@@ -16,10 +16,11 @@ describe("cardJsonToPlainText", () => {
|
|
|
16
16
|
expect(text).toContain("/new");
|
|
17
17
|
expect(text).toContain("/new cursor");
|
|
18
18
|
expect(text).toContain("/new codex");
|
|
19
|
-
expect(text).toContain("/restart");
|
|
20
|
-
expect(text).toContain("/update");
|
|
21
|
-
expect(text).toContain("/cd");
|
|
22
|
-
|
|
19
|
+
expect(text).toContain("/restart");
|
|
20
|
+
expect(text).toContain("/update");
|
|
21
|
+
expect(text).toContain("/cd");
|
|
22
|
+
expect(text).toContain("/abd");
|
|
23
|
+
});
|
|
23
24
|
|
|
24
25
|
it("converts status cards from v1 card format", () => {
|
|
25
26
|
const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
buildProgressCard,
|
|
4
|
-
buildHelpCard,
|
|
2
|
+
import {
|
|
3
|
+
buildProgressCard,
|
|
4
|
+
buildHelpCard,
|
|
5
5
|
buildCdContent,
|
|
6
6
|
buildCdCard,
|
|
7
7
|
buildSessionsCard,
|
|
8
8
|
buildStatusCard,
|
|
9
9
|
buildButtons,
|
|
10
10
|
truncateContent,
|
|
11
|
-
getToolEmoji,
|
|
12
|
-
} from "../cards.ts";
|
|
11
|
+
getToolEmoji,
|
|
12
|
+
} from "../cards.ts";
|
|
13
|
+
import { ABD_HELP_LINE } from "../shared-prefix.ts";
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// truncateContent
|
|
@@ -151,14 +152,22 @@ describe("buildHelpCard", () => {
|
|
|
151
152
|
expect(parsed.elements[0].text.content).toContain("你好");
|
|
152
153
|
});
|
|
153
154
|
|
|
154
|
-
it("includes action buttons", () => {
|
|
155
|
-
const card = buildHelpCard("test");
|
|
156
|
-
const parsed = JSON.parse(card);
|
|
157
|
-
const action = parsed.elements[2];
|
|
158
|
-
expect(action.tag).toBe("action");
|
|
159
|
-
expect(action.actions).toHaveLength(7);
|
|
160
|
-
});
|
|
161
|
-
|
|
155
|
+
it("includes action buttons", () => {
|
|
156
|
+
const card = buildHelpCard("test");
|
|
157
|
+
const parsed = JSON.parse(card);
|
|
158
|
+
const action = parsed.elements[2];
|
|
159
|
+
expect(action.tag).toBe("action");
|
|
160
|
+
expect(action.actions).toHaveLength(7);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("adds ABD prefix help as the final help line", () => {
|
|
164
|
+
const card = buildHelpCard("test");
|
|
165
|
+
const parsed = JSON.parse(card);
|
|
166
|
+
const lines = parsed.elements[1].text.content.split("\n");
|
|
167
|
+
|
|
168
|
+
expect(lines.at(-1)).toBe(ABD_HELP_LINE);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
162
171
|
|
|
163
172
|
// ---------------------------------------------------------------------------
|
|
164
173
|
// buildCdContent
|
|
@@ -65,8 +65,8 @@ vi.mock("../cursor-usage.ts", () => ({
|
|
|
65
65
|
}));
|
|
66
66
|
|
|
67
67
|
import { handleCommand } from "../orchestrator.ts";
|
|
68
|
-
import {
|
|
69
|
-
_clearAdapterCacheForTest,
|
|
68
|
+
import {
|
|
69
|
+
_clearAdapterCacheForTest,
|
|
70
70
|
_resetSessionRegistryFileForTest,
|
|
71
71
|
_resetSessionToolsFileForTest,
|
|
72
72
|
_setAdapterForToolForTest,
|
|
@@ -74,9 +74,10 @@ import {
|
|
|
74
74
|
_setSessionToolsFileForTest,
|
|
75
75
|
loadSessionRegistryForBinding,
|
|
76
76
|
recordSessionRegistry,
|
|
77
|
-
resetState,
|
|
78
|
-
} from "../session.ts";
|
|
79
|
-
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
77
|
+
resetState,
|
|
78
|
+
} from "../session.ts";
|
|
79
|
+
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
80
|
+
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
80
81
|
|
|
81
82
|
function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
|
|
82
83
|
return {
|
|
@@ -197,8 +198,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
197
198
|
expect(platform.sendCard).not.toHaveBeenCalled();
|
|
198
199
|
});
|
|
199
200
|
|
|
200
|
-
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
201
|
-
const platform = mockPlatform();
|
|
201
|
+
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
202
|
+
const platform = mockPlatform();
|
|
202
203
|
await recordSessionRegistry({
|
|
203
204
|
chatId: "wx-chat",
|
|
204
205
|
sessionId: "sid-wechat",
|
|
@@ -208,9 +209,44 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
208
209
|
});
|
|
209
210
|
|
|
210
211
|
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
211
|
-
|
|
212
|
-
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
213
|
-
});
|
|
212
|
+
|
|
213
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("treats /abd as a shared prompt prefix in an existing session", async () => {
|
|
217
|
+
const platform = mockPlatform();
|
|
218
|
+
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
219
|
+
yield {
|
|
220
|
+
type: "assistant" as const,
|
|
221
|
+
blocks: [{ type: "text" as const, text: "done" }],
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
_setAdapterForToolForTest("claude", {
|
|
225
|
+
displayName: "Claude",
|
|
226
|
+
sessionDescPrefix: "Claude Session:",
|
|
227
|
+
createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
|
|
228
|
+
prompt,
|
|
229
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
230
|
+
sessionId,
|
|
231
|
+
cwd: "F:\\repo",
|
|
232
|
+
}),
|
|
233
|
+
closeSession: async () => {},
|
|
234
|
+
});
|
|
235
|
+
await recordSessionRegistry({
|
|
236
|
+
chatId: "wx-chat",
|
|
237
|
+
sessionId: "sid-wechat",
|
|
238
|
+
tool: "claude",
|
|
239
|
+
chatName: "ready-session",
|
|
240
|
+
running: false,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
244
|
+
|
|
245
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
246
|
+
const userText = prompt.mock.calls[0][1];
|
|
247
|
+
expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
248
|
+
expect(userText).not.toContain("/abd");
|
|
249
|
+
});
|
|
214
250
|
|
|
215
251
|
it("does not send the stopped success text until the running prompt really exits", async () => {
|
|
216
252
|
const platform = mockPlatform();
|
|
@@ -232,7 +268,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
232
268
|
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
|
|
233
269
|
});
|
|
234
270
|
|
|
235
|
-
it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
|
|
271
|
+
it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
|
|
236
272
|
const platform = mockPlatform("feishu");
|
|
237
273
|
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
238
274
|
yield {
|
|
@@ -277,8 +313,40 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
277
313
|
|
|
278
314
|
const registry = await loadSessionRegistryForBinding();
|
|
279
315
|
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
280
|
-
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
281
|
-
});
|
|
316
|
+
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
|
|
320
|
+
const platform = mockPlatform("feishu");
|
|
321
|
+
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
322
|
+
yield {
|
|
323
|
+
type: "assistant" as const,
|
|
324
|
+
blocks: [{ type: "text" as const, text: "done" }],
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
_setAdapterForToolForTest("claude", {
|
|
328
|
+
displayName: "Claude",
|
|
329
|
+
sessionDescPrefix: "Claude Session:",
|
|
330
|
+
createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
|
|
331
|
+
prompt,
|
|
332
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
333
|
+
sessionId,
|
|
334
|
+
cwd: "F:\\repo",
|
|
335
|
+
}),
|
|
336
|
+
closeSession: async () => {},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
340
|
+
|
|
341
|
+
expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
|
|
342
|
+
expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
|
|
343
|
+
const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
|
|
344
|
+
expect(updateCall[1]).not.toContain("---");
|
|
345
|
+
expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
|
|
346
|
+
const userText = prompt.mock.calls[0][1];
|
|
347
|
+
expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
348
|
+
expect(userText).not.toContain("/abd");
|
|
349
|
+
});
|
|
282
350
|
|
|
283
351
|
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
284
352
|
const platform = mockPlatform("feishu");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ABD_APPEND_PROMPT,
|
|
5
|
+
applySharedPrefix,
|
|
6
|
+
} from "../shared-prefix.ts";
|
|
7
|
+
|
|
8
|
+
describe("applySharedPrefix", () => {
|
|
9
|
+
it("leaves normal messages unchanged", () => {
|
|
10
|
+
expect(applySharedPrefix("帮我分析")).toEqual({
|
|
11
|
+
matched: false,
|
|
12
|
+
text: "帮我分析",
|
|
13
|
+
body: "帮我分析",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("removes /abd without requiring a space", () => {
|
|
18
|
+
const result = applySharedPrefix("/abd帮我分析");
|
|
19
|
+
|
|
20
|
+
expect(result.matched).toBe(true);
|
|
21
|
+
expect(result.body).toBe("帮我分析");
|
|
22
|
+
expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("removes whitespace after /abd", () => {
|
|
26
|
+
const result = applySharedPrefix("/abd 帮我分析");
|
|
27
|
+
|
|
28
|
+
expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps the appendix when the user body is empty", () => {
|
|
32
|
+
const result = applySharedPrefix("/abd ");
|
|
33
|
+
|
|
34
|
+
expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Button helpers
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Button helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { ABD_HELP_LINE } from "./shared-prefix.ts";
|
|
6
|
+
|
|
7
|
+
export interface ButtonDef {
|
|
6
8
|
text: string;
|
|
7
9
|
value: string;
|
|
8
10
|
type?: "primary" | "default" | "danger";
|
|
@@ -135,10 +137,11 @@ export function buildHelpCard(
|
|
|
135
137
|
"发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
|
|
136
138
|
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
139
|
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
-
"发送 **/usage** 查看 Codex 5h 和周用量",
|
|
139
|
-
"发送 **/restart** 重启 ChatCCC 进程",
|
|
140
|
-
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
141
|
-
|
|
140
|
+
"发送 **/usage** 查看 Codex 5h 和周用量",
|
|
141
|
+
"发送 **/restart** 重启 ChatCCC 进程",
|
|
142
|
+
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
143
|
+
ABD_HELP_LINE,
|
|
144
|
+
].join("\n");
|
|
142
145
|
return JSON.stringify({
|
|
143
146
|
config: { wide_screen_mode: true },
|
|
144
147
|
header: { template: "blue", title: { content: "ChatCCC", tag: "plain_text" } },
|
package/src/orchestrator.ts
CHANGED
|
@@ -78,9 +78,10 @@ import {
|
|
|
78
78
|
enqueueMessage,
|
|
79
79
|
cancelQueuedMessage,
|
|
80
80
|
} from "./session-chat-binding.ts";
|
|
81
|
-
import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
|
|
82
|
-
import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
|
|
83
|
-
|
|
81
|
+
import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
|
|
82
|
+
import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
|
|
83
|
+
import { applySharedPrefix } from "./shared-prefix.ts";
|
|
84
|
+
export { type PlatformAdapter } from "./platform-adapter.ts";
|
|
84
85
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
85
86
|
import type { CodexUsageSummary } from "./feishu-api.ts";
|
|
86
87
|
|
|
@@ -271,14 +272,13 @@ function isUntitledSessionChatName(name: string): boolean {
|
|
|
271
272
|
return name === "新会话" || name.startsWith("新会话-");
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
function shouldSendWechatProcessingAck(
|
|
275
|
-
platform: PlatformAdapter,
|
|
276
|
-
|
|
277
|
-
chatType: string,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
275
|
+
function shouldSendWechatProcessingAck(
|
|
276
|
+
platform: PlatformAdapter,
|
|
277
|
+
isCommandText: boolean,
|
|
278
|
+
chatType: string,
|
|
279
|
+
): boolean {
|
|
280
|
+
return platform.kind === "wechat" && chatType === "p2p" && !isCommandText;
|
|
281
|
+
}
|
|
282
282
|
|
|
283
283
|
function isNonWechatP2p(platform: PlatformAdapter, chatType: string): boolean {
|
|
284
284
|
return chatType === "p2p" && platform.kind !== "wechat";
|
|
@@ -408,13 +408,17 @@ export async function handleCommand(
|
|
|
408
408
|
msgTimestamp: number,
|
|
409
409
|
chatType = "group",
|
|
410
410
|
traceId?: string,
|
|
411
|
-
): Promise<void> {
|
|
412
|
-
const tid = traceId ?? makeTraceId();
|
|
413
|
-
const
|
|
414
|
-
|
|
411
|
+
): Promise<void> {
|
|
412
|
+
const tid = traceId ?? makeTraceId();
|
|
413
|
+
const sharedPrefix = applySharedPrefix(text);
|
|
414
|
+
const promptText = sharedPrefix.text;
|
|
415
|
+
text = sharedPrefix.body;
|
|
416
|
+
const textLower = text.toLowerCase();
|
|
417
|
+
const isCommandText = !sharedPrefix.matched && textLower.startsWith("/");
|
|
418
|
+
recordChatPlatform(chatId, platform);
|
|
415
419
|
await cleanupNonWechatP2pBinding(platform, chatId, chatType, tid);
|
|
416
420
|
|
|
417
|
-
if (textLower === "/restart") {
|
|
421
|
+
if (isCommandText && textLower === "/restart") {
|
|
418
422
|
logTrace(tid, "BRANCH", { cmd: "/restart" });
|
|
419
423
|
await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => {});
|
|
420
424
|
logTrace(tid, "DONE", { outcome: "restart" });
|
|
@@ -449,7 +453,7 @@ export async function handleCommand(
|
|
|
449
453
|
return;
|
|
450
454
|
}
|
|
451
455
|
|
|
452
|
-
if (textLower === "/update") {
|
|
456
|
+
if (isCommandText && textLower === "/update") {
|
|
453
457
|
logTrace(tid, "BRANCH", { cmd: "/update" });
|
|
454
458
|
const isGlobal = isRunningFromGlobalNpm();
|
|
455
459
|
appendStartupTrace("update: command received", { isGlobal, chatId });
|
|
@@ -466,7 +470,7 @@ export async function handleCommand(
|
|
|
466
470
|
return;
|
|
467
471
|
}
|
|
468
472
|
|
|
469
|
-
if (textLower === "/usage") {
|
|
473
|
+
if (isCommandText && textLower === "/usage") {
|
|
470
474
|
const usageTool = await resolveUsageTool(chatId);
|
|
471
475
|
logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
|
|
472
476
|
try {
|
|
@@ -479,7 +483,7 @@ export async function handleCommand(
|
|
|
479
483
|
return;
|
|
480
484
|
}
|
|
481
485
|
|
|
482
|
-
if (textLower === "/cd" || textLower.startsWith("/cd ")) {
|
|
486
|
+
if (isCommandText && (textLower === "/cd" || textLower.startsWith("/cd "))) {
|
|
483
487
|
logTrace(tid, "BRANCH", {
|
|
484
488
|
cmd: "/cd",
|
|
485
489
|
arg: text.slice(3).trim() || "(none)",
|
|
@@ -602,7 +606,7 @@ export async function handleCommand(
|
|
|
602
606
|
return;
|
|
603
607
|
}
|
|
604
608
|
|
|
605
|
-
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
609
|
+
if (isCommandText && (textLower === "/new" || textLower.startsWith("/new "))) {
|
|
606
610
|
const toolArg = text.slice(5).trim().toLowerCase();
|
|
607
611
|
const tool = toolArg || resolveDefaultAgentTool();
|
|
608
612
|
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
@@ -863,7 +867,7 @@ export async function handleCommand(
|
|
|
863
867
|
if (sessionId && descriptionTool && toolLabel) {
|
|
864
868
|
// 有会话上下文 — 路由到命令处理或 prompt
|
|
865
869
|
logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
|
|
866
|
-
const routeKind =
|
|
870
|
+
const routeKind = isCommandText ? "command" : "prompt";
|
|
867
871
|
const chatKind = chatType === "p2p" ? "p2p chat" : "session group";
|
|
868
872
|
console.log(
|
|
869
873
|
`[${ts()}] [ROUTE] ${toolLabel} ${chatKind} ${routeKind} detected, session=${sessionId} tool=${descriptionTool}`,
|
|
@@ -872,7 +876,7 @@ export async function handleCommand(
|
|
|
872
876
|
if (
|
|
873
877
|
chatType !== "p2p" &&
|
|
874
878
|
isUntitledSessionChatName(chatInfo!.name) &&
|
|
875
|
-
!
|
|
879
|
+
!isCommandText
|
|
876
880
|
) {
|
|
877
881
|
const MAX_PREFIX = 10;
|
|
878
882
|
const prefix = text.slice(0, MAX_PREFIX);
|
|
@@ -907,7 +911,7 @@ export async function handleCommand(
|
|
|
907
911
|
if (
|
|
908
912
|
chatType === "p2p" &&
|
|
909
913
|
platform.kind === "wechat" &&
|
|
910
|
-
!
|
|
914
|
+
!isCommandText
|
|
911
915
|
) {
|
|
912
916
|
try {
|
|
913
917
|
const reg = await loadSessionRegistryForBinding();
|
|
@@ -946,7 +950,7 @@ export async function handleCommand(
|
|
|
946
950
|
}
|
|
947
951
|
}
|
|
948
952
|
|
|
949
|
-
if (textLower === "/stop") {
|
|
953
|
+
if (isCommandText && textLower === "/stop") {
|
|
950
954
|
logTrace(tid, "BRANCH", { cmd: "/stop" });
|
|
951
955
|
if (stopSession(sessionId)) {
|
|
952
956
|
console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
|
|
@@ -960,7 +964,7 @@ export async function handleCommand(
|
|
|
960
964
|
return;
|
|
961
965
|
}
|
|
962
966
|
|
|
963
|
-
if (textLower === "/cancel") {
|
|
967
|
+
if (isCommandText && textLower === "/cancel") {
|
|
964
968
|
logTrace(tid, "BRANCH", { cmd: "/cancel" });
|
|
965
969
|
if (cancelQueuedMessage(sessionId)) {
|
|
966
970
|
console.log(`[${ts()}] [CANCEL] Queue cancelled for session=${sessionId}`);
|
|
@@ -973,7 +977,7 @@ export async function handleCommand(
|
|
|
973
977
|
return;
|
|
974
978
|
}
|
|
975
979
|
|
|
976
|
-
if (textLower === "/test") {
|
|
980
|
+
if (isCommandText && textLower === "/test") {
|
|
977
981
|
logTrace(tid, "BRANCH", { cmd: "/test" });
|
|
978
982
|
const tableHeaders = ["名称", "版本", "状态"];
|
|
979
983
|
const tableRows = [
|
|
@@ -1010,7 +1014,7 @@ export async function handleCommand(
|
|
|
1010
1014
|
return;
|
|
1011
1015
|
}
|
|
1012
1016
|
|
|
1013
|
-
if (textLower === "/state") {
|
|
1017
|
+
if (isCommandText && textLower === "/state") {
|
|
1014
1018
|
logTrace(tid, "BRANCH", { cmd: "/state" });
|
|
1015
1019
|
const status = await getSessionStatus(chatId);
|
|
1016
1020
|
const isActive = isSessionRunning(sessionId);
|
|
@@ -1049,7 +1053,7 @@ export async function handleCommand(
|
|
|
1049
1053
|
return;
|
|
1050
1054
|
}
|
|
1051
1055
|
|
|
1052
|
-
if (textLower === "/sessions") {
|
|
1056
|
+
if (isCommandText && textLower === "/sessions") {
|
|
1053
1057
|
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
1054
1058
|
const allSessions = await getAllSessionsStatus();
|
|
1055
1059
|
const now = Date.now();
|
|
@@ -1074,7 +1078,7 @@ export async function handleCommand(
|
|
|
1074
1078
|
return;
|
|
1075
1079
|
}
|
|
1076
1080
|
|
|
1077
|
-
if (textLower === "/newh") {
|
|
1081
|
+
if (isCommandText && textLower === "/newh") {
|
|
1078
1082
|
logTrace(tid, "BRANCH", { cmd: "/newh" });
|
|
1079
1083
|
const adapter = getAdapterForTool(descriptionTool, sessionId);
|
|
1080
1084
|
let cwd: string;
|
|
@@ -1161,7 +1165,7 @@ export async function handleCommand(
|
|
|
1161
1165
|
return;
|
|
1162
1166
|
}
|
|
1163
1167
|
|
|
1164
|
-
if (textLower === "/deleteg") {
|
|
1168
|
+
if (isCommandText && textLower === "/deleteg") {
|
|
1165
1169
|
logTrace(tid, "BRANCH", { cmd: "/deleteg" });
|
|
1166
1170
|
if (chatType === "p2p") {
|
|
1167
1171
|
await platform
|
|
@@ -1199,7 +1203,7 @@ export async function handleCommand(
|
|
|
1199
1203
|
}
|
|
1200
1204
|
|
|
1201
1205
|
// /session <number>:切换到 /sessions 列表中的指定会话
|
|
1202
|
-
const sessionMatch = textLower.match(/^\/session\s+(\d+)$/);
|
|
1206
|
+
const sessionMatch = isCommandText ? textLower.match(/^\/session\s+(\d+)$/) : null;
|
|
1203
1207
|
if (sessionMatch) {
|
|
1204
1208
|
const index = parseInt(sessionMatch[1], 10) - 1;
|
|
1205
1209
|
logTrace(tid, "BRANCH", { cmd: "/session", index: index + 1 });
|
|
@@ -1325,7 +1329,7 @@ export async function handleCommand(
|
|
|
1325
1329
|
}
|
|
1326
1330
|
|
|
1327
1331
|
// /model clear — 清除当前 session 的模型覆盖
|
|
1328
|
-
if (textLower === "/model clear") {
|
|
1332
|
+
if (isCommandText && textLower === "/model clear") {
|
|
1329
1333
|
logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
|
|
1330
1334
|
clearSessionModelOverride(sessionId);
|
|
1331
1335
|
const defaultModel = getEffectiveModelForTool(descriptionTool);
|
|
@@ -1340,7 +1344,7 @@ export async function handleCommand(
|
|
|
1340
1344
|
}
|
|
1341
1345
|
|
|
1342
1346
|
// /model <name> — 切换当前 session 的模型(支持所有 agent,模糊匹配)
|
|
1343
|
-
if (textLower.startsWith("/model ")) {
|
|
1347
|
+
if (isCommandText && textLower.startsWith("/model ")) {
|
|
1344
1348
|
const modelArg = text.slice(7).trim();
|
|
1345
1349
|
if (!modelArg) return; // 纯 "/model " 不处理,交给上面的 /model 分支
|
|
1346
1350
|
logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
|
|
@@ -1374,7 +1378,7 @@ export async function handleCommand(
|
|
|
1374
1378
|
}
|
|
1375
1379
|
|
|
1376
1380
|
// /model — 查看当前会话的可用模型(根据会话 Agent 类型)
|
|
1377
|
-
if (textLower === "/model") {
|
|
1381
|
+
if (isCommandText && textLower === "/model") {
|
|
1378
1382
|
logTrace(tid, "BRANCH", { cmd: "/model", sessionId, tool: descriptionTool });
|
|
1379
1383
|
const models = getAllModelsForTool(descriptionTool as AgentTool);
|
|
1380
1384
|
const currentModel = getEffectiveModelForTool(descriptionTool, sessionId);
|
|
@@ -1398,7 +1402,7 @@ export async function handleCommand(
|
|
|
1398
1402
|
}
|
|
1399
1403
|
|
|
1400
1404
|
// /git <args>:在「当前会话工作目录」执行 git 命令
|
|
1401
|
-
if (textLower.startsWith("/git ") || textLower === "/git") {
|
|
1405
|
+
if (isCommandText && (textLower.startsWith("/git ") || textLower === "/git")) {
|
|
1402
1406
|
const args = text === "/git" ? "" : text.slice(5).trim();
|
|
1403
1407
|
logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
|
|
1404
1408
|
if (!args) {
|
|
@@ -1467,9 +1471,9 @@ export async function handleCommand(
|
|
|
1467
1471
|
|
|
1468
1472
|
// 并发检查:同一 session 只能有一个活跃 prompt,多余消息进入队列
|
|
1469
1473
|
if (isSessionRunning(sessionId)) {
|
|
1470
|
-
const queued = enqueueMessage(sessionId, {
|
|
1471
|
-
text, chatId, openId, msgTimestamp, chatType, traceId: tid,
|
|
1472
|
-
});
|
|
1474
|
+
const queued = enqueueMessage(sessionId, {
|
|
1475
|
+
text: promptText, chatId, openId, msgTimestamp, chatType, traceId: tid,
|
|
1476
|
+
});
|
|
1473
1477
|
if (queued) {
|
|
1474
1478
|
logTrace(tid, "QUEUED", { sessionId });
|
|
1475
1479
|
console.log(
|
|
@@ -1494,16 +1498,16 @@ export async function handleCommand(
|
|
|
1494
1498
|
return;
|
|
1495
1499
|
}
|
|
1496
1500
|
|
|
1497
|
-
if (shouldSendWechatProcessingAck(platform,
|
|
1501
|
+
if (shouldSendWechatProcessingAck(platform, isCommandText, chatType)) {
|
|
1498
1502
|
await platform.sendText(chatId, "生成中...").catch(() => {});
|
|
1499
1503
|
}
|
|
1500
1504
|
|
|
1501
1505
|
try {
|
|
1502
1506
|
logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
|
|
1503
1507
|
await resumeAndPrompt(
|
|
1504
|
-
sessionId,
|
|
1505
|
-
|
|
1506
|
-
platform,
|
|
1508
|
+
sessionId,
|
|
1509
|
+
promptText,
|
|
1510
|
+
platform,
|
|
1507
1511
|
chatId,
|
|
1508
1512
|
msgTimestamp,
|
|
1509
1513
|
descriptionTool,
|
|
@@ -1529,7 +1533,7 @@ export async function handleCommand(
|
|
|
1529
1533
|
}
|
|
1530
1534
|
|
|
1531
1535
|
// 无会话上下文 → 检查是否是 /model 查询
|
|
1532
|
-
if (textLower === "/model") {
|
|
1536
|
+
if (isCommandText && textLower === "/model") {
|
|
1533
1537
|
const defaultTool = resolveDefaultAgentTool();
|
|
1534
1538
|
const models = getAllModelsForTool(defaultTool);
|
|
1535
1539
|
let currentModel = "";
|
|
@@ -1556,7 +1560,7 @@ export async function handleCommand(
|
|
|
1556
1560
|
}
|
|
1557
1561
|
|
|
1558
1562
|
// 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
|
|
1559
|
-
if (textLower === "/sessions") {
|
|
1563
|
+
if (isCommandText && textLower === "/sessions") {
|
|
1560
1564
|
logTrace(tid, "BRANCH", { cmd: "/sessions", scope: "global" });
|
|
1561
1565
|
const allSessions = await getAllSessionsStatus();
|
|
1562
1566
|
const now = Date.now();
|
|
@@ -1582,7 +1586,7 @@ export async function handleCommand(
|
|
|
1582
1586
|
}
|
|
1583
1587
|
|
|
1584
1588
|
// 飞书私聊普通消息:不再绑定私聊本身,而是自动创建会话群并把私聊内容作为首轮 prompt。
|
|
1585
|
-
if (isNonWechatP2p(platform, chatType) && !
|
|
1589
|
+
if (isNonWechatP2p(platform, chatType) && !isCommandText) {
|
|
1586
1590
|
const tool = resolveDefaultAgentTool();
|
|
1587
1591
|
const toolLabel = toolDisplayName(tool);
|
|
1588
1592
|
logTrace(tid, "BRANCH", { cmd: "auto_new_from_p2p", tool });
|
|
@@ -1700,9 +1704,9 @@ export async function handleCommand(
|
|
|
1700
1704
|
try {
|
|
1701
1705
|
logTrace(tid, "RESUME", { sessionId, tool, trigger: "auto_new_from_p2p" });
|
|
1702
1706
|
await resumeAndPrompt(
|
|
1703
|
-
sessionId,
|
|
1704
|
-
|
|
1705
|
-
platform,
|
|
1707
|
+
sessionId,
|
|
1708
|
+
promptText,
|
|
1709
|
+
platform,
|
|
1706
1710
|
newChatId,
|
|
1707
1711
|
msgTimestamp,
|
|
1708
1712
|
tool,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const ABD_PREFIX = "/abd";
|
|
2
|
+
|
|
3
|
+
export const ABD_APPEND_PROMPT =
|
|
4
|
+
"请从第一性原理出发挖掘我的真实需求。你觉得我的需求合理吗?开始实现之前有什么问题要问我的?";
|
|
5
|
+
|
|
6
|
+
export const ABD_HELP_LINE =
|
|
7
|
+
`发送 **${ABD_PREFIX}** 前缀消息,将以第一性原理追问真实需求、合理性和实现前问题`;
|
|
8
|
+
|
|
9
|
+
export interface SharedPrefixResult {
|
|
10
|
+
matched: boolean;
|
|
11
|
+
text: string;
|
|
12
|
+
body: string;
|
|
13
|
+
prefix?: typeof ABD_PREFIX;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function applySharedPrefix(text: string): SharedPrefixResult {
|
|
17
|
+
if (!text.toLowerCase().startsWith(ABD_PREFIX)) {
|
|
18
|
+
return { matched: false, text, body: text };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const body = text.slice(ABD_PREFIX.length).replace(/^\s+/, "");
|
|
22
|
+
const appendix = ["---", ABD_APPEND_PROMPT].join("\n");
|
|
23
|
+
return {
|
|
24
|
+
matched: true,
|
|
25
|
+
prefix: ABD_PREFIX,
|
|
26
|
+
body,
|
|
27
|
+
text: body ? [body, appendix].join("\n\n") : appendix,
|
|
28
|
+
};
|
|
29
|
+
}
|