chatccc 0.2.19 → 0.2.21
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 +21 -17
- package/config.sample.json +3 -0
- package/images/downloads/img_v3_0211k_d061664d-e25b-41ad-be94-442513e4d22g.webp +0 -0
- package/package.json +1 -1
- package/src/__tests__/config-reload.test.ts +41 -19
- package/src/cards.ts +23 -18
- package/src/config.ts +46 -11
- package/src/feishu-api.ts +212 -104
- package/src/index.ts +33 -11
- package/src/session.ts +3 -3
- package/src/web-ui.ts +121 -4
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
|
|
|
12
12
|
|
|
13
13
|
- **手机上也能用 Claude Code / Cursor / Codex** —— 在飞书群里发消息就等于在终端输入指令,AI 的思考和回复会流式展示在群卡片里
|
|
14
14
|
- **多会话并行** —— 一个群就是一个 AI 会话,完全隔离、互不干扰,并行工作效率更高
|
|
15
|
-
- **多工具切换** —— `/new
|
|
15
|
+
- **多工具切换** —— `/new` 使用默认 Agent 创建会话,也可用 `/new claude`、`/new cursor`、`/new codex` 指定工具,各取所长
|
|
16
16
|
|
|
17
17
|
一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
|
|
18
18
|
|
|
@@ -118,7 +118,7 @@ ChatCCC 启动时会按以下顺序确定 Cursor Agent CLI:
|
|
|
118
118
|
|
|
119
119
|
> 旧版字段 `cursor.command` 仍可读取(启动时会打印一次 warning 提示改名),新配置请统一使用 `cursor.path`。
|
|
120
120
|
|
|
121
|
-
> **说明**:只使用 Claude Code(`/new claude
|
|
121
|
+
> **说明**:只使用 Claude Code(`/new claude`,或把 Claude Code 设为默认 Agent 后使用 `/new`)的用户无需安装 Cursor CLI。
|
|
122
122
|
|
|
123
123
|
#### Codex CLI(使用 Codex 会话时需要)
|
|
124
124
|
|
|
@@ -215,21 +215,24 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
215
215
|
},
|
|
216
216
|
"port": 18080,
|
|
217
217
|
"gitTimeoutSeconds": 180,
|
|
218
|
-
"claude": {
|
|
219
|
-
"enabled": false,
|
|
220
|
-
"
|
|
218
|
+
"claude": {
|
|
219
|
+
"enabled": false,
|
|
220
|
+
"defaultAgent": true,
|
|
221
|
+
"model": "",
|
|
221
222
|
"effort": "",
|
|
222
223
|
"apiKey": "",
|
|
223
224
|
"baseUrl": ""
|
|
224
225
|
},
|
|
225
|
-
"cursor": {
|
|
226
|
-
"enabled": false,
|
|
227
|
-
"
|
|
226
|
+
"cursor": {
|
|
227
|
+
"enabled": false,
|
|
228
|
+
"defaultAgent": false,
|
|
229
|
+
"path": "",
|
|
228
230
|
"model": ""
|
|
229
231
|
},
|
|
230
|
-
"codex": {
|
|
231
|
-
"enabled": false,
|
|
232
|
-
"
|
|
232
|
+
"codex": {
|
|
233
|
+
"enabled": false,
|
|
234
|
+
"defaultAgent": false,
|
|
235
|
+
"path": "",
|
|
233
236
|
"model": "",
|
|
234
237
|
"effort": ""
|
|
235
238
|
}
|
|
@@ -242,9 +245,10 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
242
245
|
| --- | --- | --- |
|
|
243
246
|
| `feishu.appId` / `feishu.appSecret` | 是 | 飞书应用「凭证与基础信息」中的 App ID / App Secret |
|
|
244
247
|
| `port` | 否 | 本地 WebSocket 中继 + Web 向导监听端口,默认 `18080`;同机多实例时改成不同值即可 |
|
|
245
|
-
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
246
|
-
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
247
|
-
| `claude.
|
|
248
|
+
| `gitTimeoutSeconds` | 否 | `/git` 命令在会话工作目录执行时的单次超时秒数,默认 `180`,允许范围 `1–3600`,超时会被 `SIGKILL` 强制终止 |
|
|
249
|
+
| `claude.enabled` / `cursor.enabled` / `codex.enabled` | 否 | 是否启用对应 AI Agent;Web 向导/管理页只展示 `enabled: true` 的 agent 卡片。字段缺省时按「任一配置字段非空」自动判定(向后兼容) |
|
|
250
|
+
| `claude.defaultAgent` / `cursor.defaultAgent` / `codex.defaultAgent` | 否 | `/new` 未指定具体 Agent 时使用哪个默认 Agent;同一时间应只有一个为 `true`。Web 配置页切换某个 Agent 为默认时会自动关闭其它 Agent 的默认开关 |
|
|
251
|
+
| `claude.model` | 否 | Claude Code 会话使用的模型;留空(`""` / 全空白)→ 不向 SDK 传 `model`,由 SDK / 服务商默认决定 |
|
|
248
252
|
| `claude.effort` | 否 | Claude 思考深度(如 `low` / `medium` / `high` / `max`);留空 → 不向 SDK 传 `effort` |
|
|
249
253
|
| `claude.apiKey` | 否 | 第三方 Anthropic 兼容网关的 API 密钥;**官方 Claude 用户保持 `""` 即可**,详见下文「第三方 API」一节 |
|
|
250
254
|
| `claude.baseUrl` | 否 | 第三方 Anthropic 兼容网关的 base URL(例如 `https://api.deepseek.com/anthropic`);**官方 Claude 用户保持 `""` 即可** |
|
|
@@ -289,14 +293,14 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
289
293
|
|
|
290
294
|
### 5. 开始使用
|
|
291
295
|
|
|
292
|
-
在飞书中找到你的机器人,发送 `/new
|
|
296
|
+
在飞书中找到你的机器人,发送 `/new`(使用 `config.json` 中 `defaultAgent: true` 的默认 Agent)或 `/new claude` / `/new cursor` / `/new codex`,机器人会自动创建一个群聊并把 AI 会话绑定到该群。之后直接在群里发消息就能对话。
|
|
293
297
|
|
|
294
298
|
### 可用指令
|
|
295
299
|
|
|
296
300
|
|
|
297
301
|
| 指令 | 作用 |
|
|
298
302
|
| --------------- | ---------------------------- |
|
|
299
|
-
| `/new` |
|
|
303
|
+
| `/new` | 使用默认 Agent 创建新会话 |
|
|
300
304
|
| `/new claude` | 创建新的 Claude Code 会话 |
|
|
301
305
|
| `/new cursor` | 创建新的 Cursor 会话 |
|
|
302
306
|
| `/new codex` | 创建新的 Codex 会话(OpenAI) |
|
|
@@ -313,4 +317,4 @@ ChatCCC 的所有运行参数都集中在包根目录的 `config.json`。
|
|
|
313
317
|
|
|
314
318
|
## 技术栈
|
|
315
319
|
|
|
316
|
-
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
|
320
|
+
TypeScript / Node.js >= 20 / tsx / Anthropic Claude Agent SDK / Cursor Agent CLI / Codex CLI (OpenAI) / 飞书 WebSocket API / CardKit
|
package/config.sample.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"gitTimeoutSeconds": 180,
|
|
8
8
|
"claude": {
|
|
9
9
|
"enabled": false,
|
|
10
|
+
"defaultAgent": true,
|
|
10
11
|
"model": "",
|
|
11
12
|
"effort": "",
|
|
12
13
|
"apiKey": "",
|
|
@@ -14,11 +15,13 @@
|
|
|
14
15
|
},
|
|
15
16
|
"cursor": {
|
|
16
17
|
"enabled": false,
|
|
18
|
+
"defaultAgent": false,
|
|
17
19
|
"path": "",
|
|
18
20
|
"model": ""
|
|
19
21
|
},
|
|
20
22
|
"codex": {
|
|
21
23
|
"enabled": false,
|
|
24
|
+
"defaultAgent": false,
|
|
22
25
|
"path": "",
|
|
23
26
|
"model": "",
|
|
24
27
|
"effort": ""
|
package/package.json
CHANGED
|
@@ -16,10 +16,11 @@ import {
|
|
|
16
16
|
CURSOR_AGENT_COMMAND,
|
|
17
17
|
GIT_TIMEOUT_MS,
|
|
18
18
|
GIT_TIMEOUT_SECONDS,
|
|
19
|
-
applyLoadedConfig,
|
|
20
|
-
config,
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
applyLoadedConfig,
|
|
20
|
+
config,
|
|
21
|
+
resolveDefaultAgentTool,
|
|
22
|
+
type AppConfig,
|
|
23
|
+
} from "../config.ts";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// applyLoadedConfig — setup → service「在线切换」时刷新进程内 config 的核心机制
|
|
@@ -36,15 +37,16 @@ const baseAppConfig: AppConfig = {
|
|
|
36
37
|
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
37
38
|
port: 18080,
|
|
38
39
|
gitTimeoutSeconds: 180,
|
|
39
|
-
claude: {
|
|
40
|
-
enabled: true,
|
|
41
|
-
|
|
40
|
+
claude: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
defaultAgent: true,
|
|
43
|
+
model: "initial-model",
|
|
42
44
|
effort: "initial-effort",
|
|
43
45
|
apiKey: "sk-initial",
|
|
44
46
|
baseUrl: "https://initial.gw/anthropic",
|
|
45
47
|
},
|
|
46
|
-
cursor: { enabled: true, path: "/initial/cursor", model: "initial-cursor-model" },
|
|
47
|
-
codex: { enabled: true, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
|
|
48
|
+
cursor: { enabled: true, defaultAgent: false, path: "/initial/cursor", model: "initial-cursor-model" },
|
|
49
|
+
codex: { enabled: true, defaultAgent: false, path: "/initial/codex", model: "initial-codex-model", effort: "initial-codex-effort" },
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
// 把 module 状态抢救快照:每个 it 跑前重置回这个状态,避免污染相邻测试。
|
|
@@ -75,9 +77,10 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
75
77
|
it("更新 Claude 配置(model / effort / apiKey / baseUrl)", () => {
|
|
76
78
|
applyLoadedConfig({
|
|
77
79
|
...structuredClone(baseAppConfig),
|
|
78
|
-
claude: {
|
|
79
|
-
enabled: true,
|
|
80
|
-
|
|
80
|
+
claude: {
|
|
81
|
+
enabled: true,
|
|
82
|
+
defaultAgent: true,
|
|
83
|
+
model: "deepseek-v4-pro",
|
|
81
84
|
effort: "high",
|
|
82
85
|
apiKey: "sk-newkey",
|
|
83
86
|
baseUrl: "https://gw2.example/anthropic",
|
|
@@ -104,7 +107,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
104
107
|
it("CURSOR_AGENT_ARGS 跟随 cursor.model 重新解析", () => {
|
|
105
108
|
applyLoadedConfig({
|
|
106
109
|
...structuredClone(baseAppConfig),
|
|
107
|
-
cursor: { enabled: true, path: "/x/cursor", model: "claude-3.7-sonnet" },
|
|
110
|
+
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "claude-3.7-sonnet" },
|
|
108
111
|
});
|
|
109
112
|
|
|
110
113
|
// CURSOR_AGENT_ARGS 是 ['-p', '--force', ..., '--model', 'claude-3.7-sonnet']
|
|
@@ -115,7 +118,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
115
118
|
it("cursor.model 留空时 CURSOR_AGENT_ARGS 不含 --model", () => {
|
|
116
119
|
applyLoadedConfig({
|
|
117
120
|
...structuredClone(baseAppConfig),
|
|
118
|
-
cursor: { enabled: true, path: "/x/cursor", model: "" },
|
|
121
|
+
cursor: { enabled: true, defaultAgent: false, path: "/x/cursor", model: "" },
|
|
119
122
|
});
|
|
120
123
|
|
|
121
124
|
expect(CURSOR_AGENT_ARGS).not.toContain("--model");
|
|
@@ -124,7 +127,7 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
124
127
|
it("CURSOR_AGENT_COMMAND 优先取 config.cursor.path", () => {
|
|
125
128
|
applyLoadedConfig({
|
|
126
129
|
...structuredClone(baseAppConfig),
|
|
127
|
-
cursor: { enabled: true, path: "C:/custom/cursor.exe", model: "" },
|
|
130
|
+
cursor: { enabled: true, defaultAgent: false, path: "C:/custom/cursor.exe", model: "" },
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
expect(CURSOR_AGENT_COMMAND).toBe("C:/custom/cursor.exe");
|
|
@@ -142,14 +145,14 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
142
145
|
});
|
|
143
146
|
});
|
|
144
147
|
|
|
145
|
-
describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
148
|
+
describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
146
149
|
it("config 引用保持不变(就地更新),但字段被刷新", () => {
|
|
147
150
|
const refBefore = config;
|
|
148
151
|
|
|
149
152
|
applyLoadedConfig({
|
|
150
153
|
...structuredClone(baseAppConfig),
|
|
151
154
|
feishu: { appId: "REF_TEST_APP", appSecret: "REF_TEST_SECRET" },
|
|
152
|
-
codex: { enabled: true, path: "/refresh/codex", model: "fresh-model", effort: "low" },
|
|
155
|
+
codex: { enabled: true, defaultAgent: false, path: "/refresh/codex", model: "fresh-model", effort: "low" },
|
|
153
156
|
});
|
|
154
157
|
|
|
155
158
|
// 必须是同一个引用:codex-adapter 等下游模块"直接 import config",
|
|
@@ -167,5 +170,24 @@ describe("applyLoadedConfig — config 对象引用契约", () => {
|
|
|
167
170
|
expect(APP_ID).toBe("");
|
|
168
171
|
expect(APP_SECRET).toBe("");
|
|
169
172
|
expect(config.feishu.appId).toBe("");
|
|
170
|
-
});
|
|
171
|
-
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("resolveDefaultAgentTool", () => {
|
|
177
|
+
it("优先使用显式 defaultAgent 且已启用的 Agent", () => {
|
|
178
|
+
const cfg = structuredClone(baseAppConfig);
|
|
179
|
+
cfg.claude.defaultAgent = false;
|
|
180
|
+
cfg.cursor.defaultAgent = true;
|
|
181
|
+
|
|
182
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("defaultAgent 指向未启用 Agent 时回退到第一个已启用 Agent", () => {
|
|
186
|
+
const cfg = structuredClone(baseAppConfig);
|
|
187
|
+
cfg.claude.enabled = false;
|
|
188
|
+
cfg.claude.defaultAgent = true;
|
|
189
|
+
cfg.cursor.defaultAgent = false;
|
|
190
|
+
|
|
191
|
+
expect(resolveDefaultAgentTool(cfg)).toBe("cursor");
|
|
192
|
+
});
|
|
193
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -108,13 +108,17 @@ export function buildProgressCard(
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export function buildHelpCard(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
111
|
+
export function buildHelpCard(
|
|
112
|
+
userText: string,
|
|
113
|
+
opts: { greeting?: string; defaultToolLabel?: string } = {},
|
|
114
|
+
): string {
|
|
115
|
+
const greeting = opts.greeting ?? `你发送了: ${userText}`;
|
|
116
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
117
|
+
const lines = [
|
|
118
|
+
`发送 **/new** 创建新会话(默认 ${defaultToolLabel})`,
|
|
119
|
+
"发送 **/new claude** 创建新 Claude 对话",
|
|
120
|
+
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
121
|
+
"发送 **/new codex** 创建新 Codex 会话",
|
|
118
122
|
"发送 **/forget** 重置当前会话(保留工作目录,同一群内继续)",
|
|
119
123
|
].join("\n");
|
|
120
124
|
return JSON.stringify({
|
|
@@ -124,7 +128,7 @@ export function buildHelpCard(userText: string, opts: { greeting?: string } = {}
|
|
|
124
128
|
{ tag: "div", text: { tag: "lark_md", content: greeting } },
|
|
125
129
|
{ tag: "div", text: { tag: "lark_md", content: lines } },
|
|
126
130
|
buildButtons([
|
|
127
|
-
{ text:
|
|
131
|
+
{ text: `新建默认会话(/new,${defaultToolLabel})`, value: JSON.stringify({ cmd: "new" }), type: "primary" },
|
|
128
132
|
{ text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
|
|
129
133
|
{ text: "新建 Codex 会话(/new codex)", value: JSON.stringify({ cmd: "new codex" }), type: "primary" },
|
|
130
134
|
{ text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
|
|
@@ -248,14 +252,15 @@ export function buildCdCard(
|
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
// 所有会话列表卡片(Claude Code 优先,然后 Cursor)
|
|
251
|
-
export function buildSessionsCard(sessions: Array<{
|
|
252
|
-
sessionId: string;
|
|
253
|
-
active: boolean;
|
|
254
|
-
turnCount: number;
|
|
255
|
-
elapsedSeconds: number | null;
|
|
256
|
-
model: string;
|
|
257
|
-
tool: string;
|
|
258
|
-
}
|
|
255
|
+
export function buildSessionsCard(sessions: Array<{
|
|
256
|
+
sessionId: string;
|
|
257
|
+
active: boolean;
|
|
258
|
+
turnCount: number;
|
|
259
|
+
elapsedSeconds: number | null;
|
|
260
|
+
model: string;
|
|
261
|
+
tool: string;
|
|
262
|
+
}>, opts: { defaultToolLabel?: string } = {}): string {
|
|
263
|
+
const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
|
|
259
264
|
// 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
|
|
260
265
|
const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
|
|
261
266
|
const cursorSessions = sessions.filter(s => s.tool === "cursor");
|
|
@@ -269,7 +274,7 @@ export function buildSessionsCard(sessions: Array<{
|
|
|
269
274
|
config: { wide_screen_mode: true },
|
|
270
275
|
header: { template: "blue", title: { content: "所有会话", tag: "plain_text" } },
|
|
271
276
|
elements: [
|
|
272
|
-
{ tag: "div", text: { tag: "lark_md", content:
|
|
277
|
+
{ tag: "div", text: { tag: "lark_md", content: `当前没有会话记录。\n\n使用 **/new**(默认 ${defaultToolLabel})、**/new claude**、**/new cursor** 或 **/new codex** 创建新会话。` } },
|
|
273
278
|
{ tag: "hr" },
|
|
274
279
|
{ tag: "action", actions: [{ tag: "button", text: { tag: "plain_text", content: "收起" }, type: "default", value: { action: "close" } }] },
|
|
275
280
|
],
|
|
@@ -355,4 +360,4 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
355
360
|
},
|
|
356
361
|
],
|
|
357
362
|
});
|
|
358
|
-
}
|
|
363
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -58,6 +58,8 @@ export async function appendChatLog(chatId: string, sender: string, text: string
|
|
|
58
58
|
export interface ClaudeConfig {
|
|
59
59
|
/** 是否启用 Claude Code Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
60
60
|
enabled: boolean;
|
|
61
|
+
/** 是否作为 /new 未指定工具时使用的默认 Agent */
|
|
62
|
+
defaultAgent: boolean;
|
|
61
63
|
model: string;
|
|
62
64
|
effort: string;
|
|
63
65
|
apiKey: string;
|
|
@@ -67,6 +69,8 @@ export interface ClaudeConfig {
|
|
|
67
69
|
export interface CursorConfig {
|
|
68
70
|
/** 是否启用 Cursor Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
69
71
|
enabled: boolean;
|
|
72
|
+
/** 是否作为 /new 未指定工具时使用的默认 Agent */
|
|
73
|
+
defaultAgent: boolean;
|
|
70
74
|
/** Cursor Agent CLI 可执行文件绝对路径;留空时由运行时按 LocalAppData / PATH 兜底 */
|
|
71
75
|
path: string;
|
|
72
76
|
model: string;
|
|
@@ -75,6 +79,8 @@ export interface CursorConfig {
|
|
|
75
79
|
export interface CodexConfig {
|
|
76
80
|
/** 是否启用 Codex Agent;缺省时按"有任意字段非空"自动判定(向后兼容) */
|
|
77
81
|
enabled: boolean;
|
|
82
|
+
/** 是否作为 /new 未指定工具时使用的默认 Agent */
|
|
83
|
+
defaultAgent: boolean;
|
|
78
84
|
/** Codex CLI 可执行文件绝对路径;留空时退回到 PATH 中的 `codex` */
|
|
79
85
|
path: string;
|
|
80
86
|
model: string;
|
|
@@ -95,6 +101,9 @@ export interface AppConfig {
|
|
|
95
101
|
codex: CodexConfig;
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
export type AgentTool = "claude" | "cursor" | "codex";
|
|
105
|
+
export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
|
|
106
|
+
|
|
98
107
|
const CONFIG_FILE = join(PROJECT_ROOT, "config.json");
|
|
99
108
|
const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
|
|
100
109
|
|
|
@@ -203,9 +212,9 @@ function loadConfig(): AppConfig {
|
|
|
203
212
|
feishu: { appId: "", appSecret: "" },
|
|
204
213
|
port: 18080,
|
|
205
214
|
gitTimeoutSeconds: 180,
|
|
206
|
-
claude: { enabled: false, model: "", effort: "", apiKey: "", baseUrl: "" },
|
|
207
|
-
cursor: { enabled: false, path: "", model: "claude-opus-4-7-max" },
|
|
208
|
-
codex: { enabled: false, path: "", model: "", effort: "" },
|
|
215
|
+
claude: { enabled: false, defaultAgent: true, model: "", effort: "", apiKey: "", baseUrl: "" },
|
|
216
|
+
cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
|
|
217
|
+
codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
|
|
209
218
|
};
|
|
210
219
|
|
|
211
220
|
if (!existsSync(CONFIG_FILE)) {
|
|
@@ -239,8 +248,8 @@ function loadConfig(): AppConfig {
|
|
|
239
248
|
|
|
240
249
|
let parsed: Partial<AppConfig> & {
|
|
241
250
|
claude?: Partial<ClaudeConfig> & { enabled?: unknown };
|
|
242
|
-
cursor?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown };
|
|
243
|
-
codex?: { enabled?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
|
|
251
|
+
cursor?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown };
|
|
252
|
+
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
|
|
244
253
|
};
|
|
245
254
|
try {
|
|
246
255
|
parsed = JSON.parse(raw);
|
|
@@ -251,8 +260,8 @@ function loadConfig(): AppConfig {
|
|
|
251
260
|
|
|
252
261
|
const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
|
|
253
262
|
const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
|
|
254
|
-
const cursorRaw = parsed.cursor ?? {}
|
|
255
|
-
const codexRaw = parsed.codex ?? {}
|
|
263
|
+
const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
|
|
264
|
+
const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
|
|
256
265
|
|
|
257
266
|
// 兼容旧字段 `command`:命中时打印一次性 warning 提示用户改名
|
|
258
267
|
const onLegacyField = (label: string, value: string): void => {
|
|
@@ -292,6 +301,21 @@ function loadConfig(): AppConfig {
|
|
|
292
301
|
(typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()),
|
|
293
302
|
);
|
|
294
303
|
|
|
304
|
+
const claudeEnabled = resolveEnabled(claude.enabled, claudeNonEmpty);
|
|
305
|
+
const cursorEnabled = resolveEnabled(cursorRaw.enabled, cursorNonEmpty);
|
|
306
|
+
const codexEnabled = resolveEnabled(codexRaw.enabled, codexNonEmpty);
|
|
307
|
+
const explicitDefaultTool: AgentTool | null =
|
|
308
|
+
typeof claude.defaultAgent === "boolean" && claude.defaultAgent && claudeEnabled ? "claude" :
|
|
309
|
+
typeof cursorRaw.defaultAgent === "boolean" && cursorRaw.defaultAgent && cursorEnabled ? "cursor" :
|
|
310
|
+
typeof codexRaw.defaultAgent === "boolean" && codexRaw.defaultAgent && codexEnabled ? "codex" :
|
|
311
|
+
null;
|
|
312
|
+
const fallbackDefaultTool: AgentTool =
|
|
313
|
+
claudeEnabled ? "claude" :
|
|
314
|
+
cursorEnabled ? "cursor" :
|
|
315
|
+
codexEnabled ? "codex" :
|
|
316
|
+
"claude";
|
|
317
|
+
const defaultTool = explicitDefaultTool ?? fallbackDefaultTool;
|
|
318
|
+
|
|
295
319
|
return {
|
|
296
320
|
feishu: {
|
|
297
321
|
appId: feishu.appId ?? "",
|
|
@@ -300,19 +324,22 @@ function loadConfig(): AppConfig {
|
|
|
300
324
|
port: typeof parsed.port === "number" ? parsed.port : 18080,
|
|
301
325
|
gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
|
|
302
326
|
claude: {
|
|
303
|
-
enabled:
|
|
327
|
+
enabled: claudeEnabled,
|
|
328
|
+
defaultAgent: defaultTool === "claude",
|
|
304
329
|
model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
|
|
305
330
|
effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
|
|
306
331
|
apiKey: claude.apiKey ?? "",
|
|
307
332
|
baseUrl: claude.baseUrl ?? "",
|
|
308
333
|
},
|
|
309
334
|
cursor: {
|
|
310
|
-
enabled:
|
|
335
|
+
enabled: cursorEnabled,
|
|
336
|
+
defaultAgent: defaultTool === "cursor",
|
|
311
337
|
path: readToolCliPath(cursorRaw, { label: "cursor", onLegacyField }),
|
|
312
338
|
model: normalizeOptionalConfigField(cursorRaw.model, { label: "cursor.model", fallback: "claude-opus-4-7-max" }),
|
|
313
339
|
},
|
|
314
340
|
codex: {
|
|
315
|
-
enabled:
|
|
341
|
+
enabled: codexEnabled,
|
|
342
|
+
defaultAgent: defaultTool === "codex",
|
|
316
343
|
path: readToolCliPath(codexRaw, { label: "codex", onLegacyField }),
|
|
317
344
|
model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
|
|
318
345
|
effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
|
|
@@ -612,5 +639,13 @@ export function toolDisplayName(tool: string): string {
|
|
|
612
639
|
return "Claude Code";
|
|
613
640
|
}
|
|
614
641
|
|
|
642
|
+
/** 解析 /new 未指定工具时使用的默认 Agent。旧配置缺省 defaultAgent 时保持 Claude 优先。 */
|
|
643
|
+
export function resolveDefaultAgentTool(cfg: AppConfig = config): AgentTool {
|
|
644
|
+
const explicit = AGENT_TOOLS.find((tool) => cfg[tool].enabled && cfg[tool].defaultAgent);
|
|
645
|
+
if (explicit) return explicit;
|
|
646
|
+
const enabledFallback = AGENT_TOOLS.find((tool) => cfg[tool].enabled);
|
|
647
|
+
return enabledFallback ?? "claude";
|
|
648
|
+
}
|
|
649
|
+
|
|
615
650
|
// 导出 config 对象供其他模块直接访问原始配置
|
|
616
|
-
export { CONFIG_FILE, CONFIG_SAMPLE_FILE };
|
|
651
|
+
export { CONFIG_FILE, CONFIG_SAMPLE_FILE };
|
package/src/feishu-api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { resolve as resolvePath } from "node:path";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve as resolvePath } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import sharp from "sharp";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -260,94 +260,94 @@ export function extractSessionId(description: string): string | null {
|
|
|
260
260
|
// Avatar
|
|
261
261
|
// ---------------------------------------------------------------------------
|
|
262
262
|
|
|
263
|
-
const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
|
|
264
|
-
const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
|
|
265
|
-
const AVATAR_KEY_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "avatar-image-keys.json");
|
|
266
|
-
const AVATAR_SOURCES: Record<string, string> = {
|
|
267
|
-
new: resolvePath(AVATAR_DIR, "status_new.png"),
|
|
268
|
-
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
269
|
-
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
270
|
-
};
|
|
271
|
-
const AVATAR_BADGES: Record<string, string> = {
|
|
272
|
-
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
273
|
-
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
274
|
-
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
275
|
-
};
|
|
276
|
-
const AVATAR_SIZE = 256;
|
|
277
|
-
const BADGE_SIZE = 92;
|
|
278
|
-
const BADGE_MARGIN = 10;
|
|
279
|
-
|
|
280
|
-
const avatarKeyCache = new Map<string, string>();
|
|
281
|
-
let avatarKeyCacheLoaded = false;
|
|
282
|
-
|
|
283
|
-
function normalizeAvatarTool(tool: string): string {
|
|
284
|
-
return AVATAR_BADGES[tool] ? tool : "claude";
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function normalizeAvatarStatus(status: string): string {
|
|
288
|
-
return AVATAR_SOURCES[status] ? status : "idle";
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function avatarCacheKey(tool: string, status: string): string {
|
|
292
|
-
return `${normalizeAvatarTool(tool)}:${normalizeAvatarStatus(status)}`;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
async function loadAvatarKeyCache(): Promise<void> {
|
|
296
|
-
if (avatarKeyCacheLoaded) return;
|
|
297
|
-
avatarKeyCacheLoaded = true;
|
|
298
|
-
try {
|
|
299
|
-
const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
|
|
300
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
301
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
302
|
-
if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
305
|
-
// Missing or malformed cache should not block avatar updates.
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
async function persistAvatarKeyCache(): Promise<void> {
|
|
310
|
-
await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
|
|
311
|
-
await writeFile(
|
|
312
|
-
AVATAR_KEY_CACHE_FILE,
|
|
313
|
-
JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
|
|
314
|
-
"utf-8",
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function renderAvatar(tool: string, status: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
319
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
320
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
321
|
-
const badge = await sharp(await readFile(AVATAR_BADGES[normalizedTool]))
|
|
322
|
-
.resize(BADGE_SIZE, BADGE_SIZE, {
|
|
323
|
-
fit: "contain",
|
|
324
|
-
kernel: sharp.kernel.lanczos3,
|
|
325
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
326
|
-
})
|
|
327
|
-
.png()
|
|
328
|
-
.toBuffer();
|
|
329
|
-
|
|
330
|
-
const jpeg = await sharp(await readFile(AVATAR_SOURCES[normalizedStatus]))
|
|
331
|
-
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" })
|
|
332
|
-
.composite([{
|
|
333
|
-
input: badge,
|
|
334
|
-
left: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
335
|
-
top: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
336
|
-
}])
|
|
337
|
-
.flatten({ background: "#ffffff" })
|
|
338
|
-
.removeAlpha()
|
|
339
|
-
.jpeg({ quality: 95, progressive: false })
|
|
263
|
+
const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
|
|
264
|
+
const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
|
|
265
|
+
const AVATAR_KEY_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "avatar-image-keys.json");
|
|
266
|
+
const AVATAR_SOURCES: Record<string, string> = {
|
|
267
|
+
new: resolvePath(AVATAR_DIR, "status_new.png"),
|
|
268
|
+
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
269
|
+
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
270
|
+
};
|
|
271
|
+
const AVATAR_BADGES: Record<string, string> = {
|
|
272
|
+
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
273
|
+
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
274
|
+
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
275
|
+
};
|
|
276
|
+
const AVATAR_SIZE = 256;
|
|
277
|
+
const BADGE_SIZE = 92;
|
|
278
|
+
const BADGE_MARGIN = 10;
|
|
279
|
+
|
|
280
|
+
const avatarKeyCache = new Map<string, string>();
|
|
281
|
+
let avatarKeyCacheLoaded = false;
|
|
282
|
+
|
|
283
|
+
function normalizeAvatarTool(tool: string): string {
|
|
284
|
+
return AVATAR_BADGES[tool] ? tool : "claude";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeAvatarStatus(status: string): string {
|
|
288
|
+
return AVATAR_SOURCES[status] ? status : "idle";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function avatarCacheKey(tool: string, status: string): string {
|
|
292
|
+
return `${normalizeAvatarTool(tool)}:${normalizeAvatarStatus(status)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function loadAvatarKeyCache(): Promise<void> {
|
|
296
|
+
if (avatarKeyCacheLoaded) return;
|
|
297
|
+
avatarKeyCacheLoaded = true;
|
|
298
|
+
try {
|
|
299
|
+
const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
|
|
300
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
301
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
302
|
+
if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Missing or malformed cache should not block avatar updates.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function persistAvatarKeyCache(): Promise<void> {
|
|
310
|
+
await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
|
|
311
|
+
await writeFile(
|
|
312
|
+
AVATAR_KEY_CACHE_FILE,
|
|
313
|
+
JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
|
|
314
|
+
"utf-8",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function renderAvatar(tool: string, status: string): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
319
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
320
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
321
|
+
const badge = await sharp(await readFile(AVATAR_BADGES[normalizedTool]))
|
|
322
|
+
.resize(BADGE_SIZE, BADGE_SIZE, {
|
|
323
|
+
fit: "contain",
|
|
324
|
+
kernel: sharp.kernel.lanczos3,
|
|
325
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
326
|
+
})
|
|
327
|
+
.png()
|
|
340
328
|
.toBuffer();
|
|
341
329
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
330
|
+
const jpeg = await sharp(await readFile(AVATAR_SOURCES[normalizedStatus]))
|
|
331
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" })
|
|
332
|
+
.composite([{
|
|
333
|
+
input: badge,
|
|
334
|
+
left: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
335
|
+
top: AVATAR_SIZE - BADGE_SIZE - BADGE_MARGIN,
|
|
336
|
+
}])
|
|
337
|
+
.flatten({ background: "#ffffff" })
|
|
338
|
+
.removeAlpha()
|
|
339
|
+
.jpeg({ quality: 95, progressive: false })
|
|
340
|
+
.toBuffer();
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
buffer: jpeg,
|
|
344
|
+
contentType: "image/jpeg",
|
|
345
|
+
filename: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function uploadImage(token: string, tool: string, status: string): Promise<string> {
|
|
350
|
+
const image = await renderAvatar(tool, status);
|
|
351
351
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
352
352
|
const form = new FormData();
|
|
353
353
|
form.append("image_type", "avatar");
|
|
@@ -368,23 +368,23 @@ async function uploadImage(token: string, tool: string, status: string): Promise
|
|
|
368
368
|
return data.data!.image_key!;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
|
|
372
|
-
await loadAvatarKeyCache();
|
|
373
|
-
const keyName = avatarCacheKey(tool, status);
|
|
374
|
-
const cached = avatarKeyCache.get(keyName);
|
|
371
|
+
async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
|
|
372
|
+
await loadAvatarKeyCache();
|
|
373
|
+
const keyName = avatarCacheKey(tool, status);
|
|
374
|
+
const cached = avatarKeyCache.get(keyName);
|
|
375
375
|
if (cached) return cached;
|
|
376
|
-
const key = await uploadImage(token, tool, status);
|
|
377
|
-
avatarKeyCache.set(keyName, key);
|
|
378
|
-
await persistAvatarKeyCache().catch((err) => {
|
|
379
|
-
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|
|
380
|
-
});
|
|
376
|
+
const key = await uploadImage(token, tool, status);
|
|
377
|
+
avatarKeyCache.set(keyName, key);
|
|
378
|
+
await persistAvatarKeyCache().catch((err) => {
|
|
379
|
+
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|
|
380
|
+
});
|
|
381
381
|
console.log(`[${ts()}] [AVATAR] Uploaded "${status}" → image_key=${key}`);
|
|
382
382
|
return key;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
|
|
386
|
-
try {
|
|
387
|
-
const avatarKey = await getOrUploadAvatarKey(token, tool, status);
|
|
385
|
+
export async function setChatAvatar(token: string, chatId: string, tool: string, status: string): Promise<void> {
|
|
386
|
+
try {
|
|
387
|
+
const avatarKey = await getOrUploadAvatarKey(token, tool, status);
|
|
388
388
|
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
389
389
|
method: "PUT",
|
|
390
390
|
headers: {
|
|
@@ -401,14 +401,122 @@ export async function setChatAvatar(token: string, chatId: string, tool: string,
|
|
|
401
401
|
}
|
|
402
402
|
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
403
403
|
} catch (err) {
|
|
404
|
-
console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
404
|
+
console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Image download & cache
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
const IMAGE_DOWNLOAD_DIR = resolvePath(PROJECT_ROOT, "images", "downloads");
|
|
413
|
+
const IMAGE_CACHE_FILE = resolvePath(PROJECT_ROOT, "state", "image-cache.json");
|
|
414
|
+
|
|
415
|
+
const imageCache = new Map<string, string>();
|
|
416
|
+
let imageCacheLoaded = false;
|
|
417
|
+
|
|
418
|
+
async function loadImageCache(): Promise<void> {
|
|
419
|
+
if (imageCacheLoaded) return;
|
|
420
|
+
imageCacheLoaded = true;
|
|
421
|
+
try {
|
|
422
|
+
const raw = await readFile(IMAGE_CACHE_FILE, "utf-8");
|
|
423
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
424
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
425
|
+
if (typeof value === "string" && value.trim()) imageCache.set(key, value);
|
|
426
|
+
}
|
|
427
|
+
} catch { /* missing or malformed cache is not fatal */ }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function persistImageCache(): Promise<void> {
|
|
431
|
+
await mkdir(dirname(IMAGE_CACHE_FILE), { recursive: true });
|
|
432
|
+
await writeFile(
|
|
433
|
+
IMAGE_CACHE_FILE,
|
|
434
|
+
JSON.stringify(Object.fromEntries(imageCache.entries()), null, 2),
|
|
435
|
+
"utf-8",
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function extFromContentType(contentType: string | null): string {
|
|
440
|
+
if (!contentType) return ".png";
|
|
441
|
+
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
442
|
+
const map: Record<string, string> = {
|
|
443
|
+
"image/png": ".png",
|
|
444
|
+
"image/jpeg": ".jpg",
|
|
445
|
+
"image/webp": ".webp",
|
|
446
|
+
"image/gif": ".gif",
|
|
447
|
+
"image/bmp": ".bmp",
|
|
448
|
+
"image/svg+xml": ".svg",
|
|
449
|
+
};
|
|
450
|
+
return map[mime] ?? ".png";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function downloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
|
|
454
|
+
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}/resources/${fileKey}?type=image`, {
|
|
455
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
456
|
+
});
|
|
457
|
+
if (!resp.ok) {
|
|
458
|
+
const text = await resp.text().catch(() => "");
|
|
459
|
+
throw new Error(`downloadImage HTTP ${resp.status}: ${text.slice(0, 200)}`);
|
|
460
|
+
}
|
|
461
|
+
const ext = extFromContentType(resp.headers.get("content-type"));
|
|
462
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
463
|
+
await mkdir(IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
464
|
+
const localPath = resolvePath(IMAGE_DOWNLOAD_DIR, `${fileKey}${ext}`);
|
|
465
|
+
await writeFile(localPath, buffer);
|
|
466
|
+
return localPath;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function getOrDownloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
|
|
470
|
+
await loadImageCache();
|
|
471
|
+
const cached = imageCache.get(fileKey);
|
|
472
|
+
if (cached) return cached;
|
|
473
|
+
const localPath = await downloadImage(token, messageId, fileKey);
|
|
474
|
+
imageCache.set(fileKey, localPath);
|
|
475
|
+
await persistImageCache().catch((err) => {
|
|
476
|
+
console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
|
|
477
|
+
});
|
|
478
|
+
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
479
|
+
return localPath;
|
|
480
|
+
}
|
|
407
481
|
|
|
408
482
|
// ---------------------------------------------------------------------------
|
|
409
483
|
// Messaging
|
|
410
484
|
// ---------------------------------------------------------------------------
|
|
411
485
|
|
|
486
|
+
const DELAY_NOTICE_THRESHOLD_MS = 15 * 60 * 1000; // 15 分钟
|
|
487
|
+
|
|
488
|
+
/** 消息延迟超过阈值时生成提醒文本,否则返回 null */
|
|
489
|
+
export function formatDelayNotice(createTimeMs: number, nowMs?: number): string | null {
|
|
490
|
+
const now = nowMs ?? Date.now();
|
|
491
|
+
const delayMs = now - createTimeMs;
|
|
492
|
+
if (delayMs < DELAY_NOTICE_THRESHOLD_MS) return null;
|
|
493
|
+
|
|
494
|
+
const sendDate = new Date(createTimeMs);
|
|
495
|
+
const month = sendDate.getMonth() + 1;
|
|
496
|
+
const day = sendDate.getDate();
|
|
497
|
+
const hour = String(sendDate.getHours()).padStart(2, "0");
|
|
498
|
+
const min = String(sendDate.getMinutes()).padStart(2, "0");
|
|
499
|
+
const sendTimeStr = `${month}月${day}日 ${hour}:${min}`;
|
|
500
|
+
|
|
501
|
+
const totalMinutes = Math.floor(delayMs / 60000);
|
|
502
|
+
let delayStr: string;
|
|
503
|
+
if (totalMinutes < 60) {
|
|
504
|
+
delayStr = `${totalMinutes} 分钟`;
|
|
505
|
+
} else {
|
|
506
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
507
|
+
const mins = totalMinutes % 60;
|
|
508
|
+
if (hours < 24) {
|
|
509
|
+
delayStr = mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`;
|
|
510
|
+
} else {
|
|
511
|
+
const days = Math.floor(hours / 24);
|
|
512
|
+
const remainHours = hours % 24;
|
|
513
|
+
delayStr = remainHours > 0 ? `${days} 天 ${remainHours} 小时` : `${days} 天`;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达`;
|
|
518
|
+
}
|
|
519
|
+
|
|
412
520
|
export async function sendTextReply(
|
|
413
521
|
token: string,
|
|
414
522
|
chatId: string,
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* =================================================================
|
|
4
4
|
* Supported tools: Claude Code, Cursor, Codex (OpenAI).
|
|
5
5
|
*
|
|
6
|
-
* When a user sends "/new [tool]" to the bot:
|
|
6
|
+
* When a user sends "/new [tool]" to the bot (omitting tool uses the configured default Agent):
|
|
7
7
|
* 1. Create an AI tool session via the corresponding adapter, get session ID
|
|
8
8
|
* 2. Create a new Feishu group chat and add the user
|
|
9
9
|
* 3. Rename the group (name + description) to the session ID
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
getRecentDirs,
|
|
57
57
|
addRecentDir,
|
|
58
58
|
sessionPrefixForTool,
|
|
59
|
+
resolveDefaultAgentTool,
|
|
59
60
|
toolDisplayName,
|
|
60
61
|
ts,
|
|
61
62
|
} from "./config.ts";
|
|
@@ -64,6 +65,7 @@ import {
|
|
|
64
65
|
addReaction,
|
|
65
66
|
createGroupChat,
|
|
66
67
|
extractSessionInfo,
|
|
68
|
+
formatDelayNotice,
|
|
67
69
|
getChatInfo,
|
|
68
70
|
getTenantAccessToken,
|
|
69
71
|
recallMessage,
|
|
@@ -72,6 +74,7 @@ import {
|
|
|
72
74
|
setChatAvatar,
|
|
73
75
|
updateCardMessage,
|
|
74
76
|
updateChatInfo,
|
|
77
|
+
getOrDownloadImage,
|
|
75
78
|
sendRestartCard,
|
|
76
79
|
verifyAllPermissions,
|
|
77
80
|
reportPermissionResults,
|
|
@@ -124,7 +127,7 @@ function getInnerEvent(data: Evt): InnerEvent {
|
|
|
124
127
|
* 将飞书消息的原始 content JSON 结构转成可读文本,保留代码块等结构信息。
|
|
125
128
|
* 未知类型直接返回 JSON 原文,让 AI 自行理解。
|
|
126
129
|
*/
|
|
127
|
-
function formatMessageContent(message: { message_type?: string; content?: string }): string {
|
|
130
|
+
async function formatMessageContent(message: { message_id?: string; message_type?: string; content?: string }): Promise<string> {
|
|
128
131
|
const contentStr = message.content ?? "{}";
|
|
129
132
|
let content: Record<string, unknown>;
|
|
130
133
|
try { content = JSON.parse(contentStr); } catch { return ""; }
|
|
@@ -141,7 +144,21 @@ function formatMessageContent(message: { message_type?: string; content?: string
|
|
|
141
144
|
return formatPostContent(content);
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
|
|
147
|
+
if (message.message_type === "image") {
|
|
148
|
+
const imageKey = content.image_key as string | undefined;
|
|
149
|
+
const messageId = message.message_id;
|
|
150
|
+
if (!imageKey || !messageId) return contentStr;
|
|
151
|
+
try {
|
|
152
|
+
const token = await getTenantAccessToken();
|
|
153
|
+
const localPath = await getOrDownloadImage(token, messageId, imageKey);
|
|
154
|
+
return `[图片] ${localPath}`;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error(`[${ts()}] [IMAGE] download failed for ${imageKey}: ${(err as Error).message}`);
|
|
157
|
+
return `[图片: ${imageKey}]`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 其他类型(file, audio, media, sticker)直接给原始 JSON
|
|
145
162
|
return contentStr;
|
|
146
163
|
}
|
|
147
164
|
|
|
@@ -195,7 +212,7 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
195
212
|
}
|
|
196
213
|
if (!cmd) return null;
|
|
197
214
|
|
|
198
|
-
const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
|
|
215
|
+
const CMD_MAP: Record<string, string> = { stop: "/stop", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", forget: "/forget" };
|
|
199
216
|
let text = CMD_MAP[cmd] ?? "";
|
|
200
217
|
if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
|
|
201
218
|
const path = (action.value as Record<string, string>).path;
|
|
@@ -405,7 +422,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
405
422
|
);
|
|
406
423
|
|
|
407
424
|
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
408
|
-
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
425
|
+
setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
|
|
409
426
|
console.log(`${"=".repeat(60)}`);
|
|
410
427
|
return;
|
|
411
428
|
}
|
|
@@ -558,7 +575,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
558
575
|
tool: descriptionTool,
|
|
559
576
|
});
|
|
560
577
|
|
|
561
|
-
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
578
|
+
setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
|
|
562
579
|
|
|
563
580
|
await sendCardReply(
|
|
564
581
|
freshToken, chatId, `${toolLabel} Session Reset`,
|
|
@@ -721,8 +738,8 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
721
738
|
console.log(`${"=".repeat(60)}`);
|
|
722
739
|
console.log(` ChatCCC — Feishu Bot Bridge for Claude Code${modeTag}`);
|
|
723
740
|
console.log(`${"=".repeat(60)}`);
|
|
724
|
-
console.log(` Send "/new" to the bot to create a new group +
|
|
725
|
-
console.log(` In a
|
|
741
|
+
console.log(` Send "/new" to the bot to create a new group + ${toolDisplayName(resolveDefaultAgentTool())} session.`);
|
|
742
|
+
console.log(` In a session group, send any message to resume & prompt.`);
|
|
726
743
|
console.log(`${"=".repeat(60)}`);
|
|
727
744
|
|
|
728
745
|
console.log(`\n[启动 4/7] 向飞书开放平台申请 tenant_access_token …`);
|
|
@@ -782,7 +799,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
782
799
|
}
|
|
783
800
|
}
|
|
784
801
|
|
|
785
|
-
const text = formatMessageContent(message);
|
|
802
|
+
const text = await formatMessageContent(message);
|
|
786
803
|
const sender = event.sender;
|
|
787
804
|
const openId = sender?.sender_id?.open_id ?? "";
|
|
788
805
|
const chatId = message.chat_id ?? "";
|
|
@@ -802,6 +819,11 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
802
819
|
|
|
803
820
|
if (!text) return;
|
|
804
821
|
const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
|
|
822
|
+
const delayNotice = formatDelayNotice(msgTimestamp);
|
|
823
|
+
if (delayNotice) {
|
|
824
|
+
const delayToken = await getTenantAccessToken();
|
|
825
|
+
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
826
|
+
}
|
|
805
827
|
await handleCommand(text, chatId, openId, msgTimestamp);
|
|
806
828
|
} catch (err) {
|
|
807
829
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
@@ -861,7 +883,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
861
883
|
console.log("[启动 7/7] 已连接本地中继,可接收转发事件。\n");
|
|
862
884
|
printServiceRunningHint("local", `http://127.0.0.1:${CHATCCC_PORT}`);
|
|
863
885
|
});
|
|
864
|
-
ws.on("message", (raw: Buffer) => {
|
|
886
|
+
ws.on("message", async (raw: Buffer) => {
|
|
865
887
|
try {
|
|
866
888
|
const data = JSON.parse(raw.toString()) as Evt;
|
|
867
889
|
const action = parseCardAction(data);
|
|
@@ -874,7 +896,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
874
896
|
const event = getInnerEvent(data);
|
|
875
897
|
const message = event.message;
|
|
876
898
|
if (!message) return;
|
|
877
|
-
const text = formatMessageContent(message);
|
|
899
|
+
const text = await formatMessageContent(message);
|
|
878
900
|
const openId = event.sender?.sender_id?.open_id ?? "";
|
|
879
901
|
const chatId = message.chat_id ?? "";
|
|
880
902
|
appendChatLog(chatId, openId, text);
|
package/src/session.ts
CHANGED
|
@@ -251,7 +251,7 @@ export function accumulateBlockContent(
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// ---------------------------------------------------------------------------
|
|
254
|
-
//
|
|
254
|
+
// AI tool session management
|
|
255
255
|
// ---------------------------------------------------------------------------
|
|
256
256
|
|
|
257
257
|
/**
|
|
@@ -324,7 +324,7 @@ export async function resumeAndPrompt(
|
|
|
324
324
|
});
|
|
325
325
|
const myGen = sessionGen;
|
|
326
326
|
|
|
327
|
-
setChatAvatar(token, chatId, tool, "busy").catch(() => {});
|
|
327
|
+
setChatAvatar(token, chatId, tool, "busy").catch(() => {});
|
|
328
328
|
|
|
329
329
|
const now = Date.now();
|
|
330
330
|
const existingInfo = sessionInfoMap.get(chatId);
|
|
@@ -466,7 +466,7 @@ export async function resumeAndPrompt(
|
|
|
466
466
|
if (!cEntry || cEntry.gen !== myGen) return;
|
|
467
467
|
const wasStopped = cEntry.stopped;
|
|
468
468
|
chatSessionMap.delete(chatId);
|
|
469
|
-
setChatAvatar(token, chatId, tool, "idle").catch(() => {});
|
|
469
|
+
setChatAvatar(token, chatId, tool, "idle").catch(() => {});
|
|
470
470
|
|
|
471
471
|
const finalCardContent = state.accumulatedContent || " ";
|
|
472
472
|
if (cardId) {
|
package/src/web-ui.ts
CHANGED
|
@@ -27,10 +27,10 @@ interface AppConfig {
|
|
|
27
27
|
feishu?: { appId?: string; appSecret?: string };
|
|
28
28
|
port?: number;
|
|
29
29
|
gitTimeoutSeconds?: number;
|
|
30
|
-
claude?: { enabled?: boolean; model?: string; effort?: string; apiKey?: string; baseUrl?: string };
|
|
30
|
+
claude?: { enabled?: boolean; defaultAgent?: boolean; model?: string; effort?: string; apiKey?: string; baseUrl?: string };
|
|
31
31
|
// `command` 是已废弃的旧字段名,保留只读以兼容升级前的 config.json
|
|
32
|
-
cursor?: { enabled?: boolean; path?: string; command?: string; model?: string };
|
|
33
|
-
codex?: { enabled?: boolean; path?: string; command?: string; model?: string; effort?: string };
|
|
32
|
+
cursor?: { enabled?: boolean; defaultAgent?: boolean; path?: string; command?: string; model?: string };
|
|
33
|
+
codex?: { enabled?: boolean; defaultAgent?: boolean; path?: string; command?: string; model?: string; effort?: string };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
@@ -343,6 +343,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
343
343
|
} else if (key === "CHATCCC_CLAUDE_ENABLED") {
|
|
344
344
|
result.claude = result.claude || {};
|
|
345
345
|
(result.claude as Record<string, unknown>).enabled = val === true || val === "true";
|
|
346
|
+
} else if (key === "CHATCCC_CLAUDE_DEFAULT_AGENT") {
|
|
347
|
+
result.claude = result.claude || {};
|
|
348
|
+
(result.claude as Record<string, unknown>).defaultAgent = val === true || val === "true";
|
|
346
349
|
} else if (key === "CHATCCC_CURSOR_PATH") {
|
|
347
350
|
result.cursor = result.cursor || {};
|
|
348
351
|
(result.cursor as Record<string, unknown>).path = val;
|
|
@@ -352,6 +355,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
352
355
|
} else if (key === "CHATCCC_CURSOR_ENABLED") {
|
|
353
356
|
result.cursor = result.cursor || {};
|
|
354
357
|
(result.cursor as Record<string, unknown>).enabled = val === true || val === "true";
|
|
358
|
+
} else if (key === "CHATCCC_CURSOR_DEFAULT_AGENT") {
|
|
359
|
+
result.cursor = result.cursor || {};
|
|
360
|
+
(result.cursor as Record<string, unknown>).defaultAgent = val === true || val === "true";
|
|
355
361
|
} else if (key === "CHATCCC_CODEX_PATH") {
|
|
356
362
|
result.codex = result.codex || {};
|
|
357
363
|
(result.codex as Record<string, unknown>).path = val;
|
|
@@ -364,6 +370,9 @@ function unflattenConfig(flat: Record<string, unknown>): Record<string, unknown>
|
|
|
364
370
|
} else if (key === "CHATCCC_CODEX_ENABLED") {
|
|
365
371
|
result.codex = result.codex || {};
|
|
366
372
|
(result.codex as Record<string, unknown>).enabled = val === true || val === "true";
|
|
373
|
+
} else if (key === "CHATCCC_CODEX_DEFAULT_AGENT") {
|
|
374
|
+
result.codex = result.codex || {};
|
|
375
|
+
(result.codex as Record<string, unknown>).defaultAgent = val === true || val === "true";
|
|
367
376
|
}
|
|
368
377
|
}
|
|
369
378
|
return result;
|
|
@@ -519,6 +528,8 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
519
528
|
.agent-toggle:checked{background:#3b82f6}
|
|
520
529
|
.agent-toggle::before{content:"";position:absolute;width:18px;height:18px;border-radius:50%;background:#fff;top:2px;left:2px;transition:left .2s;box-shadow:0 1px 2px rgba(0,0,0,.2)}
|
|
521
530
|
.agent-toggle:checked::before{left:18px}
|
|
531
|
+
.agent-default-row{display:flex;align-items:center;gap:8px;font-size:13px;color:#334155;margin:-4px 0 12px}
|
|
532
|
+
.agent-default-row input{margin:0}
|
|
522
533
|
.agent-body{flex:1}
|
|
523
534
|
.agent-body fieldset{border:none;padding:0;margin:0}
|
|
524
535
|
.agent-body fieldset[disabled]{opacity:.45;pointer-events:none}
|
|
@@ -600,6 +611,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
600
611
|
<div class="desc">Anthropic Claude Agent SDK<br>官方/第三方 API 均可</div>
|
|
601
612
|
</div>
|
|
602
613
|
</div>
|
|
614
|
+
<label class="agent-default-row">
|
|
615
|
+
<input type="checkbox" id="agent-default-claude" onchange="onDefaultAgentToggle('claude', this.checked)">
|
|
616
|
+
设为默认 Agent
|
|
617
|
+
</label>
|
|
603
618
|
<fieldset class="agent-body" id="agent-body-claude" disabled>
|
|
604
619
|
<div class="form-group" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px">
|
|
605
620
|
<label style="margin-bottom:8px;font-weight:600;color:#334155">API 来源</label>
|
|
@@ -649,6 +664,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
649
664
|
<div class="desc">Cursor Agent CLI<br>需安装 Cursor</div>
|
|
650
665
|
</div>
|
|
651
666
|
</div>
|
|
667
|
+
<label class="agent-default-row">
|
|
668
|
+
<input type="checkbox" id="agent-default-cursor" onchange="onDefaultAgentToggle('cursor', this.checked)">
|
|
669
|
+
设为默认 Agent
|
|
670
|
+
</label>
|
|
652
671
|
<fieldset class="agent-body" id="agent-body-cursor" disabled>
|
|
653
672
|
<div class="form-group">
|
|
654
673
|
<label>CLI 路径</label>
|
|
@@ -673,6 +692,10 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
673
692
|
<div class="desc">OpenAI Codex CLI<br>需安装并登录</div>
|
|
674
693
|
</div>
|
|
675
694
|
</div>
|
|
695
|
+
<label class="agent-default-row">
|
|
696
|
+
<input type="checkbox" id="agent-default-codex" onchange="onDefaultAgentToggle('codex', this.checked)">
|
|
697
|
+
设为默认 Agent
|
|
698
|
+
</label>
|
|
676
699
|
<fieldset class="agent-body" id="agent-body-codex" disabled>
|
|
677
700
|
<div class="form-group">
|
|
678
701
|
<label>CLI 路径</label>
|
|
@@ -741,6 +764,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
741
764
|
<div class="config-row" id="cfg-row-CLAUDE_BASE_URL"><span class="key">Base URL</span><span class="val" id="cfg-CLAUDE_BASE_URL">-</span></div>
|
|
742
765
|
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-ANTHROPIC_MODEL">-</span></div>
|
|
743
766
|
<div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-ANTHROPIC_EFFORT">-</span></div>
|
|
767
|
+
<label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-claude" onchange="setDashboardDefaultAgent('claude', this.checked)"> 设为默认 Agent</label>
|
|
744
768
|
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('claude')">编辑</button>
|
|
745
769
|
</div>
|
|
746
770
|
</details>
|
|
@@ -750,6 +774,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
750
774
|
<div class="section-detail">
|
|
751
775
|
<div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CURSOR_PATH">-</span></div>
|
|
752
776
|
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CURSOR_MODEL">-</span></div>
|
|
777
|
+
<label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-cursor" onchange="setDashboardDefaultAgent('cursor', this.checked)"> 设为默认 Agent</label>
|
|
753
778
|
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('cursor')">编辑</button>
|
|
754
779
|
</div>
|
|
755
780
|
</details>
|
|
@@ -760,6 +785,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
760
785
|
<div class="config-row"><span class="key">CLI 路径</span><span class="val" id="cfg-CODEX_PATH">-</span></div>
|
|
761
786
|
<div class="config-row"><span class="key">模型</span><span class="val" id="cfg-CODEX_MODEL">-</span></div>
|
|
762
787
|
<div class="config-row"><span class="key">Effort</span><span class="val" id="cfg-CODEX_EFFORT">-</span></div>
|
|
788
|
+
<label class="agent-default-row" style="margin-top:10px"><input type="checkbox" id="dash-default-codex" onchange="setDashboardDefaultAgent('codex', this.checked)"> 设为默认 Agent</label>
|
|
763
789
|
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('codex')">编辑</button>
|
|
764
790
|
</div>
|
|
765
791
|
</details>
|
|
@@ -791,6 +817,7 @@ let state = {
|
|
|
791
817
|
view: 'loading',
|
|
792
818
|
// 三个 Agent 各自的启用开关;初始全 false,renderStep2() 会按已存在 config 自动打开
|
|
793
819
|
agentsEnabled: { claude: false, cursor: false, codex: false },
|
|
820
|
+
defaultAgent: 'claude',
|
|
794
821
|
wizardStep: 1,
|
|
795
822
|
config: {},
|
|
796
823
|
running: false,
|
|
@@ -855,6 +882,51 @@ function onAgentToggle(agent, enabled) {
|
|
|
855
882
|
if (card) card.classList.toggle('enabled', enabled);
|
|
856
883
|
var body = document.getElementById('agent-body-' + agent);
|
|
857
884
|
if (body) body.disabled = !enabled;
|
|
885
|
+
if (!enabled && state.defaultAgent === agent) {
|
|
886
|
+
state.defaultAgent = firstEnabledAgent();
|
|
887
|
+
} else if (enabled && !state.defaultAgent) {
|
|
888
|
+
state.defaultAgent = agent;
|
|
889
|
+
}
|
|
890
|
+
updateDefaultAgentToggles();
|
|
891
|
+
updateStep2NextBtn();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function firstEnabledAgent() {
|
|
895
|
+
if (state.agentsEnabled.claude) return 'claude';
|
|
896
|
+
if (state.agentsEnabled.cursor) return 'cursor';
|
|
897
|
+
if (state.agentsEnabled.codex) return 'codex';
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn) {
|
|
902
|
+
if (claudeOn && c.claude && c.claude.defaultAgent === true) return 'claude';
|
|
903
|
+
if (cursorOn && c.cursor && c.cursor.defaultAgent === true) return 'cursor';
|
|
904
|
+
if (codexOn && c.codex && c.codex.defaultAgent === true) return 'codex';
|
|
905
|
+
if (claudeOn) return 'claude';
|
|
906
|
+
if (cursorOn) return 'cursor';
|
|
907
|
+
if (codexOn) return 'codex';
|
|
908
|
+
return 'claude';
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function updateDefaultAgentToggles() {
|
|
912
|
+
['claude','cursor','codex'].forEach(function(agent){
|
|
913
|
+
var el = document.getElementById('agent-default-' + agent);
|
|
914
|
+
if (el) {
|
|
915
|
+
el.checked = state.defaultAgent === agent;
|
|
916
|
+
el.disabled = !state.agentsEnabled[agent];
|
|
917
|
+
}
|
|
918
|
+
var dashEl = document.getElementById('dash-default-' + agent);
|
|
919
|
+
if (dashEl) dashEl.checked = state.defaultAgent === agent;
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function onDefaultAgentToggle(agent, enabled) {
|
|
924
|
+
if (enabled) {
|
|
925
|
+
state.defaultAgent = agent;
|
|
926
|
+
} else if (state.defaultAgent === agent) {
|
|
927
|
+
state.defaultAgent = null;
|
|
928
|
+
}
|
|
929
|
+
updateDefaultAgentToggles();
|
|
858
930
|
updateStep2NextBtn();
|
|
859
931
|
}
|
|
860
932
|
|
|
@@ -878,6 +950,10 @@ function updateStep2NextBtn() {
|
|
|
878
950
|
if (state.agentsEnabled.claude && isClaudeFieldsValid()) validCount++;
|
|
879
951
|
if (state.agentsEnabled.cursor) validCount++;
|
|
880
952
|
if (state.agentsEnabled.codex) validCount++;
|
|
953
|
+
if (validCount > 0 && !state.defaultAgent) {
|
|
954
|
+
state.defaultAgent = firstEnabledAgent();
|
|
955
|
+
updateDefaultAgentToggles();
|
|
956
|
+
}
|
|
881
957
|
btn.disabled = validCount === 0;
|
|
882
958
|
}
|
|
883
959
|
|
|
@@ -1018,12 +1094,14 @@ function renderStep2() {
|
|
|
1018
1094
|
var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
|
|
1019
1095
|
var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
|
|
1020
1096
|
var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
|
|
1097
|
+
state.defaultAgent = resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn);
|
|
1021
1098
|
document.getElementById('agent-enable-claude').checked = claudeOn;
|
|
1022
1099
|
document.getElementById('agent-enable-cursor').checked = cursorOn;
|
|
1023
1100
|
document.getElementById('agent-enable-codex').checked = codexOn;
|
|
1024
1101
|
onAgentToggle('claude', claudeOn);
|
|
1025
1102
|
onAgentToggle('cursor', cursorOn);
|
|
1026
1103
|
onAgentToggle('codex', codexOn);
|
|
1104
|
+
updateDefaultAgentToggles();
|
|
1027
1105
|
|
|
1028
1106
|
// Cursor path placeholder/hint:把已探测到的路径显示为占位
|
|
1029
1107
|
var hint = document.getElementById('cursor-path-hint');
|
|
@@ -1060,6 +1138,12 @@ function collectAllFields() {
|
|
|
1060
1138
|
vars.CHATCCC_CLAUDE_ENABLED = !!state.agentsEnabled.claude;
|
|
1061
1139
|
vars.CHATCCC_CURSOR_ENABLED = !!state.agentsEnabled.cursor;
|
|
1062
1140
|
vars.CHATCCC_CODEX_ENABLED = !!state.agentsEnabled.codex;
|
|
1141
|
+
if (!state.defaultAgent || !state.agentsEnabled[state.defaultAgent]) {
|
|
1142
|
+
state.defaultAgent = firstEnabledAgent();
|
|
1143
|
+
}
|
|
1144
|
+
vars.CHATCCC_CLAUDE_DEFAULT_AGENT = state.defaultAgent === 'claude';
|
|
1145
|
+
vars.CHATCCC_CURSOR_DEFAULT_AGENT = state.defaultAgent === 'cursor';
|
|
1146
|
+
vars.CHATCCC_CODEX_DEFAULT_AGENT = state.defaultAgent === 'codex';
|
|
1063
1147
|
if (state.agentsEnabled.claude) {
|
|
1064
1148
|
AGENT_FIELDS.claude.forEach(function(key){
|
|
1065
1149
|
var el = document.getElementById('field-' + key);
|
|
@@ -1099,6 +1183,9 @@ function renderStep3() {
|
|
|
1099
1183
|
if (state.agentsEnabled.codex) enabledList.push('codex');
|
|
1100
1184
|
if (enabledList.length === 0) {
|
|
1101
1185
|
lines.push('<div style="color:#ef4444">未启用任何 AI Agent</div>');
|
|
1186
|
+
} else {
|
|
1187
|
+
var defaultLabel = state.defaultAgent === 'cursor' ? 'Cursor' : state.defaultAgent === 'codex' ? 'Codex' : 'Claude Code';
|
|
1188
|
+
lines.push('<div class="config-row"><span class="key">/new 默认 Agent</span><span class="val">' + defaultLabel + '</span></div>');
|
|
1102
1189
|
}
|
|
1103
1190
|
enabledList.forEach(function(t){
|
|
1104
1191
|
if (t === 'claude') {
|
|
@@ -1146,6 +1233,33 @@ async function saveConfig(vars) {
|
|
|
1146
1233
|
return result.ok;
|
|
1147
1234
|
}
|
|
1148
1235
|
|
|
1236
|
+
async function setDashboardDefaultAgent(agent, enabled) {
|
|
1237
|
+
if (!enabled) {
|
|
1238
|
+
updateDefaultAgentToggles();
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
state.defaultAgent = agent;
|
|
1242
|
+
var vars = {
|
|
1243
|
+
CHATCCC_CLAUDE_DEFAULT_AGENT: agent === 'claude',
|
|
1244
|
+
CHATCCC_CURSOR_DEFAULT_AGENT: agent === 'cursor',
|
|
1245
|
+
CHATCCC_CODEX_DEFAULT_AGENT: agent === 'codex'
|
|
1246
|
+
};
|
|
1247
|
+
var result = await api('/api/config', 'POST', { vars: vars, claudeApiMode: detectClaudeApiModeFromConfig(state.config && state.config.claude) });
|
|
1248
|
+
if (result.ok) {
|
|
1249
|
+
state.config.claude = state.config.claude || {};
|
|
1250
|
+
state.config.cursor = state.config.cursor || {};
|
|
1251
|
+
state.config.codex = state.config.codex || {};
|
|
1252
|
+
state.config.claude.defaultAgent = agent === 'claude';
|
|
1253
|
+
state.config.cursor.defaultAgent = agent === 'cursor';
|
|
1254
|
+
state.config.codex.defaultAgent = agent === 'codex';
|
|
1255
|
+
updateDefaultAgentToggles();
|
|
1256
|
+
toast('默认 Agent 已更新');
|
|
1257
|
+
} else {
|
|
1258
|
+
toast('保存失败: ' + (result.error || '未知错误'), 'error');
|
|
1259
|
+
updateDefaultAgentToggles();
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1149
1263
|
async function saveAndStart() {
|
|
1150
1264
|
var vars = collectAllFields();
|
|
1151
1265
|
if (!vars.CHATCCC_APP_ID || !vars.CHATCCC_APP_SECRET) {
|
|
@@ -1233,9 +1347,12 @@ function updateDashboardUI() {
|
|
|
1233
1347
|
var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
|
|
1234
1348
|
var cursorOn = isAgentEnabled(c.cursor, CURSOR_FALLBACK_KEYS);
|
|
1235
1349
|
var codexOn = isAgentEnabled(c.codex, CODEX_FALLBACK_KEYS);
|
|
1350
|
+
state.agentsEnabled = { claude: claudeOn, cursor: cursorOn, codex: codexOn };
|
|
1351
|
+
state.defaultAgent = resolveDefaultAgentFromConfig(c, claudeOn, cursorOn, codexOn);
|
|
1236
1352
|
document.getElementById('dash-claude').style.display = claudeOn ? '' : 'none';
|
|
1237
1353
|
document.getElementById('dash-cursor').style.display = cursorOn ? '' : 'none';
|
|
1238
1354
|
document.getElementById('dash-codex').style.display = codexOn ? '' : 'none';
|
|
1355
|
+
updateDefaultAgentToggles();
|
|
1239
1356
|
// 三个都未启用时给一个空态提示,引导用户去配置向导启用
|
|
1240
1357
|
var emptyHint = document.getElementById('dash-no-agent-hint');
|
|
1241
1358
|
if (emptyHint) emptyHint.style.display = (!claudeOn && !cursorOn && !codexOn) ? '' : 'none';
|
|
@@ -1588,4 +1705,4 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
|
|
|
1588
1705
|
console.log("");
|
|
1589
1706
|
openInBrowser(url);
|
|
1590
1707
|
});
|
|
1591
|
-
}
|
|
1708
|
+
}
|