chatccc 0.2.95 → 0.2.96

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.95",
3
+ "version": "0.2.96",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cards.ts CHANGED
@@ -407,3 +407,63 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
407
407
  ],
408
408
  });
409
409
  }
410
+
411
+ export interface ModelSwitchOption {
412
+ label: string;
413
+ model: string;
414
+ }
415
+
416
+ /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
417
+ export function buildModelCard(
418
+ currentModel: string,
419
+ available: ModelSwitchOption[],
420
+ ): string {
421
+ const currentLine = currentModel
422
+ ? `**当前模型:** \`${currentModel}\``
423
+ : "**当前模型:** 未指定";
424
+ const hasPro = available.some(o => o.label === "pro");
425
+ const hasFlash = available.some(o => o.label === "flash");
426
+
427
+ const lines: string[] = [currentLine];
428
+ if (available.length > 0) {
429
+ lines.push("", "**可切换模型:**");
430
+ for (const opt of available) {
431
+ lines.push(`- ${opt.label}: \`${opt.model}\``);
432
+ }
433
+ } else {
434
+ lines.push("", "没有可切换的模型。");
435
+ }
436
+
437
+ const buttons: { text: string; value: string; type?: "primary" | "default" | "danger" }[] = [];
438
+ if (hasPro) {
439
+ buttons.push({
440
+ text: "/model pro",
441
+ value: JSON.stringify({ action: "model_pro" }),
442
+ type: "primary",
443
+ });
444
+ }
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
+
460
+ return JSON.stringify({
461
+ config: { wide_screen_mode: true },
462
+ header: { template: "blue", title: { content: "模型切换", tag: "plain_text" } },
463
+ elements: [
464
+ { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
465
+ { tag: "hr" },
466
+ buildButtons(buttons),
467
+ ],
468
+ });
469
+ }
package/src/config.ts CHANGED
@@ -511,6 +511,11 @@ export let CLAUDE_API_KEY = config.claude.apiKey;
511
511
  /** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
512
512
  export let CLAUDE_BASE_URL = config.claude.baseUrl;
513
513
 
514
+ /** 返回当前生效的 Claude 模型(per-session 覆盖由 session.ts 管理,此处仅返回全局配置) */
515
+ export function getEffectiveClaudeModel(): string {
516
+ return CLAUDE_MODEL;
517
+ }
518
+
514
519
  // ---------------------------------------------------------------------------
515
520
  // /git 超时配置(实际值来自 config.json,纯函数与常量见 ./config-utils.ts)
516
521
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -285,7 +285,7 @@ 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" };
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" };
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;
@@ -13,6 +13,7 @@ import { makeTraceId, logTrace } from "./trace.ts";
13
13
  import {
14
14
  CLAUDE_EFFORT,
15
15
  CLAUDE_MODEL,
16
+ CLAUDE_SUBAGENT_MODEL,
16
17
  GIT_TIMEOUT_MS,
17
18
  PROJECT_ROOT,
18
19
  anthropicConfigDisplay,
@@ -28,6 +29,7 @@ import {
28
29
  } from "./config.ts";
29
30
  import {
30
31
  buildHelpCard,
32
+ buildModelCard,
31
33
  buildStatusCard,
32
34
  buildCdContent,
33
35
  buildCdCard,
@@ -41,12 +43,14 @@ import {
41
43
  runGitCommand,
42
44
  } from "./git-command.ts";
43
45
  import {
46
+ clearSessionModelOverride,
44
47
  getSessionStatus,
45
48
  getAllSessionsStatus,
46
49
  initClaudeSession,
47
50
  lastMsgTimestamps,
48
51
  resumeAndPrompt,
49
52
  sessionInfoMap,
53
+ setSessionModelOverride,
50
54
  switchChatBinding,
51
55
  recordSessionRegistry,
52
56
  getAdapterForTool,
@@ -141,7 +145,7 @@ export async function handleCommand(
141
145
  chatInfo.description,
142
146
  );
143
147
  if (sessionInfoResult) {
144
- const adapter = getAdapterForTool(sessionInfoResult.tool);
148
+ const adapter = getAdapterForTool(sessionInfoResult.tool, sessionInfoResult.sessionId);
145
149
  const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
146
150
  sessionCwd = info?.cwd;
147
151
  }
@@ -243,6 +247,54 @@ export async function handleCommand(
243
247
  return;
244
248
  }
245
249
 
250
+ if (textLower === "/model") {
251
+ logTrace(tid, "BRANCH", { cmd: "/model" });
252
+
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 });
279
+
280
+ if (platform.kind === "wechat") {
281
+ const lines = [CLAUDE_MODEL ? `当前模型: ${CLAUDE_MODEL}` : "当前模型: 未指定"];
282
+ if (available.length > 0) {
283
+ lines.push("", "可切换模型:");
284
+ for (const o of available) lines.push(` ${o.label}: ${o.model}`);
285
+ lines.push("", "输入 /model pro 或 /model flash 切换模型");
286
+ } else {
287
+ lines.push("", "没有可切换的模型。请在 config.json 的 claude.model 或 claude.subagentModel 中配置含 pro/flash 与 deepseek 关键字的模型名。");
288
+ }
289
+ await platform.sendText(chatId, lines.join("\n")).catch(() => {});
290
+ } else {
291
+ const card = buildModelCard(CLAUDE_MODEL, available);
292
+ await platform.sendRawCard(chatId, card);
293
+ }
294
+ logTrace(tid, "DONE", { outcome: "model_query" });
295
+ return;
296
+ }
297
+
246
298
  if (textLower === "/new" || textLower.startsWith("/new ")) {
247
299
  const toolArg = text.slice(5).trim().toLowerCase();
248
300
  const tool = toolArg || resolveDefaultAgentTool();
@@ -510,7 +562,7 @@ export async function handleCommand(
510
562
  ) {
511
563
  const MAX_PREFIX = 10;
512
564
  const prefix = text.slice(0, MAX_PREFIX);
513
- const adapter = getAdapterForTool(descriptionTool);
565
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
514
566
  const info = await adapter
515
567
  .getSessionInfo(sessionId)
516
568
  .catch(() => undefined);
@@ -553,7 +605,7 @@ export async function handleCommand(
553
605
  ) {
554
606
  const MAX_PREFIX = 10;
555
607
  const prefix = text.slice(0, MAX_PREFIX);
556
- const adapter = getAdapterForTool(descriptionTool!);
608
+ const adapter = getAdapterForTool(descriptionTool!, sessionId);
557
609
  const info = await adapter
558
610
  .getSessionInfo(sessionId)
559
611
  .catch(() => undefined);
@@ -674,7 +726,7 @@ export async function handleCommand(
674
726
 
675
727
  if (textLower === "/newh") {
676
728
  logTrace(tid, "BRANCH", { cmd: "/newh" });
677
- const adapter = getAdapterForTool(descriptionTool);
729
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
678
730
  let cwd: string;
679
731
  try {
680
732
  const info = await adapter.getSessionInfo(sessionId);
@@ -849,7 +901,7 @@ export async function handleCommand(
849
901
  return;
850
902
  }
851
903
 
852
- const targetAdapter = getAdapterForTool(target.tool);
904
+ const targetAdapter = getAdapterForTool(target.tool, target.sessionId);
853
905
  let cwd2: string;
854
906
  try {
855
907
  const targetInfo = await targetAdapter.getSessionInfo(
@@ -920,6 +972,64 @@ export async function handleCommand(
920
972
  return;
921
973
  }
922
974
 
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 });
979
+
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
+ }
990
+
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
+ };
999
+
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
+ }
1011
+
1012
+ const target = findModel(modelArg);
1013
+ if (!target) {
1014
+ const msg = `未找到同时含 "${modelArg}" 和 "deepseek" 关键字的模型。请在 config.json 的 claude.model 或 claude.subagentModel 中配置。`;
1015
+ await (platform.kind === "wechat"
1016
+ ? platform.sendText(chatId, msg)
1017
+ : platform.sendCard(chatId, "模型切换", msg, "red")
1018
+ ).catch(() => {});
1019
+ logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg });
1020
+ return;
1021
+ }
1022
+
1023
+ setSessionModelOverride(sessionId, target);
1024
+ const msg = `已切换当前会话模型为 ${modelArg}: \`${target}\``;
1025
+ await (platform.kind === "wechat"
1026
+ ? platform.sendText(chatId, msg)
1027
+ : platform.sendCard(chatId, "模型切换", msg, "green")
1028
+ ).catch(() => {});
1029
+ logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId });
1030
+ return;
1031
+ }
1032
+
923
1033
  // /git <args>:在「当前会话工作目录」执行 git 命令
