chatccc 0.2.96 → 0.2.98

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.96",
3
+ "version": "0.2.98",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
2
+ import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
3
3
  import type { FeishuPlatform } from "../feishu-platform.ts";
4
4
 
5
5
  const realPlatform = getPlatform();
@@ -17,6 +17,7 @@ describe("feishu-platform", () => {
17
17
  sendTextReply: async () => true,
18
18
  sendCardReply: async () => true,
19
19
  sendRawCard: async () => true,
20
+ sendPostMessage: async () => true,
20
21
  sendImageReply: async () => true,
21
22
  sendFileReply: async () => true,
22
23
  addReaction: async () => {},
@@ -31,6 +31,11 @@ export interface UnifiedTextBlock {
31
31
  * 由 pickFinalReply 在两者之间挑选。
32
32
  *
33
33
  * 对没有 partial/final 双轨的适配器(如 Claude SDK),永远不会 emit 此类型。
34
+ *
35
+ * 命名注意:此处的 "final" 指"完整/最终版本"(与 partial delta 相对),
36
+ * 不等于 stream-state / AccumulatorState 中的 finalReply / finalText。
37
+ * 后者命名含 "final" 但实际语义是"本轮所有文本的累积",属历史遗留命名,
38
+ * 详见 session.ts 的 AccumulatorState 注释和 stream-state.ts 的 StreamState 注释。
34
39
  */
35
40
  export interface UnifiedTextFinalBlock {
36
41
  type: "text_final";
@@ -150,9 +150,10 @@ function spawnCodex(
150
150
  args: string[],
151
151
  cwd?: string,
152
152
  stdinText?: string,
153
+ modelOverride?: string,
153
154
  ): ChildProcess {
154
155
  const allArgs = [...args];
155
- const model = resolveCodexModel();
156
+ const model = modelOverride ?? resolveCodexModel();
156
157
  if (model) {
157
158
  // 把 -m 插在 exec 后面、其他参数前面
158
159
  const execIdx = allArgs.indexOf("exec");
@@ -222,9 +223,11 @@ class CodexAdapter implements ToolAdapter {
222
223
  readonly displayName = "Codex";
223
224
  readonly sessionDescPrefix = "Codex Session:";
224
225
  private metaStore: CodexSessionMetaStore;
226
+ private modelOverride: string | undefined;
225
227
 
226
- constructor(metaStore: CodexSessionMetaStore) {
228
+ constructor(metaStore: CodexSessionMetaStore, modelOverride?: string) {
227
229
  this.metaStore = metaStore;
230
+ this.modelOverride = modelOverride;
228
231
  }
229
232
 
230
233
  // createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
@@ -251,7 +254,7 @@ class CodexAdapter implements ToolAdapter {
251
254
  ? [...CODEX_BASE_ARGS, "-C", cwd, "-"]
252
255
  : [...CODEX_BASE_ARGS, "resume", threadId, "-"];
253
256
 
254
- const proc = spawnCodex(args, cwd, userText);
257
+ const proc = spawnCodex(args, cwd, userText, this.modelOverride);
255
258
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
256
259
 
257
260
  // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
@@ -304,6 +307,8 @@ class CodexAdapter implements ToolAdapter {
304
307
 
305
308
  export interface CreateCodexAdapterOptions {
306
309
  metaStore?: CodexSessionMetaStore;
310
+ /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
311
+ model?: string;
307
312
  }
308
313
 
309
314
  export function createCodexAdapter(
@@ -311,5 +316,6 @@ export function createCodexAdapter(
311
316
  ): ToolAdapter {
312
317
  return new CodexAdapter(
313
318
  options.metaStore ?? defaultCodexSessionMetaStore,
319
+ options.model,
314
320
  );
315
321
  }
@@ -260,8 +260,18 @@ function spawnAgent(
260
260
  extraArgs: string[],
261
261
  cwd?: string,
262
262
  stdinText?: string,
263
+ modelOverride?: string,
263
264
  ): ChildProcess {
264
- const allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
265
+ let allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
266
+ if (modelOverride) {
267
+ // 替换全局 --model 为 per-session override
268
+ const modelIdx = allArgs.findIndex((a, i) => a === "--model" && i + 1 < allArgs.length);
269
+ if (modelIdx >= 0) {
270
+ allArgs[modelIdx + 1] = modelOverride;
271
+ } else {
272
+ allArgs.push("--model", modelOverride);
273
+ }
274
+ }
265
275
  const proc = spawn(CURSOR_AGENT_COMMAND, allArgs, {
266
276
  cwd,
267
277
  stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
@@ -324,13 +334,15 @@ class CursorAdapter implements ToolAdapter {
324
334
  readonly sessionDescPrefix = "Cursor Session:";
325
335
  private activeProcs = new Set<ChildProcess>();
326
336
  private metaStore: CursorSessionMetaStore;
337
+ private modelOverride: string | undefined;
327
338
 
328
- constructor(metaStore: CursorSessionMetaStore) {
339
+ constructor(metaStore: CursorSessionMetaStore, modelOverride?: string) {
329
340
  this.metaStore = metaStore;
341
+ this.modelOverride = modelOverride;
330
342
  }
331
343
 
332
344
  async createSession(cwd: string): Promise<CreateSessionResult> {
333
- const proc = spawnAgent(["ok"], cwd);
345
+ const proc = spawnAgent(["ok"], cwd, undefined, this.modelOverride);
334
346
  this.activeProcs.add(proc);
335
347
 
336
348
  for await (const msg of readJsonLines(proc, undefined, "createSession")) {
@@ -358,7 +370,7 @@ class CursorAdapter implements ToolAdapter {
358
370
  options?: ToolPromptOptions,
359
371
  ): AsyncIterable<UnifiedStreamMessage> {
360
372
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
361
- const proc = spawnAgent(["--resume", sessionId], cwd, userText);
373
+ const proc = spawnAgent(["--resume", sessionId], cwd, userText, this.modelOverride);
362
374
  this.activeProcs.add(proc);
363
375
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
364
376
 
@@ -414,10 +426,12 @@ class CursorAdapter implements ToolAdapter {
414
426
  export interface CreateCursorAdapterOptions {
415
427
  /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
416
428
  metaStore?: CursorSessionMetaStore;
429
+ /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 cursor.model */
430
+ model?: string;
417
431
  }
418
432
 
419
433
  export function createCursorAdapter(
420
434
  options: CreateCursorAdapterOptions = {},
421
435
  ): ToolAdapter {
422
- return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore);
436
+ return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore, options.model);
423
437
  }
package/src/cards.ts CHANGED
@@ -408,58 +408,41 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
408
408
  });
409
409
  }
410
410
 
411
- export interface ModelSwitchOption {
412
- label: string;
413
- model: string;
414
- }
415
-
416
411
  /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
417
412
  export function buildModelCard(
418
413
  currentModel: string,
419
- available: ModelSwitchOption[],
414
+ models: string[],
415
+ tool?: string,
420
416
  ): string {
417
+ const toolLabel = tool ? ` (${tool})` : "";
421
418
  const currentLine = currentModel
422
419
  ? `**当前模型:** \`${currentModel}\``
423
420
  : "**当前模型:** 未指定";
424
- const hasPro = available.some(o => o.label === "pro");
425
- const hasFlash = available.some(o => o.label === "flash");
426
421
 
427
422
  const lines: string[] = [currentLine];
428
- if (available.length > 0) {
423
+ if (models.length > 0) {
429
424
  lines.push("", "**可切换模型:**");
430
- for (const opt of available) {
431
- lines.push(`- ${opt.label}: \`${opt.model}\``);
425
+ for (const m of models) {
426
+ lines.push(`- \`${m}\``);
432
427
  }
428
+ lines.push("", "点击按钮切换模型,或输入 `/model clear` 恢复默认");
433
429
  } else {
434
- lines.push("", "没有可切换的模型。");
430
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
435
431
  }
436
432
 
437
- const buttons: { text: string; value: string; type?: "primary" | "default" | "danger" }[] = [];
438
- if (hasPro) {
433
+ const buttons: ButtonDef[] = [];
434
+ for (const m of models.slice(0, 20)) {
435
+ const shortName = m.includes("/") ? m.slice(m.lastIndexOf("/") + 1) : m;
439
436
  buttons.push({
440
- text: "/model pro",
441
- value: JSON.stringify({ action: "model_pro" }),
437
+ text: `/model ${shortName}`,
438
+ value: JSON.stringify({ cmd: `/model ${m}` }),
442
439
  type: "primary",
443
440
  });
444
441
  }
445
- if (hasFlash) {
446
- buttons.push({
447
- text: "/model flash",
448
- value: JSON.stringify({ action: "model_flash" }),
449
- type: "primary",
450
- });
451
- }
452
- if (!hasPro && !hasFlash) {
453
- buttons.push({
454
- text: "/model clear",
455
- value: JSON.stringify({ action: "model_clear" }),
456
- type: "default",
457
- });
458
- }
459
442
 
460
443
  return JSON.stringify({
461
444
  config: { wide_screen_mode: true },
462
- header: { template: "blue", title: { content: "模型切换", tag: "plain_text" } },
445
+ header: { template: "blue", title: { content: `模型切换${toolLabel}`, tag: "plain_text" } },
463
446
  elements: [
464
447
  { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
465
448
  { tag: "hr" },
package/src/config.ts CHANGED
@@ -120,6 +120,25 @@ export interface AppConfig {
120
120
  export type AgentTool = "claude" | "cursor" | "codex";
121
121
  export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
122
122
 
123
+ /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
124
+ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
125
+ const seen = new Set<string>();
126
+ const collect = (v: unknown) => {
127
+ if (typeof v === "string" && v.trim()) seen.add(v.trim());
128
+ };
129
+
130
+ if (tool === "claude") {
131
+ collect(cfg.claude.model);
132
+ collect(cfg.claude.subagentModel);
133
+ } else if (tool === "cursor") {
134
+ collect(cfg.cursor.model);
135
+ } else if (tool === "codex") {
136
+ collect(cfg.codex.model);
137
+ }
138
+
139
+ return Array.from(seen).slice(0, 100);
140
+ }
141
+
123
142
  const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
124
143
  const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
125
144
 
package/src/feishu-api.ts CHANGED
@@ -532,15 +532,92 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
532
532
  return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
533
533
  }
534
534
 
535
+ /**
536
+ * 检测文本中的 markdown 表格,用代码块包裹。
537
+ *
538
+ * 飞书 markdown 渲染对表格支持不稳定(列对齐易错乱),用代码块包裹可保证
539
+ * 等宽字体显示、列对齐正确。只处理不在已有代码块内的表格。
540
+ *
541
+ * 检测规则:至少两行连续的 "|col|col|" 模式,其中必须包含一行分隔行
542
+ * (如 |---|----|),避免将单行含 | 的普通文本误判为表格。
543
+ */
544
+ function wrapMarkdownTables(text: string): string {
545
+ const lines = text.split("\n");
546
+ const result: string[] = [];
547
+ let inCodeBlock = false;
548
+ let tableRows: string[] = [];
549
+ let foundSeparator = false;
550
+
551
+ const isTableRow = (line: string): boolean =>
552
+ /^\s*\|.+\|/.test(line);
553
+
554
+ const isSeparatorRow = (line: string): boolean =>
555
+ /^\s*\|[\s\-:]+\|/.test(line) && /-/.test(line);
556
+
557
+ const flushTable = (): void => {
558
+ if (foundSeparator && tableRows.length >= 2) {
559
+ result.push("```");
560
+ result.push(...tableRows);
561
+ result.push("```");
562
+ } else {
563
+ result.push(...tableRows);
564
+ }
565
+ tableRows = [];
566
+ foundSeparator = false;
567
+ };
568
+
569
+ for (const line of lines) {
570
+ const trimmed = line.trim();
571
+
572
+ if (trimmed.startsWith("```")) {
573
+ flushTable();
574
+ inCodeBlock = !inCodeBlock;
575
+ result.push(line);
576
+ continue;
577
+ }
578
+
579
+ if (inCodeBlock) {
580
+ result.push(line);
581
+ continue;
582
+ }
583
+
584
+ if (!foundSeparator) {
585
+ if (isTableRow(line)) {
586
+ tableRows.push(line);
587
+ } else if (tableRows.length > 0 && isSeparatorRow(line)) {
588
+ tableRows.push(line);
589
+ foundSeparator = true;
590
+ } else {
591
+ if (tableRows.length > 0) {
592
+ result.push(...tableRows);
593
+ tableRows = [];
594
+ }
595
+ result.push(line);
596
+ }
597
+ } else {
598
+ if (isTableRow(line)) {
599
+ tableRows.push(line);
600
+ } else {
601
+ flushTable();
602
+ result.push(line);
603
+ }
604
+ }
605
+ }
606
+
607
+ flushTable();
608
+ return result.join("\n");
609
+ }
610
+
535
611
  export async function sendTextReply(
536
612
  token: string,
537
613
  chatId: string,
538
614
  text: string
539
615
  ): Promise<boolean> {
540
616
  const safeText = applyPrivacy(text);
617
+ const wrappedText = wrapMarkdownTables(safeText);
541
618
  const card = JSON.stringify({
542
619
  config: { wide_screen_mode: true },
543
- elements: [{ tag: "markdown", content: safeText }],
620
+ elements: [{ tag: "markdown", content: wrappedText }],
544
621
  });
545
622
  try {
546
623
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -915,3 +992,51 @@ export async function updateCardMessage(token: string, messageId: string, conten
915
992
  }
916
993
  return true;
917
994
  }
995
+
996
+ /**
997
+ * 发送飞书 post 类型消息(非卡片富文本消息)。
998
+ *
999
+ * post 消息支持 table、text、a、at、img 等富文本元素,
1000
+ * 与 interactive 卡片消息不同,post 直接在消息流中渲染。
1001
+ *
1002
+ * @param postContent 飞书 post content 格式的二维数组,每个元素是一个 paragraph
1003
+ */
1004
+ export async function sendPostMessage(
1005
+ token: string,
1006
+ chatId: string,
1007
+ title: string,
1008
+ postContent: unknown[][],
1009
+ ): Promise<boolean> {
1010
+ const content = JSON.stringify({
1011
+ post: {
1012
+ zh_cn: {
1013
+ title,
1014
+ content: postContent,
1015
+ },
1016
+ },
1017
+ });
1018
+ try {
1019
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
1020
+ method: "POST",
1021
+ headers: {
1022
+ Authorization: `Bearer ${token}`,
1023
+ "Content-Type": "application/json",
1024
+ },
1025
+ body: JSON.stringify({
1026
+ receive_id: chatId,
1027
+ msg_type: "post",
1028
+ content,
1029
+ }),
1030
+ });
1031
+ const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
1032
+ if (data.code !== 0) {
1033
+ console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
1034
+ return false;
1035
+ }
1036
+ console.log(`[${ts()}] [SEND] post OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
1037
+ return true;
1038
+ } catch (err) {
1039
+ console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} ${(err as Error).message}`);
1040
+ return false;
1041
+ }
1042
+ }
@@ -19,6 +19,7 @@ export interface FeishuPlatform {
19
19
  sendTextReply: typeof realApi.sendTextReply;
20
20
  sendCardReply: typeof realApi.sendCardReply;
21
21
  sendRawCard: typeof realApi.sendRawCard;
22
+ sendPostMessage: typeof realApi.sendPostMessage;
22
23
  sendImageReply: typeof realApi.sendImageReply;
23
24
  sendFileReply: typeof realApi.sendFileReply;
24
25
  addReaction: typeof realApi.addReaction;
@@ -134,6 +135,10 @@ export function formatDelayNotice(...args: Parameters<typeof realApi.formatDelay
134
135
  return _impl.formatDelayNotice(...args);
135
136
  }
136
137
 
138
+ export function sendPostMessage(...args: Parameters<typeof realApi.sendPostMessage>): ReturnType<typeof realApi.sendPostMessage> {
139
+ return _impl.sendPostMessage(...args);
140
+ }
141
+
137
142
  export function sendRestartCard(...args: Parameters<typeof realApi.sendRestartCard>): ReturnType<typeof realApi.sendRestartCard> {
138
143
  return _impl.sendRestartCard(...args);
139
144
  }
package/src/index.ts CHANGED
@@ -285,12 +285,14 @@ function parseCardAction(data: unknown): CardActionResult | null {
285
285
  }
286
286
  if (!cmd) return null;
287
287
 
288
- const CMD_MAP: Record<string, string> = { stop: "/stop", cancel: "/cancel", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", newh: "/newh", model_pro: "/model pro", model_flash: "/model flash", model_clear: "/model clear" };
288
+ const CMD_MAP: Record<string, string> = { stop: "/stop", cancel: "/cancel", new: "/new", "new claude": "/new claude", "new cursor": "/new cursor", "new codex": "/new codex", restart: "/restart", status: "/status", cd: "/cd", sessions: "/sessions", newh: "/newh" };
289
289
  let text = CMD_MAP[cmd] ?? "";
290
290
  if (cmd === "cd" && typeof action.value === "object" && action.value !== null) {
291
291
  const path = (action.value as Record<string, string>).path;
292
292
  if (path) text = `/cd ${path}`;
293
293
  }
294
+ // cmd 本身就是以 / 开头的完整指令时,直接使用(如 /model <name> 动态按钮)
295
+ if (!text && cmd.startsWith("/")) text = cmd;
294
296
  if (!text) return null;
295
297
 
296
298
  const chatId =
@@ -10,6 +10,7 @@ import { readdir, stat } from "node:fs/promises";
10
10
  import { resolve, dirname } from "node:path";
11
11
 
12
12
  import { makeTraceId, logTrace } from "./trace.ts";
13
+ import { appendStartupTrace } from "./shared.ts";
13
14
  import {
14
15
  CLAUDE_EFFORT,
15
16
  CLAUDE_MODEL,
@@ -17,7 +18,9 @@ import {
17
18
  GIT_TIMEOUT_MS,
18
19
  PROJECT_ROOT,
19
20
  anthropicConfigDisplay,
21
+ config,
20
22
  fileLog,
23
+ getAllModelsForTool,
21
24
  getDefaultCwd,
22
25
  setDefaultCwd,
23
26
  getRecentDirs,
@@ -26,6 +29,7 @@ import {
26
29
  sessionPrefixForTool,
27
30
  toolDisplayName,
28
31
  ts,
32
+ type AgentTool,
29
33
  } from "./config.ts";
30
34
  import {
31
35
  buildHelpCard,
@@ -54,6 +58,7 @@ import {
54
58
  switchChatBinding,
55
59
  recordSessionRegistry,
56
60
  getAdapterForTool,
61
+ getEffectiveModelForTool,
57
62
  stopSession,
58
63
  loadSessionRegistryForBinding,
59
64
  removeSessionRegistryRecord,
@@ -69,6 +74,7 @@ import {
69
74
  enqueueMessage,
70
75
  cancelQueuedMessage,
71
76
  } from "./session-chat-binding.ts";
77
+ import { getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
72
78
  export { type PlatformAdapter } from "./platform-adapter.ts";
73
79
  import type { PlatformAdapter } from "./platform-adapter.ts";
74
80
 
@@ -85,6 +91,21 @@ export function sessionChatName(left: string, cwd: string): string {
85
91
  return `${left}-${cwdDisplayName(cwd)}`;
86
92
  }
87
93
 
94
+ /** 模型模糊匹配:精确匹配优先,否则找子串匹配(模型名越短越优先) */
95
+ function findModelMatch(input: string, models: string[]): string | null {
96
+ if (models.length === 0) return null;
97
+ const inputLower = input.toLowerCase();
98
+ // 1) 精确匹配(忽略大小写)
99
+ for (const m of models) {
100
+ if (m.toLowerCase() === inputLower) return m;
101
+ }
102
+ // 2) 子串匹配:模型全名包含输入,按模型名长度升序(越短越优先)
103
+ const candidates = models
104
+ .filter(m => m.toLowerCase().includes(inputLower))
105
+ .sort((a, b) => a.length - b.length);
106
+ return candidates[0] ?? null;
107
+ }
108
+
88
109
  function isUntitledSessionChatName(name: string): boolean {
89
110
  return name === "新会话" || name.startsWith("新会话-");
90
111
  }
@@ -118,15 +139,34 @@ export async function handleCommand(
118
139
  logTrace(tid, "BRANCH", { cmd: "/restart" });
119
140
  await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => {});
120
141
  logTrace(tid, "DONE", { outcome: "restart" });
121
- console.log(`[${ts()}] [RESTART] Spawning new process...`);
142
+
143
+ appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
122
144
  const child = spawn("npx", ["tsx", "src/index.ts"], {
123
145
  cwd: PROJECT_ROOT,
124
146
  detached: true,
125
147
  stdio: "ignore",
126
148
  shell: true,
127
149
  });
150
+
151
+ child.on("error", (err) => {
152
+ appendStartupTrace("restart: spawn error", { error: err.message });
153
+ });
154
+
155
+ child.on("exit", (code, signal) => {
156
+ appendStartupTrace("restart: child exit", {
157
+ childPid: child.pid,
158
+ code,
159
+ signal,
160
+ });
161
+ });
162
+
128
163
  child.unref();
129
- setTimeout(() => process.exit(0), 200);
164
+
165
+ // 给子进程 2 秒启动窗口,期间若立即 crash 则 exit handler 已写入 trace
166
+ setTimeout(() => {
167
+ appendStartupTrace("restart: parent exit", { childPid: child.pid });
168
+ process.exit(0);
169
+ }, 2000);
130
170
  return;
131
171
  }
132
172
 
@@ -250,48 +290,28 @@ export async function handleCommand(
250
290
  if (textLower === "/model") {
251
291
  logTrace(tid, "BRANCH", { cmd: "/model" });
252
292
 
253
- const isDeepSeek = CLAUDE_MODEL.toLowerCase().includes("deepseek")
254
- || CLAUDE_SUBAGENT_MODEL.toLowerCase().includes("deepseek");
255
- if (!isDeepSeek) {
256
- const msg = "当前配置不支持模型切换(模型名中未找到 DeepSeek 相关内容)。";
257
- await (platform.kind === "wechat"
258
- ? platform.sendText(chatId, msg)
259
- : platform.sendCard(chatId, "模型切换", msg, "red")
260
- ).catch(() => {});
261
- logTrace(tid, "DONE", { outcome: "model_unsupported" });
262
- return;
263
- }
264
-
265
- const findModel = (keyword: string): string | null => {
266
- for (const name of [CLAUDE_MODEL.trim(), CLAUDE_SUBAGENT_MODEL.trim()]) {
267
- if (!name) continue;
268
- const nl = name.toLowerCase();
269
- if (nl.includes("deepseek") && nl.includes(keyword)) return name;
270
- }
271
- return null;
272
- };
273
-
274
- const available: { label: string; model: string }[] = [];
275
- const pm = findModel("pro");
276
- if (pm) available.push({ label: "pro", model: pm });
277
- const fm = findModel("flash");
278
- if (fm) available.push({ label: "flash", model: fm });
293
+ const defaultTool = resolveDefaultAgentTool();
294
+ const models = getAllModelsForTool(defaultTool);
295
+ let currentModel = "";
296
+ if (defaultTool === "cursor") currentModel = config.cursor.model;
297
+ else if (defaultTool === "codex") currentModel = config.codex.model;
298
+ else currentModel = CLAUDE_MODEL;
279
299
 
280
300
  if (platform.kind === "wechat") {
281
- const lines = [CLAUDE_MODEL ? `当前模型: ${CLAUDE_MODEL}` : "当前模型: 未指定"];
282
- if (available.length > 0) {
301
+ const lines = [currentModel ? `当前模型 (${defaultTool}): ${currentModel}` : `当前模型 (${defaultTool}): 未指定`];
302
+ if (models.length > 0) {
283
303
  lines.push("", "可切换模型:");
284
- for (const o of available) lines.push(` ${o.label}: ${o.model}`);
285
- lines.push("", "输入 /model pro 或 /model flash 切换模型");
304
+ for (const m of models) lines.push(` ${m}`);
305
+ lines.push("", "在会话中输入 /model <模型名> 切换模型");
286
306
  } else {
287
- lines.push("", "没有可切换的模型。请在 config.json 的 claude.model 或 claude.subagentModel 中配置含 pro/flash 与 deepseek 关键字的模型名。");
307
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
288
308
  }
289
309
  await platform.sendText(chatId, lines.join("\n")).catch(() => {});
290
310
  } else {
291
- const card = buildModelCard(CLAUDE_MODEL, available);
311
+ const card = buildModelCard(currentModel, models, defaultTool);
292
312
  await platform.sendRawCard(chatId, card);
293
313
  }
294
- logTrace(tid, "DONE", { outcome: "model_query" });
314
+ logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
295
315
  return;
296
316
  }
297
317
 
@@ -391,6 +411,7 @@ export async function handleCommand(
391
411
  `**工作目录:** \`${cwd}\`\n\n` +
392
412
  `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
393
413
  `发送 **/cd** 切换新建会话的默认目录。\n` +
414
+ `发送 **/model** 查看或切换当前会话的模型。\n` +
394
415
  `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
395
416
  `发送 **/sessions** 查看所有会话状态。\n` +
396
417
  `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
@@ -476,6 +497,7 @@ export async function handleCommand(
476
497
  `**工作目录:** \`${cwd}\`\n\n` +
477
498
  `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
478
499
  `发送 **/cd** 切换新建会话的默认目录。\n` +
500
+ `发送 **/model** 查看或切换当前会话的模型。\n` +
479
501
  `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
480
502
  `发送 **/sessions** 查看所有会话状态。\n` +
481
503
  `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
@@ -660,6 +682,43 @@ export async function handleCommand(
660
682
  return;
661
683
  }
662
684
 
685
+ if (textLower === "/test") {
686
+ logTrace(tid, "BRANCH", { cmd: "/test" });
687
+ const tableHeaders = ["名称", "版本", "状态"];
688
+ const tableRows = [
689
+ ["ChatCCC", "0.2.96", "运行中"],
690
+ ["Claude SDK", "0.50.0", "已连接"],
691
+ ["Feishu API", "v1", "正常"],
692
+ ];
693
+ const mdTable = [
694
+ `| ${tableHeaders.join(" | ")} |`,
695
+ `| ${tableHeaders.map(() => "---").join(" | ")} |`,
696
+ ...tableRows.map((row) => `| ${row.join(" | ")} |`),
697
+ ].join("\n");
698
+
699
+ if (platform.kind === "feishu") {
700
+ try {
701
+ const token = await getTenantAccessToken();
702
+ const postContent: unknown[][] = [
703
+ // 先尝试富文本表格
704
+ [{ tag: "table", cells: [tableHeaders, ...tableRows] }],
705
+ // 再用代码块包起来
706
+ [{ tag: "text", text: `\n表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\`` }],
707
+ ];
708
+ await sendPostMessage(token, chatId, "测试表格", postContent);
709
+ } catch (err) {
710
+ console.error(`[${ts()}] [TEST] post message failed: ${(err as Error).message}`);
711
+ // Fallback to markdown card
712
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
713
+ }
714
+ } else {
715
+ // WeChat / other platforms: just send code block
716
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
717
+ }
718
+ logTrace(tid, "DONE", { outcome: "test" });
719
+ return;
720
+ }
721
+
663
722
  if (textLower === "/status") {
664
723
  logTrace(tid, "BRANCH", { cmd: "/status" });
665
724
  const status = await getSessionStatus(chatId);
@@ -799,7 +858,8 @@ export async function handleCommand(
799
858
  `**Session ID:** ${newSessionId}\n` +
800
859
  `**工作目录:** \`${cwd}\`(沿用当前会话目录)\n\n` +
801
860
  `直接在这里发消息即可继续对话。\n` +
802
- `发送 **/cd** 可切换新建会话的默认目录。`,
861
+ `发送 **/cd** 可切换新建会话的默认目录。\n` +
862
+ `发送 **/model** 查看或切换当前会话的模型。`,
803
863
  "green",
804
864
  );
805
865
 
@@ -959,7 +1019,8 @@ export async function handleCommand(
959
1019
  `**序号:** ${index + 1}\n` +
960
1020
  `**Session ID:** ${target.sessionId}\n` +
961
1021
  `**工作目录:** \`${cwd2}\`\n\n` +
962
- `直接在这里发消息即可继续对话。${busyNote}`,
1022
+ `直接在这里发消息即可继续对话。\n` +
1023
+ `发送 **/model** 查看或切换当前会话的模型。${busyNote}`,
963
1024
  "green",
964
1025
  );
965
1026
 
@@ -972,61 +1033,52 @@ export async function handleCommand(
972
1033
  return;
973
1034
  }
974
1035
 
975
- // /model pro、/model flash、/model clear(仅对当前 session 生效)
976
- if (textLower === "/model pro" || textLower === "/model flash" || textLower === "/model clear") {
977
- const modelArg = textLower === "/model clear" ? "clear" : textLower.slice(7).trim();
978
- logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId });
1036
+ // /model clear 清除当前 session 的模型覆盖
1037
+ if (textLower === "/model clear") {
1038
+ logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
1039
+ clearSessionModelOverride(sessionId);
1040
+ const defaultModel = getEffectiveModelForTool(descriptionTool);
1041
+ const toolLabel = toolDisplayName(descriptionTool);
1042
+ const msg = `已清除当前 ${toolLabel} 会话的模型覆盖,恢复使用: \`${defaultModel || "(未指定)"}\``;
1043
+ await (platform.kind === "wechat"
1044
+ ? platform.sendText(chatId, msg)
1045
+ : platform.sendCard(chatId, "模型切换", msg, "green")
1046
+ ).catch(() => {});
1047
+ logTrace(tid, "DONE", { outcome: "model_cleared", sessionId, tool: descriptionTool });
1048
+ return;
1049
+ }
979
1050
 
980
- // Claude Code 支持模型切换
981
- if (descriptionTool !== "claude") {
982
- const msg = "模型切换仅支持 Claude Code 会话。";
983
- await (platform.kind === "wechat"
984
- ? platform.sendText(chatId, msg)
985
- : platform.sendCard(chatId, "模型切换", msg, "red")
986
- ).catch(() => {});
987
- logTrace(tid, "DONE", { outcome: "model_wrong_tool", tool: descriptionTool });
988
- return;
989
- }
1051
+ // /model <name> 切换当前 session 的模型(支持所有 agent,模糊匹配)
1052
+ if (textLower.startsWith("/model ")) {
1053
+ const modelArg = text.slice(7).trim();
1054
+ if (!modelArg) return; // 纯 "/model " 不处理,交给上面的 /model 分支
1055
+ logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
990
1056
 
991
- const findModel = (keyword: string): string | null => {
992
- for (const name of [CLAUDE_MODEL.trim(), CLAUDE_SUBAGENT_MODEL.trim()]) {
993
- if (!name) continue;
994
- const nl = name.toLowerCase();
995
- if (nl.includes("deepseek") && nl.includes(keyword)) return name;
996
- }
997
- return null;
998
- };
1057
+ const models = getAllModelsForTool(descriptionTool as AgentTool);
1058
+ const toolLabel = toolDisplayName(descriptionTool);
999
1059
 
1000
- if (modelArg === "clear") {
1001
- clearSessionModelOverride(sessionId);
1002
- const restored = CLAUDE_MODEL.trim() || "(未指定)";
1003
- const msg = `已清除当前会话的模型覆盖,恢复使用: \`${restored}\``;
1004
- await (platform.kind === "wechat"
1005
- ? platform.sendText(chatId, msg)
1006
- : platform.sendCard(chatId, "模型切换", msg, "green")
1007
- ).catch(() => {});
1008
- logTrace(tid, "DONE", { outcome: "model_cleared", sessionId });
1009
- return;
1010
- }
1060
+ // 查找目标模型:精确匹配优先,否则子串匹配(模型名越短越优先)
1061
+ const target = findModelMatch(modelArg, models);
1011
1062
 
1012
- const target = findModel(modelArg);
1013
1063
  if (!target) {
1014
- const msg = `未找到同时含 "${modelArg}" 和 "deepseek" 关键字的模型。请在 config.json claude.model 或 claude.subagentModel 中配置。`;
1064
+ const msg = models.length > 0
1065
+ ? `未找到匹配 "${modelArg}" 的模型。当前 ${toolLabel} 可选模型:\n${models.map(m => ` \`${m}\``).join("\n")}`
1066
+ : `当前 ${toolLabel} 没有可切换的模型。请在 config.json 中配置模型字段。`;
1015
1067
  await (platform.kind === "wechat"
1016
1068
  ? platform.sendText(chatId, msg)
1017
1069
  : platform.sendCard(chatId, "模型切换", msg, "red")
1018
1070
  ).catch(() => {});
1019
- logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg });
1071
+ logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg, tool: descriptionTool });
1020
1072
  return;
1021
1073
  }
1022
1074
 
1023
1075
  setSessionModelOverride(sessionId, target);
1024
- const msg = `已切换当前会话模型为 ${modelArg}: \`${target}\``;
1076
+ const msg = `已切换当前 ${toolLabel} 会话模型为: \`${target}\``;
1025
1077
  await (platform.kind === "wechat"
1026
1078
  ? platform.sendText(chatId, msg)
1027
1079
  : platform.sendCard(chatId, "模型切换", msg, "green")
1028
1080
  ).catch(() => {});
1029
- logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId });
1081
+ logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId, tool: descriptionTool });
1030
1082
  return;
1031
1083
  }
1032
1084
 
package/src/session.ts CHANGED
@@ -315,7 +315,7 @@ const adapterCache = new Map<string, ToolAdapter>();
315
315
  // Per-session 模型覆盖(/model 命令设置,不持久化)
316
316
  const sessionModelOverrides = new Map<string, string>();
317
317
 
318
- /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置 */
318
+ /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
319
319
  function getModelForSession(sessionId?: string): string {
320
320
  if (sessionId) {
321
321
  const override = sessionModelOverrides.get(sessionId);
@@ -324,7 +324,18 @@ function getModelForSession(sessionId?: string): string {
324
324
  return CLAUDE_MODEL;
325
325
  }
326
326
 
327
- /** 为指定 session 设置模型覆盖(/model pro、/model flash) */
327
+ /** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
328
+ export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
329
+ if (sessionId) {
330
+ const override = sessionModelOverrides.get(sessionId);
331
+ if (override) return override;
332
+ }
333
+ if (tool === "cursor") return config.cursor.model;
334
+ if (tool === "codex") return config.codex.model;
335
+ return CLAUDE_MODEL;
336
+ }
337
+
338
+ /** 为指定 session 设置模型覆盖(/model <name>) */
328
339
  export function setSessionModelOverride(sessionId: string, model: string): void {
329
340
  sessionModelOverrides.set(sessionId, model);
330
341
  adapterCache.clear();
@@ -337,16 +348,16 @@ export function clearSessionModelOverride(sessionId: string): void {
337
348
  }
338
349
 
339
350
  export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
340
- const effectiveModel = tool === "claude" ? getModelForSession(sessionId) : "";
341
- const cacheKey = tool === "claude" ? `${tool}:${effectiveModel}` : tool;
351
+ const effectiveModel = getEffectiveModelForTool(tool, sessionId);
352
+ const cacheKey = effectiveModel ? `${tool}:${effectiveModel}` : tool;
342
353
  const cached = adapterCache.get(cacheKey);
343
354
  if (cached) return cached;
344
355
 
345
356
  let adapter: ToolAdapter;
346
357
  if (tool === "cursor") {
347
- adapter = createCursorAdapter();
358
+ adapter = createCursorAdapter({ model: effectiveModel || undefined });
348
359
  } else if (tool === "codex") {
349
- adapter = createCodexAdapter();
360
+ adapter = createCodexAdapter({ model: effectiveModel || undefined });
350
361
  } else {
351
362
  adapter = createClaudeAdapter({
352
363
  model: effectiveModel,
@@ -534,9 +545,13 @@ export function _resetSessionRegistryFileForTest(): void {
534
545
  // accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
535
546
  // ---------------------------------------------------------------------------
536
547
 
548
+ // 注意:以下字段命名含 "final",但实际语义是"本轮所有文本的累积",并非仅
549
+ // "最后一段回复"。历史原因得名——当时以为 finalReply 只包含最后一轮输出,实则
550
+ // 所有 text block(工具调用前、调用间、调用后)都累加在这里。
551
+ // accumulatedContent + finalReply 拼接后才是完整流式输出的全部内容。
537
552
  export interface AccumulatorState {
538
553
  accumulatedContent: string;
539
- /** partial text 块按追加语义累积;适用于 Cursor 的流式增量与 Claude SDK 的 delta */
554
+ /** 本轮所有 text block 的累加(流式文本 delta),包括工具调用前后的全部文本 */
540
555
  finalText: string;
541
556
  /**
542
557
  * 适配器明确给出的"完整最终文本"(覆盖语义)。
@@ -1691,6 +1706,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
1691
1706
 
1692
1707
  export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
1693
1708
  adapterCache.set(tool, adapter);
1709
+ // 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
1710
+ const effective = getEffectiveModelForTool(tool);
1711
+ if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
1694
1712
  }
1695
1713
 
1696
1714
  export function clearAdapterCache(): void {
@@ -78,6 +78,12 @@ export const SimulatedPlatform: FeishuPlatform = {
78
78
  return true;
79
79
  },
80
80
 
81
+ async sendPostMessage(_token, chatId, title, postContent) {
82
+ console.log(`[${ts()}] [SIM:SEND] post → ${chatId}: title="${title}" paragraphs=${postContent.length}`);
83
+ simStore.sendReply(chatId, "post", `[${title}] ${postContent.length} paragraphs`);
84
+ return true;
85
+ },
86
+
81
87
  // ---- 消息管理 ----
82
88
  async addReaction(_token, _messageId, _emojiType) {
83
89
  // 模拟模式不需要表情回应
package/src/sim-store.ts CHANGED
@@ -30,7 +30,7 @@ export interface SimMessage {
30
30
  id: string; // UUID
31
31
  chatId: string;
32
32
  senderId: string; // SimAccount.id
33
- type: "text" | "card" | "image" | "file" | "raw_card" | "system";
33
+ type: "text" | "card" | "image" | "file" | "raw_card" | "post" | "system";
34
34
  content: string; // 文本内容或 JSON 字符串
35
35
  timestamp: number;
36
36
  }
@@ -9,17 +9,20 @@ import { USER_DATA_DIR, ts } from "./config.ts";
9
9
 
10
10
  export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
11
11
 
12
- export interface StreamState {
13
- sessionId: string;
14
- status: "running" | "done" | "stopped" | "error";
15
- accumulatedContent: string;
16
- finalReply: string;
17
- /** The turn whose terminal text reply has already been delivered to IM. */
18
- finalReplySentTurn?: number;
19
- finalReplySentAt?: number;
20
- chunkCount: number;
21
- turnCount: number;
22
- contextTokens: number;
12
+ export interface StreamState {
13
+ sessionId: string;
14
+ status: "running" | "done" | "stopped" | "error";
15
+ accumulatedContent: string;
16
+ /** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
17
+ * 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
18
+ * 参见 session.ts 的 AccumulatorState 注释。 */
19
+ finalReply: string;
20
+ /** The turn whose terminal text reply has already been delivered to IM. */
21
+ finalReplySentTurn?: number;
22
+ finalReplySentAt?: number;
23
+ chunkCount: number;
24
+ turnCount: number;
25
+ contextTokens: number;
23
26
  updatedAt: number;
24
27
  cwd: string;
25
28
  tool: string;
@@ -74,7 +77,7 @@ export function _resetRenameForTest(): void {
74
77
  * 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
75
78
  * 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
76
79
  */
77
- export async function writeStreamState(state: StreamState): Promise<void> {
80
+ export async function writeStreamState(state: StreamState): Promise<void> {
78
81
  const filePath = getStreamStatePath(state.sessionId);
79
82
  const payload = JSON.stringify(state, null, 2);
80
83
  try {
@@ -98,26 +101,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
98
101
  } catch (err) {
99
102
  console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
100
103
  }
101
- }
102
-
103
- export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
104
- return state.finalReplySentTurn === state.turnCount;
105
- }
106
-
107
- export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
108
- const state = await readStreamState(sessionId);
109
- if (!state) return;
110
- if (state.turnCount !== turnCount) return;
111
- if (state.status === "running") return;
112
-
113
- state.finalReplySentTurn = turnCount;
114
- state.finalReplySentAt = sentAt;
115
- state.updatedAt = Date.now();
116
- await writeStreamState(state);
117
- }
118
-
119
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
120
- return {
104
+ }
105
+
106
+ export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
107
+ return state.finalReplySentTurn === state.turnCount;
108
+ }
109
+
110
+ export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
111
+ const state = await readStreamState(sessionId);
112
+ if (!state) return;
113
+ if (state.turnCount !== turnCount) return;
114
+ if (state.status === "running") return;
115
+
116
+ state.finalReplySentTurn = turnCount;
117
+ state.finalReplySentAt = sentAt;
118
+ state.updatedAt = Date.now();
119
+ await writeStreamState(state);
120
+ }
121
+
122
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
123
+ return {
121
124
  sessionId,
122
125
  status: "running",
123
126
  accumulatedContent: "",
@@ -157,4 +160,4 @@ export async function fixStaleStreamStates(): Promise<void> {
157
160
  } catch (err) {
158
161
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
159
162
  }
160
- }
163
+ }