chatccc 0.2.100 → 0.2.102

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.
@@ -17,7 +17,8 @@
17
17
  "subagentModel": "",
18
18
  "effort": "",
19
19
  "apiKey": "",
20
- "baseUrl": ""
20
+ "baseUrl": "",
21
+ "useThirdPartyApi": false
21
22
  },
22
23
  "cursor": {
23
24
  "enabled": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cards.ts CHANGED
@@ -24,6 +24,12 @@ export function buildButtons(buttons: ButtonDef[]): object {
24
24
  // Content helpers
25
25
  // ---------------------------------------------------------------------------
26
26
 
27
+ // 检测 markdown 代码块是否未闭合(``` 出现奇数次)
28
+ export function isCodeBlockOpen(text: string): boolean {
29
+ const matches = text.match(/```/g);
30
+ return matches ? matches.length % 2 !== 0 : false;
31
+ }
32
+
27
33
  export function truncateContent(text: string, maxLines = 20, maxChars = 8000): string {
28
34
  const lines = text.split("\n");
29
35
  let displayText: string;
@@ -39,6 +45,11 @@ export function truncateContent(text: string, maxLines = 20, maxChars = 8000): s
39
45
  displayText = "..." + displayText.slice(-maxChars);
40
46
  }
41
47
 
48
+ // 截断后如果代码块未闭合,补上闭合标记,避免后续追加内容时误入代码块
49
+ if (isCodeBlockOpen(displayText)) {
50
+ displayText += "\n```";
51
+ }
52
+
42
53
  return displayText;
43
54
  }
44
55
 
@@ -120,6 +131,7 @@ export function buildHelpCard(
120
131
  "发送 **/new cursor** 创建新 Cursor 会话",
121
132
  "发送 **/new codex** 创建新 Codex 会话",
122
133
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
134
+ "发送 **/claude** 查看或切换 Claude API 模式(官方/第三方)",
123
135
  ].join("\n");
124
136
  return JSON.stringify({
125
137
  config: { wide_screen_mode: true },
@@ -450,3 +462,34 @@ export function buildModelCard(
450
462
  ],
451
463
  });
452
464
  }
465
+
466
+ /** Claude API 模式切换卡片(/claude 命令) */
467
+ export function buildClaudeCard(
468
+ currentMode: "official" | "thirdparty",
469
+ missingFields: string[],
470
+ ): string {
471
+ const modeLabel = currentMode === "thirdparty" ? "第三方 API(自定义网关)" : "官方 API(Anthropic 直连)";
472
+ const modeDesc = currentMode === "thirdparty"
473
+ ? "使用第三方兼容网关,需配置 API Key 和 Base URL。"
474
+ : "直连 Anthropic 官方 API,使用系统环境变量中的 ANTHROPIC_API_KEY。";
475
+ const lines = [`**当前模式:** ${modeLabel}`, "", modeDesc];
476
+
477
+ if (missingFields.length > 0) {
478
+ lines.push("", `**切换失败:** 以下必填项未配置: ${missingFields.map(f => `\`${f}\``).join(", ")}`);
479
+ }
480
+
481
+ const buttons: ButtonDef[] = [
482
+ { text: "/claude official", value: JSON.stringify({ cmd: "/claude official" }), type: currentMode === "official" ? "default" : "primary" },
483
+ { text: "/claude 3rd", value: JSON.stringify({ cmd: "/claude 3rd" }), type: currentMode === "thirdparty" ? "default" : "primary" },
484
+ ];
485
+
486
+ return JSON.stringify({
487
+ config: { wide_screen_mode: true },
488
+ header: { template: "blue", title: { content: "Claude API 模式", tag: "plain_text" } },
489
+ elements: [
490
+ { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
491
+ { tag: "hr" },
492
+ buildButtons(buttons),
493
+ ],
494
+ });
495
+ }
package/src/config.ts CHANGED
@@ -67,6 +67,8 @@ export interface ClaudeConfig {
67
67
  effort: string;
68
68
  apiKey: string;
69
69
  baseUrl: string;
70
+ /** 是否使用第三方 API(非 Anthropic 官方);为 true 时 baseUrl 必须配置 */
71
+ useThirdPartyApi: boolean;
70
72
  }
71
73
 
72
74
  export interface CursorConfig {
@@ -322,7 +324,7 @@ function loadConfig(): AppConfig {
322
324
  port: 18080,
323
325
  gitTimeoutSeconds: 180,
324
326
  allowInterrupt: false,
325
- claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "" },
327
+ claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "", useThirdPartyApi: false },
326
328
  cursor: { enabled: false, defaultAgent: false, path: "", model: "claude-opus-4-7-max" },
327
329
  codex: { enabled: false, defaultAgent: false, path: "", model: "", effort: "" },
328
330
  };
@@ -466,6 +468,7 @@ function loadConfig(): AppConfig {
466
468
  effort: normalizeOptionalConfigField(claude.effort, { label: "claude.effort" }),
467
469
  apiKey: claude.apiKey ?? "",
468
470
  baseUrl: claude.baseUrl ?? "",
471
+ useThirdPartyApi: typeof claude.useThirdPartyApi === "boolean" ? claude.useThirdPartyApi : false,
469
472
  },
470
473
  cursor: {
471
474
  enabled: cursorEnabled,
@@ -529,6 +532,8 @@ export let CLAUDE_EFFORT = config.claude.effort;
529
532
  export let CLAUDE_API_KEY = config.claude.apiKey;
530
533
  /** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
531
534
  export let CLAUDE_BASE_URL = config.claude.baseUrl;
535
+ /** 是否使用第三方 API(非 Anthropic 官方) */
536
+ export let CLAUDE_USE_THIRD_PARTY_API = config.claude.useThirdPartyApi;
532
537
 
533
538
  /** 返回当前生效的 Claude 模型(per-session 覆盖由 session.ts 管理,此处仅返回全局配置) */
534
539
  export function getEffectiveClaudeModel(): string {
@@ -608,6 +613,7 @@ export function applyLoadedConfig(next: AppConfig): void {
608
613
  CLAUDE_EFFORT = next.claude.effort;
609
614
  CLAUDE_API_KEY = next.claude.apiKey;
610
615
  CLAUDE_BASE_URL = next.claude.baseUrl;
616
+ CLAUDE_USE_THIRD_PARTY_API = next.claude.useThirdPartyApi;
611
617
  GIT_TIMEOUT_SECONDS = next.gitTimeoutSeconds;
612
618
  GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
613
619
  ALLOW_INTERRUPT = next.allowInterrupt;
package/src/feishu-api.ts CHANGED
@@ -892,6 +892,7 @@ export async function sendRawCard(
892
892
  chatId: string,
893
893
  cardJson: string
894
894
  ): Promise<boolean> {
895
+ const safeJson = applyPrivacy(cardJson);
895
896
  try {
896
897
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
897
898
  method: "POST",
@@ -899,7 +900,7 @@ export async function sendRawCard(
899
900
  Authorization: `Bearer ${token}`,
900
901
  "Content-Type": "application/json",
901
902
  },
902
- body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: cardJson }),
903
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: safeJson }),
903
904
  });
904
905
  const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
905
906
  if (data.code !== 0) {
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { spawn } from "node:child_process";
9
9
  import { readdir, stat } from "node:fs/promises";
10
+ import { readFileSync, writeFileSync } from "node:fs";
10
11
  import { resolve, dirname } from "node:path";
11
12
 
12
13
  import { makeTraceId, logTrace } from "./trace.ts";
@@ -15,6 +16,10 @@ import {
15
16
  CLAUDE_EFFORT,
16
17
  CLAUDE_MODEL,
17
18
  CLAUDE_SUBAGENT_MODEL,
19
+ CLAUDE_API_KEY,
20
+ CLAUDE_BASE_URL,
21
+ CLAUDE_USE_THIRD_PARTY_API,
22
+ CONFIG_FILE,
18
23
  GIT_TIMEOUT_MS,
19
24
  PROJECT_ROOT,
20
25
  anthropicConfigDisplay,
@@ -29,6 +34,7 @@ import {
29
34
  sessionPrefixForTool,
30
35
  toolDisplayName,
31
36
  ts,
37
+ reloadConfigFromDisk,
32
38
  type AgentTool,
33
39
  } from "./config.ts";
34
40
  import {
@@ -40,6 +46,7 @@ import {
40
46
  buildSessionsCard,
41
47
  buildQueuedCard,
42
48
  buildQueueFullCard,
49
+ buildClaudeCard,
43
50
  } from "./cards.ts";
44
51
  import {
45
52
  formatGitResult,
@@ -383,6 +390,7 @@ export async function handleCommand(
383
390
  `**工作目录:** \`${cwd}\`\n\n` +
384
391
  `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
385
392
  `发送 **/cd** 切换新建会话的默认目录。\n` +
393
+ `发送 **/claude** 查看或切换 Claude API 模式(官方/第三方)。\n` +
386
394
  `发送 **/model** 查看或切换当前会话的模型。\n` +
387
395
  `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
388
396
  `发送 **/sessions** 查看所有会话状态。\n` +
@@ -1078,6 +1086,72 @@ export async function handleCommand(
1078
1086
  return;
1079
1087
  }
1080
1088
 
1089
+ // /claude — 查看/切换 Claude API 模式(官方 vs 第三方)
1090
+ if (textLower === "/claude" || textLower === "/claude official" || textLower === "/claude 3rd") {
1091
+ logTrace(tid, "BRANCH", { cmd: "/claude", arg: text.slice(8).trim() || "(none)" });
1092
+
1093
+ if (textLower === "/claude official") {
1094
+ // 切换到官方模式:设置 useThirdPartyApi=false,清空 apiKey 和 baseUrl
1095
+ try {
1096
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
1097
+ const cfg = JSON.parse(raw);
1098
+ cfg.claude = cfg.claude || {};
1099
+ cfg.claude.useThirdPartyApi = false;
1100
+ cfg.claude.apiKey = "";
1101
+ cfg.claude.baseUrl = "";
1102
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1103
+ reloadConfigFromDisk();
1104
+ logTrace(tid, "DONE", { outcome: "claude_official" });
1105
+ const card = buildClaudeCard("official", []);
1106
+ await platform.sendRawCard(chatId, card);
1107
+ } catch (err) {
1108
+ logTrace(tid, "DONE", { outcome: "claude_official_fail", error: (err as Error).message });
1109
+ await platform.sendText(chatId, `切换失败: ${(err as Error).message}`).catch(() => {});
1110
+ }
1111
+ return;
1112
+ }
1113
+
1114
+ if (textLower === "/claude 3rd") {
1115
+ // 切换到第三方模式:检查 apiKey 和 baseUrl 是否已配置
1116
+ const missing: string[] = [];
1117
+ if (!CLAUDE_API_KEY.trim()) missing.push("API Key");
1118
+ if (!CLAUDE_BASE_URL.trim()) missing.push("Base URL");
1119
+
1120
+ if (missing.length > 0) {
1121
+ logTrace(tid, "DONE", { outcome: "claude_3rd_missing", missing });
1122
+ const card = buildClaudeCard(
1123
+ CLAUDE_USE_THIRD_PARTY_API ? "thirdparty" : "official",
1124
+ missing,
1125
+ );
1126
+ await platform.sendRawCard(chatId, card);
1127
+ return;
1128
+ }
1129
+
1130
+ try {
1131
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
1132
+ const cfg = JSON.parse(raw);
1133
+ cfg.claude = cfg.claude || {};
1134
+ cfg.claude.useThirdPartyApi = true;
1135
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1136
+ reloadConfigFromDisk();
1137
+ logTrace(tid, "DONE", { outcome: "claude_3rd" });
1138
+ const card = buildClaudeCard("thirdparty", []);
1139
+ await platform.sendRawCard(chatId, card);
1140
+ } catch (err) {
1141
+ logTrace(tid, "DONE", { outcome: "claude_3rd_fail", error: (err as Error).message });
1142
+ await platform.sendText(chatId, `切换失败: ${(err as Error).message}`).catch(() => {});
1143
+ }
1144
+ return;
1145
+ }
1146
+
1147
+ // /claude — 显示当前模式和切换按钮
1148
+ const currentMode: "official" | "thirdparty" = CLAUDE_USE_THIRD_PARTY_API ? "thirdparty" : "official";
1149
+ const card = buildClaudeCard(currentMode, []);
1150
+ await platform.sendRawCard(chatId, card);
1151
+ logTrace(tid, "DONE", { outcome: "claude_query", mode: currentMode });
1152
+ return;
1153
+ }
1154
+
1081
1155
  // /git <args>:在「当前会话工作目录」执行 git 命令
1082
1156
  if (textLower.startsWith("/git ") || textLower === "/git") {
1083
1157
  const args = text === "/git" ? "" : text.slice(5).trim();
@@ -1209,6 +1283,69 @@ export async function handleCommand(
1209
1283
  return;
1210
1284
  }
1211
1285
 
1286
+ // 无会话上下文 → 检查是否是 /claude 查询
1287
+ if (textLower === "/claude" || textLower === "/claude official" || textLower === "/claude 3rd") {
1288
+ logTrace(tid, "BRANCH", { cmd: "/claude", arg: text.slice(8).trim() || "(none)", noSession: true });
1289
+
1290
+ if (textLower === "/claude official") {
1291
+ try {
1292
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
1293
+ const cfg = JSON.parse(raw);
1294
+ cfg.claude = cfg.claude || {};
1295
+ cfg.claude.useThirdPartyApi = false;
1296
+ cfg.claude.apiKey = "";
1297
+ cfg.claude.baseUrl = "";
1298
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1299
+ reloadConfigFromDisk();
1300
+ logTrace(tid, "DONE", { outcome: "claude_official" });
1301
+ const card = buildClaudeCard("official", []);
1302
+ await platform.sendRawCard(chatId, card);
1303
+ } catch (err) {
1304
+ logTrace(tid, "DONE", { outcome: "claude_official_fail", error: (err as Error).message });
1305
+ await platform.sendText(chatId, `切换失败: ${(err as Error).message}`).catch(() => {});
1306
+ }
1307
+ return;
1308
+ }
1309
+
1310
+ if (textLower === "/claude 3rd") {
1311
+ const missing: string[] = [];
1312
+ if (!CLAUDE_API_KEY.trim()) missing.push("API Key");
1313
+ if (!CLAUDE_BASE_URL.trim()) missing.push("Base URL");
1314
+
1315
+ if (missing.length > 0) {
1316
+ logTrace(tid, "DONE", { outcome: "claude_3rd_missing", missing });
1317
+ const card = buildClaudeCard(
1318
+ CLAUDE_USE_THIRD_PARTY_API ? "thirdparty" : "official",
1319
+ missing,
1320
+ );
1321
+ await platform.sendRawCard(chatId, card);
1322
+ return;
1323
+ }
1324
+
1325
+ try {
1326
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
1327
+ const cfg = JSON.parse(raw);
1328
+ cfg.claude = cfg.claude || {};
1329
+ cfg.claude.useThirdPartyApi = true;
1330
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
1331
+ reloadConfigFromDisk();
1332
+ logTrace(tid, "DONE", { outcome: "claude_3rd" });
1333
+ const card = buildClaudeCard("thirdparty", []);
1334
+ await platform.sendRawCard(chatId, card);
1335
+ } catch (err) {
1336
+ logTrace(tid, "DONE", { outcome: "claude_3rd_fail", error: (err as Error).message });
1337
+ await platform.sendText(chatId, `切换失败: ${(err as Error).message}`).catch(() => {});
1338
+ }
1339
+ return;
1340
+ }
1341
+
1342
+ const currentMode: "official" | "thirdparty" = CLAUDE_USE_THIRD_PARTY_API ? "thirdparty" : "official";
1343
+ const card = buildClaudeCard(currentMode, []);
1344
+ await platform.sendRawCard(chatId, card);
1345
+ logTrace(tid, "DONE", { outcome: "claude_query", mode: currentMode });
1346
+ return;
1347
+ }
1348
+
1212
1349
  // 无会话上下文 → 检查是否是 /model 查询
1213
1350
  if (textLower === "/model") {
1214
1351
  const defaultTool = resolveDefaultAgentTool();
package/src/session.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  toolDisplayName,
21
21
  ts,
22
22
  } from "./config.ts";
23
- import { buildProgressCard, getToolEmoji, truncateContent } from "./cards.ts";
23
+ import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
24
24
  import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
25
25
  import { logTrace } from "./trace.ts";
26
26
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
@@ -1430,7 +1430,9 @@ export function startUnifiedDisplayLoop(): void {
1430
1430
  const delta = (accDelta + replyDelta).trim();
1431
1431
 
1432
1432
  display.dotCount = (display.dotCount % 9) + 1;
1433
- const displayContent = (delta || "") + "\n" + "。" .repeat(display.dotCount);
1433
+ let deltaBase = (delta || "");
1434
+ if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
1435
+ const displayContent = deltaBase + "\n" + "。" .repeat(display.dotCount);
1434
1436
  if (displayContent === display.lastSentContent) continue;
1435
1437
 
1436
1438
  display.lastSentContent = displayContent;
@@ -1453,7 +1455,9 @@ export function startUnifiedDisplayLoop(): void {
1453
1455
  }
1454
1456
 
1455
1457
  display.dotCount = (display.dotCount % 9) + 1;
1456
- const fullContent = state.accumulatedContent + state.finalReply + "\n" + "。".repeat(display.dotCount);
1458
+ let contentBase = state.accumulatedContent + state.finalReply;
1459
+ if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
1460
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1457
1461
  if (fullContent === display.lastSentContent) continue;
1458
1462
 
1459
1463
  display.lastSentContent = fullContent;