924
1034
  if (textLower.startsWith("/git ") || textLower === "/git") {
925
1035
  const args = text === "/git" ? "" : text.slice(5).trim();
@@ -935,7 +1045,7 @@ export async function handleCommand(
935
1045
  return;
936
1046
  }
937
1047
 
938
- const adapter = getAdapterForTool(descriptionTool);
1048
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
939
1049
  let cwd: string | undefined;
940
1050
  try {
941
1051
  const info = await adapter.getSessionInfo(sessionId);
package/src/session.ts CHANGED
@@ -307,13 +307,39 @@ export function resetState(): void {
307
307
  // 是 onReady/onReconnected 取代 resetState 的正确入口。
308
308
 
309
309
  // ---------------------------------------------------------------------------
310
- // Adapter: 按 tool 创建并缓存
310
+ // Adapter: 按 tool + effectiveModel 创建并缓存
311
311
  // ---------------------------------------------------------------------------
312
312
 
313
313
  const adapterCache = new Map<string, ToolAdapter>();
314
314
 
315
- export function getAdapterForTool(tool: string): ToolAdapter {
316
- const cached = adapterCache.get(tool);
315
+ // Per-session 模型覆盖(/model 命令设置,不持久化)
316
+ const sessionModelOverrides = new Map<string, string>();
317
+
318
+ /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置 */
319
+ function getModelForSession(sessionId?: string): string {
320
+ if (sessionId) {
321
+ const override = sessionModelOverrides.get(sessionId);
322
+ if (override) return override;
323
+ }
324
+ return CLAUDE_MODEL;
325
+ }
326
+
327
+ /** 为指定 session 设置模型覆盖(/model pro、/model flash) */
328
+ export function setSessionModelOverride(sessionId: string, model: string): void {
329
+ sessionModelOverrides.set(sessionId, model);
330
+ adapterCache.clear();
331
+ }
332
+
333
+ /** 清除指定 session 的模型覆盖(/model clear) */
334
+ export function clearSessionModelOverride(sessionId: string): void {
335
+ sessionModelOverrides.delete(sessionId);
336
+ adapterCache.clear();
337
+ }
338
+
339
+ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
340
+ const effectiveModel = tool === "claude" ? getModelForSession(sessionId) : "";
341
+ const cacheKey = tool === "claude" ? `${tool}:${effectiveModel}` : tool;
342
+ const cached = adapterCache.get(cacheKey);
317
343
  if (cached) return cached;
318
344
 
319
345
  let adapter: ToolAdapter;
@@ -323,7 +349,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
323
349
  adapter = createCodexAdapter();
324
350
  } else {
325
351
  adapter = createClaudeAdapter({
326
- model: CLAUDE_MODEL,
352
+ model: effectiveModel,
327
353
  subagentModel: CLAUDE_SUBAGENT_MODEL,
328
354
  effort: CLAUDE_EFFORT,
329
355
  isEmpty: isAnthropicConfigEmpty,
@@ -331,7 +357,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
331
357
  baseUrl: CLAUDE_BASE_URL,
332
358
  });
333
359
  }
334
- adapterCache.set(tool, adapter);
360
+ adapterCache.set(cacheKey, adapter);
335
361
  return adapter;
336
362
  }
337
363
 
@@ -748,7 +774,7 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
748
774
  * Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
749
775
  * cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
750
776
  */
751
- function formatToolConfigForLog(tool: string, sessionModel?: string): string {
777
+ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
752
778
  if (tool === "cursor") {
753
779
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
754
780
  }
@@ -761,7 +787,7 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
761
787
  : "effort=(由 codex config.toml 决定)";
762
788
  return `model=${modelStr}, ${effortStr}`;
763
789
  }
764
- return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
790
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
765
791
  }
766
792
 
767
793
  export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
@@ -841,12 +867,12 @@ export async function runAgentSession(
841
867
  let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
842
868
  let cwd: string;
843
869
  try {
844
- adapter = getAdapterForTool(tool);
870
+ adapter = getAdapterForTool(tool, sessionId);
845
871
  info = await adapter.getSessionInfo(sessionId);
846
872
  cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
847
873
  if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
848
874
  console.log(
849
- `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
875
+ `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`
850
876
  );
851
877
 
852
878
  // 构建 IM skills prompt(sessionId 方式,无 token)
@@ -1541,7 +1567,7 @@ async function resolveModelEffort(
1541
1567
  if (tool === "cursor") {
1542
1568
  let model = UNKNOWN_MODEL_PLACEHOLDER;
1543
1569
  try {
1544
- const adapter = getAdapterForTool(tool);
1570
+ const adapter = getAdapterForTool(tool, sessionId);
1545
1571
  const info = await adapter.getSessionInfo(sessionId);
1546
1572
  if (info?.model) model = info.model;
1547
1573
  } catch {
@@ -1558,7 +1584,7 @@ async function resolveModelEffort(
1558
1584
  };
1559
1585
  }
1560
1586
  return {
1561
- model: anthropicConfigDisplay(CLAUDE_MODEL),
1587
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
1562
1588
  effort: anthropicConfigDisplay(CLAUDE_EFFORT),
1563
1589
  };
1564
1590
  }