chatccc 0.2.60 → 0.2.62
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/config.sample.json +10 -9
- package/im-skills/feishu-skill/download-video.mjs +17 -10
- package/package.json +1 -1
- package/src/__tests__/wechat-platform.test.ts +68 -56
- package/src/config.ts +24 -2
- package/src/index.ts +5 -2
- package/src/orchestrator.ts +2 -1
- package/src/session.ts +8 -1
- package/src/web-ui.ts +26 -3
- package/src/wechat-platform.ts +29 -55
package/config.sample.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"feishu": {
|
|
3
3
|
"appId": "",
|
|
4
|
-
"appSecret": ""
|
|
4
|
+
"appSecret": "",
|
|
5
|
+
"domain": "feishu"
|
|
5
6
|
},
|
|
6
7
|
"platforms": {
|
|
7
8
|
"feishu": { "enabled": true },
|
|
@@ -10,14 +11,14 @@
|
|
|
10
11
|
"port": 18080,
|
|
11
12
|
"gitTimeoutSeconds": 180,
|
|
12
13
|
"allowInterrupt": false,
|
|
13
|
-
"claude": {
|
|
14
|
-
"enabled": false,
|
|
15
|
-
"defaultAgent": true,
|
|
16
|
-
"model": "
|
|
17
|
-
"subagentModel": "",
|
|
18
|
-
"effort": "",
|
|
19
|
-
"apiKey": "",
|
|
20
|
-
"baseUrl": ""
|
|
14
|
+
"claude": {
|
|
15
|
+
"enabled": false,
|
|
16
|
+
"defaultAgent": true,
|
|
17
|
+
"model": "",
|
|
18
|
+
"subagentModel": "",
|
|
19
|
+
"effort": "",
|
|
20
|
+
"apiKey": "",
|
|
21
|
+
"baseUrl": ""
|
|
21
22
|
},
|
|
22
23
|
"cursor": {
|
|
23
24
|
"enabled": false,
|
|
@@ -30,6 +30,12 @@ function walkUpForConfig(startDir) {
|
|
|
30
30
|
return result;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function getBaseUrl(domain) {
|
|
34
|
+
return domain === "lark"
|
|
35
|
+
? "https://open.larksuite.com/open-apis"
|
|
36
|
+
: "https://open.feishu.cn/open-apis";
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
async function findConfig() {
|
|
34
40
|
const paths = [
|
|
35
41
|
join(homedir(), ".chatccc", "config.json"),
|
|
@@ -44,7 +50,7 @@ async function findConfig() {
|
|
|
44
50
|
const appSecret = cfg.feishu?.appSecret || "";
|
|
45
51
|
if (appId && appSecret) {
|
|
46
52
|
console.error(`Using config: ${path}`);
|
|
47
|
-
return { appId, appSecret };
|
|
53
|
+
return { appId, appSecret, baseUrl: getBaseUrl(cfg.feishu?.domain) };
|
|
48
54
|
}
|
|
49
55
|
} catch {
|
|
50
56
|
// Try the next candidate.
|
|
@@ -53,8 +59,8 @@ async function findConfig() {
|
|
|
53
59
|
throw new Error(`Could not find Feishu config. Tried: ${paths.slice(0, 3).join(", ")}...`);
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
async function getTenantAccessToken(appId, appSecret) {
|
|
57
|
-
const response = await fetch(
|
|
62
|
+
async function getTenantAccessToken(appId, appSecret, baseUrl) {
|
|
63
|
+
const response = await fetch(`${baseUrl}/auth/v3/tenant_access_token/internal`, {
|
|
58
64
|
method: "POST",
|
|
59
65
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
60
66
|
body: Buffer.from(JSON.stringify({ app_id: appId, app_secret: appSecret }), "utf8"),
|
|
@@ -66,10 +72,10 @@ async function getTenantAccessToken(appId, appSecret) {
|
|
|
66
72
|
return data.tenant_access_token;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
async function findMessageId(token, chatId, fileKey) {
|
|
75
|
+
async function findMessageId(token, baseUrl, chatId, fileKey) {
|
|
70
76
|
let pageToken = "";
|
|
71
77
|
for (let page = 0; page < 10; page++) {
|
|
72
|
-
const url = new URL(
|
|
78
|
+
const url = new URL(`${baseUrl}/im/v1/messages`);
|
|
73
79
|
url.searchParams.set("receive_id_type", "chat_id");
|
|
74
80
|
url.searchParams.set("receive_id", chatId);
|
|
75
81
|
url.searchParams.set("page_size", "50");
|
|
@@ -101,8 +107,8 @@ function safeFileName(name) {
|
|
|
101
107
|
return (name || "download.bin").replace(/[\\/:*?"<>|]/g, "_");
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
async function downloadResource(token, messageId, fileKey, fileName) {
|
|
105
|
-
const url =
|
|
110
|
+
async function downloadResource(token, baseUrl, messageId, fileKey, fileName) {
|
|
111
|
+
const url = `${baseUrl}/im/v1/messages/${encodeURIComponent(messageId)}/resources/${encodeURIComponent(fileKey)}?type=file`;
|
|
106
112
|
console.error(`Downloading: ${url}`);
|
|
107
113
|
|
|
108
114
|
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -131,12 +137,12 @@ async function main() {
|
|
|
131
137
|
process.exit(1);
|
|
132
138
|
}
|
|
133
139
|
|
|
134
|
-
const { appId, appSecret } = await findConfig();
|
|
135
|
-
const token = await getTenantAccessToken(appId, appSecret);
|
|
140
|
+
const { appId, appSecret, baseUrl } = await findConfig();
|
|
141
|
+
const token = await getTenantAccessToken(appId, appSecret, baseUrl);
|
|
136
142
|
|
|
137
143
|
let messageId = args["message-id"] || "";
|
|
138
144
|
if (!messageId && args["chat-id"]) {
|
|
139
|
-
messageId = await findMessageId(token, args["chat-id"], args["file-key"]);
|
|
145
|
+
messageId = await findMessageId(token, baseUrl, args["chat-id"], args["file-key"]);
|
|
140
146
|
if (!messageId) {
|
|
141
147
|
throw new Error(`No message found for file_key=${args["file-key"]}`);
|
|
142
148
|
}
|
|
@@ -149,6 +155,7 @@ async function main() {
|
|
|
149
155
|
|
|
150
156
|
const localPath = await downloadResource(
|
|
151
157
|
token,
|
|
158
|
+
baseUrl,
|
|
152
159
|
messageId,
|
|
153
160
|
args["file-key"],
|
|
154
161
|
args.name || "download.bin",
|
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { buildHelpCard } from "../cards.ts";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
createWechatAdapter,
|
|
8
|
-
} from "../wechat-platform.ts";
|
|
9
|
-
|
|
10
|
-
describe("createWechatAdapter", () => {
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
_resetWechatClawStateForTest();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildHelpCard } from "../cards.ts";
|
|
4
|
+
import {
|
|
5
|
+
_resetWechatClawStateForTest,
|
|
6
|
+
_setWxMinSendIntervalMsForTest,
|
|
7
|
+
createWechatAdapter,
|
|
8
|
+
} from "../wechat-platform.ts";
|
|
9
|
+
|
|
10
|
+
describe("createWechatAdapter", () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_resetWechatClawStateForTest();
|
|
13
|
+
_setWxMinSendIntervalMsForTest(0); // 测试中禁用发送间隔限制
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("degrades raw cards to plain text messages", async () => {
|
|
16
17
|
const wire = {
|
|
17
18
|
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
18
19
|
sendText: vi.fn(
|
|
@@ -38,7 +39,7 @@ describe("createWechatAdapter", () => {
|
|
|
38
39
|
expect(log).toHaveBeenCalledWith("[WECHAT] sendRawCard degraded to text");
|
|
39
40
|
});
|
|
40
41
|
|
|
41
|
-
it("
|
|
42
|
+
it("appends claw suffix on 9th non-final message and blocks further non-final messages", async () => {
|
|
42
43
|
const wire = {
|
|
43
44
|
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
44
45
|
sendText: vi.fn(
|
|
@@ -52,48 +53,59 @@ describe("createWechatAdapter", () => {
|
|
|
52
53
|
log,
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
// 前8条正常发送
|
|
57
|
+
for (let i = 0; i < 8; i++) {
|
|
56
58
|
await expect(platform.sendText("wx-chat-limit", `chunk ${i}`)).resolves.toBe(true);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// 第9条非最终消息:应附加 claw 后缀
|
|
62
|
+
await expect(platform.sendText("wx-chat-limit", "chunk 8")).resolves.toBe(true);
|
|
63
|
+
|
|
64
|
+
// 第10条起非最终消息被阻止
|
|
65
|
+
await expect(platform.sendText("wx-chat-limit", "chunk 9")).resolves.toBe(false);
|
|
66
|
+
|
|
67
|
+
expect(wire.push).toHaveBeenCalledTimes(9);
|
|
68
|
+
// 验证第9条附带了 claw 后缀
|
|
69
|
+
const ninthCall = wire.push.mock.calls[8];
|
|
70
|
+
expect(ninthCall[1]).toContain("chunk 8");
|
|
71
|
+
expect(ninthCall[1]).toContain("由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你");
|
|
72
|
+
expect(log).toHaveBeenCalledWith(
|
|
73
|
+
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=10",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("allows final messages through after the 9th message claw limit", async () => {
|
|
78
|
+
const wire = {
|
|
79
|
+
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
80
|
+
sendText: vi.fn(
|
|
81
|
+
async (_chatId: string, _text: string, _contextToken?: string) =>
|
|
82
|
+
"msg-id",
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
const log = vi.fn();
|
|
86
|
+
const platform = createWechatAdapter({
|
|
87
|
+
getWire: () => wire,
|
|
88
|
+
log,
|
|
89
|
+
});
|
|
90
|
+
const chatId = "wx-chat-final-allow";
|
|
91
|
+
const finalText = "done\n━━━ 回答结束 ━━━";
|
|
92
|
+
|
|
93
|
+
// 前8条正常发送
|
|
94
|
+
for (let i = 0; i < 8; i++) {
|
|
95
|
+
await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 第9条非最终消息带后缀
|
|
99
|
+
await expect(platform.sendText(chatId, "chunk 8")).resolves.toBe(true);
|
|
100
|
+
|
|
101
|
+
// 第10条:最终消息应允许发送(不被阻止也不被 queuing)
|
|
102
|
+
await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
|
|
103
|
+
|
|
60
104
|
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
105
|
+
const lastCall = wire.push.mock.calls[9];
|
|
106
|
+
expect(lastCall[1]).toBe(finalText);
|
|
61
107
|
expect(log).toHaveBeenCalledWith(
|
|
62
|
-
"[WECHAT] sendText
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("queues final messages after the claw limit until the user wakes the chat", async () => {
|
|
67
|
-
const wire = {
|
|
68
|
-
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
69
|
-
sendText: vi.fn(
|
|
70
|
-
async (_chatId: string, _text: string, _contextToken?: string) =>
|
|
71
|
-
"msg-id",
|
|
72
|
-
),
|
|
73
|
-
};
|
|
74
|
-
const log = vi.fn();
|
|
75
|
-
const platform = createWechatAdapter({
|
|
76
|
-
getWire: () => wire,
|
|
77
|
-
log,
|
|
78
|
-
});
|
|
79
|
-
const chatId = "wx-chat-final-limit";
|
|
80
|
-
const finalText = "done\n━━━ 回答结束 ━━━";
|
|
81
|
-
|
|
82
|
-
for (let i = 0; i < 10; i++) {
|
|
83
|
-
await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
await expect(platform.sendText(chatId, finalText)).resolves.toBe(true);
|
|
87
|
-
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
88
|
-
expect(log).toHaveBeenCalledWith(
|
|
89
|
-
`[WECHAT] final queued (claw limit): chatId=${chatId} count=11 len=${finalText.length}`,
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
await expect(_flushPendingClawFinalTextForTest(chatId, wire, log)).resolves.toBe(true);
|
|
93
|
-
expect(wire.push).toHaveBeenCalledTimes(11);
|
|
94
|
-
expect(wire.push).toHaveBeenLastCalledWith(chatId, finalText);
|
|
95
|
-
expect(log).toHaveBeenCalledWith(
|
|
96
|
-
`[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${finalText.length}`,
|
|
97
|
-
);
|
|
98
|
-
});
|
|
99
|
-
});
|
|
108
|
+
expect.stringContaining("[WECHAT] sendText OK"),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { appendFile, cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
|
|
8
|
+
import { Domain } from "@larksuiteoapi/node-sdk";
|
|
9
|
+
|
|
8
10
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
9
11
|
import { appendStartupTrace, setupFileLogging } from "./shared.ts";
|
|
10
12
|
import {
|
|
@@ -90,9 +92,25 @@ export interface CodexConfig {
|
|
|
90
92
|
effort: string;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
export type FeishuDomain = "feishu" | "lark";
|
|
96
|
+
|
|
93
97
|
export interface FeishuConfig {
|
|
94
98
|
appId: string;
|
|
95
99
|
appSecret: string;
|
|
100
|
+
/** "feishu" (默认) → https://open.feishu.cn, "lark" → https://open.larksuite.com */
|
|
101
|
+
domain?: FeishuDomain;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 用户配置的 domain 字符串 → SDK Domain 枚举 */
|
|
105
|
+
export function feishuDomainToSdkDomain(domain?: FeishuDomain): Domain {
|
|
106
|
+
return domain === "lark" ? Domain.Lark : Domain.Feishu;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 用户配置的 domain 字符串 → REST API base URL */
|
|
110
|
+
export function feishuDomainToBaseUrl(domain?: FeishuDomain): string {
|
|
111
|
+
return domain === "lark"
|
|
112
|
+
? "https://open.larksuite.com/open-apis"
|
|
113
|
+
: "https://open.feishu.cn/open-apis";
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
export interface PlatformConfig {
|
|
@@ -298,7 +316,7 @@ function autofillToolPathsAfterSampleCopy(configFile: string): void {
|
|
|
298
316
|
|
|
299
317
|
function loadConfig(): AppConfig {
|
|
300
318
|
const defaults: AppConfig = {
|
|
301
|
-
feishu: { appId: "", appSecret: "" },
|
|
319
|
+
feishu: { appId: "", appSecret: "", domain: "feishu" },
|
|
302
320
|
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
303
321
|
port: 18080,
|
|
304
322
|
gitTimeoutSeconds: 180,
|
|
@@ -358,6 +376,8 @@ function loadConfig(): AppConfig {
|
|
|
358
376
|
}
|
|
359
377
|
|
|
360
378
|
const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
|
|
379
|
+
const feishuDomain: FeishuDomain =
|
|
380
|
+
(feishu as { domain?: unknown }).domain === "lark" ? "lark" : "feishu";
|
|
361
381
|
const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
|
|
362
382
|
const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
|
|
363
383
|
const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
|
|
@@ -420,6 +440,7 @@ function loadConfig(): AppConfig {
|
|
|
420
440
|
feishu: {
|
|
421
441
|
appId: feishu.appId ?? "",
|
|
422
442
|
appSecret: feishu.appSecret ?? "",
|
|
443
|
+
domain: feishuDomain,
|
|
423
444
|
},
|
|
424
445
|
platforms: {
|
|
425
446
|
feishu: {
|
|
@@ -497,7 +518,7 @@ export let APP_SECRET = config.feishu.appSecret;
|
|
|
497
518
|
export let FEISHU_ENABLED = config.platforms.feishu.enabled;
|
|
498
519
|
export let ILINK_ENABLED = config.platforms.ilink.enabled;
|
|
499
520
|
export let ILINK_REUSE_TOKEN_ON_START = config.platforms.ilink.reuseTokenOnStart ?? true;
|
|
500
|
-
export
|
|
521
|
+
export let BASE_URL = feishuDomainToBaseUrl(config.feishu.domain);
|
|
501
522
|
export const CHATCCC_PORT = config.port;
|
|
502
523
|
|
|
503
524
|
/** 与 CHATCCC_PORT 一致,供 --local 连接本机中继 */
|
|
@@ -576,6 +597,7 @@ export function applyLoadedConfig(next: AppConfig): void {
|
|
|
576
597
|
|
|
577
598
|
APP_ID = next.feishu.appId;
|
|
578
599
|
APP_SECRET = next.feishu.appSecret;
|
|
600
|
+
BASE_URL = feishuDomainToBaseUrl(next.feishu.domain);
|
|
579
601
|
FEISHU_ENABLED = next.platforms.feishu.enabled;
|
|
580
602
|
ILINK_ENABLED = next.platforms.ilink.enabled;
|
|
581
603
|
ILINK_REUSE_TOKEN_ON_START = next.platforms.ilink.reuseTokenOnStart ?? true;
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
26
26
|
|
|
27
|
-
import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
|
|
27
|
+
import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
|
|
28
28
|
import WebSocket from "ws";
|
|
29
29
|
|
|
30
30
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
|
|
@@ -50,6 +50,8 @@ import {
|
|
|
50
50
|
maskAppId,
|
|
51
51
|
resolveDefaultAgentTool,
|
|
52
52
|
toolDisplayName,
|
|
53
|
+
config,
|
|
54
|
+
feishuDomainToSdkDomain,
|
|
53
55
|
ts,
|
|
54
56
|
} from "./config.ts";
|
|
55
57
|
import { printServiceDidNotStart, printServiceRunningHint } from "./exit-banner.ts";
|
|
@@ -403,7 +405,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
403
405
|
console.error(` 接口: POST ${BASE_URL}/auth/v3/tenant_access_token/internal`);
|
|
404
406
|
console.error(" 常见原因:");
|
|
405
407
|
console.error(
|
|
406
|
-
|
|
408
|
+
` - 本机网络无法访问 ${new URL(BASE_URL).hostname}(可尝试:关闭系统/终端代理、检查防火墙;Windows 可管理员运行 netsh winsock reset 后重启)`,
|
|
407
409
|
);
|
|
408
410
|
console.error(" - App ID / App Secret 与开放平台「凭证与基础信息」不一致");
|
|
409
411
|
console.error(" - 自建应用尚未创建/发布可用版本");
|
|
@@ -594,6 +596,7 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
594
596
|
const wsClient = new WSClient({
|
|
595
597
|
appId: APP_ID,
|
|
596
598
|
appSecret: APP_SECRET,
|
|
599
|
+
domain: feishuDomainToSdkDomain(config.feishu.domain),
|
|
597
600
|
onReady: async () => {
|
|
598
601
|
await rebuildBindingsFromRegistry().catch((err) =>
|
|
599
602
|
console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
|
package/src/orchestrator.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setDefaultCwd,
|
|
22
22
|
getRecentDirs,
|
|
23
23
|
addRecentDir,
|
|
24
|
+
resolveDefaultAgentTool,
|
|
24
25
|
sessionPrefixForTool,
|
|
25
26
|
toolDisplayName,
|
|
26
27
|
ts,
|
|
@@ -240,7 +241,7 @@ export async function handleCommand(
|
|
|
240
241
|
|
|
241
242
|
if (textLower === "/new" || textLower.startsWith("/new ")) {
|
|
242
243
|
const toolArg = text.slice(5).trim().toLowerCase();
|
|
243
|
-
const tool = toolArg ||
|
|
244
|
+
const tool = toolArg || resolveDefaultAgentTool();
|
|
244
245
|
logTrace(tid, "BRANCH", { cmd: "/new", tool });
|
|
245
246
|
const validTools = ["claude", "cursor", "codex"];
|
|
246
247
|
if (!validTools.includes(tool)) {
|
package/src/session.ts
CHANGED
|
@@ -30,6 +30,13 @@ import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
|
30
30
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
31
31
|
import { buildImSkillsPrompt, exportSkillSubDocs } from "./im-skills.ts";
|
|
32
32
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
33
|
+
|
|
34
|
+
// 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
|
|
35
|
+
function compressWechatDisplayText(text: string): string {
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
if (lines.length <= 10) return text;
|
|
38
|
+
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
39
|
+
}
|
|
33
40
|
import { readStreamState, writeStreamState, createEmptyStreamState, fixStaleStreamStates } from "./stream-state.ts";
|
|
34
41
|
import {
|
|
35
42
|
bindChatToSession,
|
|
@@ -1015,7 +1022,7 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1015
1022
|
|
|
1016
1023
|
d.cardBusy = true;
|
|
1017
1024
|
try {
|
|
1018
|
-
const ok = await p.sendText(chatId, delta);
|
|
1025
|
+
const ok = await p.sendText(chatId, compressWechatDisplayText(delta));
|
|
1019
1026
|
if (ok) {
|
|
1020
1027
|
d.lastSentAccLen = state.accumulatedContent.length;
|
|
1021
1028
|
d.lastSentFinalReply = state.finalReply;
|
package/src/web-ui.ts
CHANGED
|
@@ -342,6 +342,9 @@ export function unflattenConfig(flat: Record<string, unknown>): Record<string, u
|
|
|
342
342
|
} else if (key === "CHATCCC_APP_SECRET") {
|
|
343
343
|
result.feishu = result.feishu || {};
|
|
344
344
|
(result.feishu as Record<string, unknown>).appSecret = val;
|
|
345
|
+
} else if (key === "CHATCCC_FEISHU_DOMAIN") {
|
|
346
|
+
result.feishu = result.feishu || {};
|
|
347
|
+
(result.feishu as Record<string, unknown>).domain = val;
|
|
345
348
|
} else if (key === "CHATCCC_FEISHU_ENABLED") {
|
|
346
349
|
result.platforms = result.platforms || {};
|
|
347
350
|
(result.platforms as Record<string, unknown>).feishu = (result.platforms as Record<string, unknown>).feishu || {};
|
|
@@ -639,6 +642,14 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
639
642
|
<input type="password" id="field-CHATCCC_APP_SECRET" placeholder="...">
|
|
640
643
|
<div class="hint">飞书开放平台「凭证与基础信息」→ App Secret</div>
|
|
641
644
|
</div>
|
|
645
|
+
<div class="form-group">
|
|
646
|
+
<label>平台域名</label>
|
|
647
|
+
<select id="field-CHATCCC_FEISHU_DOMAIN">
|
|
648
|
+
<option value="feishu">飞书 (open.feishu.cn)</option>
|
|
649
|
+
<option value="lark">Lark (open.larksuite.com)</option>
|
|
650
|
+
</select>
|
|
651
|
+
<div class="hint">国际版 Lark 用户请选择 Lark</div>
|
|
652
|
+
</div>
|
|
642
653
|
</div>
|
|
643
654
|
</div>
|
|
644
655
|
|
|
@@ -826,6 +837,7 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
826
837
|
</div>
|
|
827
838
|
<div class="config-row"><span class="key">App ID</span><span class="val" id="cfg-APP_ID">-</span></div>
|
|
828
839
|
<div class="config-row"><span class="key">App Secret</span><span class="val" id="cfg-APP_SECRET">-</span></div>
|
|
840
|
+
<div class="config-row"><span class="key">平台域名</span><span class="val" id="cfg-FEISHU_DOMAIN">-</span></div>
|
|
829
841
|
<button class="btn btn-outline" style="margin-top:8px" onclick="editSection('feishu')">编辑</button>
|
|
830
842
|
</div>
|
|
831
843
|
</details>
|
|
@@ -926,7 +938,7 @@ const AGENT_FIELDS = {
|
|
|
926
938
|
cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL'],
|
|
927
939
|
codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_EFFORT']
|
|
928
940
|
};
|
|
929
|
-
const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
|
|
941
|
+
const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET','CHATCCC_FEISHU_DOMAIN'];
|
|
930
942
|
|
|
931
943
|
// 当前选中的 Claude API 模式("official" / "thirdparty")
|
|
932
944
|
// Wizard / Dashboard 都通过这个变量驱动 UI 显隐和提交时的 mode 字段
|
|
@@ -1197,6 +1209,7 @@ function renderStep1() {
|
|
|
1197
1209
|
var f = c.feishu || {};
|
|
1198
1210
|
prefillNested('field-CHATCCC_APP_ID', f.appId);
|
|
1199
1211
|
prefillNested('field-CHATCCC_APP_SECRET', f.appSecret);
|
|
1212
|
+
prefillNested('field-CHATCCC_FEISHU_DOMAIN', f.domain || 'feishu');
|
|
1200
1213
|
// 平台开关:按已有 config 回填;首次配置(无飞书凭证)时默认关闭飞书、开启微信
|
|
1201
1214
|
var hasExistingCreds = Boolean(c.feishu?.appId?.trim() && c.feishu?.appSecret?.trim());
|
|
1202
1215
|
var feishuEnabled = hasExistingCreds
|
|
@@ -1349,6 +1362,7 @@ function renderStep3() {
|
|
|
1349
1362
|
if (state.platformsEnabled.feishu) {
|
|
1350
1363
|
lines.push('<div class="config-row"><span class="key">CHATCCC_APP_ID</span><span class="val">' + (vars.CHATCCC_APP_ID || '<span style="color:#ef4444">未填写</span>') + '</span></div>');
|
|
1351
1364
|
lines.push('<div class="config-row"><span class="key">CHATCCC_APP_SECRET</span><span class="val">' + (vars.CHATCCC_APP_SECRET ? '***已设置***' : '<span style="color:#ef4444">未填写</span>') + '</span></div>');
|
|
1365
|
+
lines.push('<div class="config-row"><span class="key">平台域名</span><span class="val">' + ((vars.CHATCCC_FEISHU_DOMAIN === 'lark') ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)') + '</span></div>');
|
|
1352
1366
|
}
|
|
1353
1367
|
|
|
1354
1368
|
lines.push('<h3 style="margin:16px 0 8px">微信 iLink</h3>');
|
|
@@ -1534,6 +1548,7 @@ function updateDashboardUI() {
|
|
|
1534
1548
|
state.platformsEnabled.feishu = feishuEnabled;
|
|
1535
1549
|
document.getElementById('cfg-APP_ID').textContent = c.feishu && c.feishu.appId ? c.feishu.appId.slice(0,8) + '...' + c.feishu.appId.slice(-4) : '-';
|
|
1536
1550
|
document.getElementById('cfg-APP_SECRET').textContent = c.feishu && c.feishu.appSecret ? '***已设置***' : '-';
|
|
1551
|
+
document.getElementById('cfg-FEISHU_DOMAIN').textContent = (c.feishu && c.feishu.domain === 'lark') ? 'Lark (open.larksuite.com)' : '飞书 (open.feishu.cn)';
|
|
1537
1552
|
|
|
1538
1553
|
// 只显示已启用的 Agent 卡片(按 enabled 字段;缺省时退回到"任一字段非空"兼容旧 config)
|
|
1539
1554
|
var claudeOn = isAgentEnabled(c.claude, CLAUDE_FALLBACK_KEYS);
|
|
@@ -1636,7 +1651,7 @@ function editSection(section) {
|
|
|
1636
1651
|
|
|
1637
1652
|
var html = '';
|
|
1638
1653
|
var labelMap = {
|
|
1639
|
-
'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret',
|
|
1654
|
+
'CHATCCC_APP_ID': 'App ID', 'CHATCCC_APP_SECRET': 'App Secret', 'CHATCCC_FEISHU_DOMAIN': '平台域名',
|
|
1640
1655
|
'CLAUDE_API_KEY': 'API Key', 'CLAUDE_BASE_URL': 'Base URL',
|
|
1641
1656
|
'CHATCCC_ANTHROPIC_MODEL': '模型', 'CHATCCC_ANTHROPIC_SUBAGENT_MODEL': 'Subagent 模型', 'CHATCCC_ANTHROPIC_EFFORT': 'Effort',
|
|
1642
1657
|
'CHATCCC_CURSOR_PATH': 'CLI 路径', 'CHATCCC_CURSOR_MODEL': '模型',
|
|
@@ -1673,6 +1688,7 @@ function editSection(section) {
|
|
|
1673
1688
|
if (section === 'feishu') {
|
|
1674
1689
|
if (key === 'CHATCCC_APP_ID' && state.config.feishu) val = state.config.feishu.appId || '';
|
|
1675
1690
|
else if (key === 'CHATCCC_APP_SECRET' && state.config.feishu) val = state.config.feishu.appSecret || '';
|
|
1691
|
+
else if (key === 'CHATCCC_FEISHU_DOMAIN' && state.config.feishu) val = state.config.feishu.domain || 'feishu';
|
|
1676
1692
|
} else if (section === 'claude' && state.config.claude) {
|
|
1677
1693
|
if (key === 'CLAUDE_API_KEY') val = state.config.claude.apiKey || '';
|
|
1678
1694
|
else if (key === 'CLAUDE_BASE_URL') val = state.config.claude.baseUrl || '';
|
|
@@ -1707,7 +1723,14 @@ function editSection(section) {
|
|
|
1707
1723
|
var groupClass = 'form-group' + (isClaudeSubagentField && claudeApiMode !== 'thirdparty' ? ' hidden' : '');
|
|
1708
1724
|
var subagentAttr = isClaudeSubagentField ? ' data-claude-subagent-field="1"' : '';
|
|
1709
1725
|
html += '<div class="' + groupClass + '"' + subagentAttr + '><label>' + (labelMap[key] || key) + '</label>';
|
|
1710
|
-
|
|
1726
|
+
if (key === 'CHATCCC_FEISHU_DOMAIN') {
|
|
1727
|
+
html += '<select id="edit-' + key + '">';
|
|
1728
|
+
html += '<option value="feishu"' + (val === 'lark' ? '' : ' selected') + '>飞书 (open.feishu.cn)</option>';
|
|
1729
|
+
html += '<option value="lark"' + (val === 'lark' ? ' selected' : '') + '>Lark (open.larksuite.com)</option>';
|
|
1730
|
+
html += '</select>';
|
|
1731
|
+
} else {
|
|
1732
|
+
html += '<input type="' + (isSecret ? 'password' : 'text') + '" id="edit-' + key + '" value="' + String(val).replace(/"/g,'"') + '">';
|
|
1733
|
+
}
|
|
1711
1734
|
html += '</div>';
|
|
1712
1735
|
});
|
|
1713
1736
|
|
package/src/wechat-platform.ts
CHANGED
|
@@ -66,14 +66,20 @@ export function getIlinkWire(): OpenIlinkWire | null {
|
|
|
66
66
|
const contextTokenMap = new Map<string, string>();
|
|
67
67
|
/** chatId → 用户未回复时已连发消息数 */
|
|
68
68
|
const consecutiveSendCount = new Map<string, number>();
|
|
69
|
-
/** chatId →
|
|
70
|
-
const
|
|
69
|
+
/** chatId → 上一次 sendText 成功的时间戳 */
|
|
70
|
+
const lastSendTimeMap = new Map<string, number>();
|
|
71
71
|
const textCardMap = new Map<string, { chatId?: string; text: string; lastSentText: string; lastSentAt: number }>();
|
|
72
72
|
let textCardSeq = 0;
|
|
73
73
|
let platformLog: (msg: string) => void = () => {};
|
|
74
74
|
|
|
75
75
|
const TEXT_CARD_UPDATE_INTERVAL_MS = 30_000;
|
|
76
76
|
|
|
77
|
+
/** 微信连发间隔阈值:第5条起限制 ≥ 此值(默认10秒),测试可覆写 */
|
|
78
|
+
let wxMinSendIntervalMs = 10_000;
|
|
79
|
+
export function _setWxMinSendIntervalMsForTest(ms: number): void {
|
|
80
|
+
wxMinSendIntervalMs = ms;
|
|
81
|
+
}
|
|
82
|
+
|
|
77
83
|
type WechatWireSender = Pick<OpenIlinkWire, "sendText" | "push">;
|
|
78
84
|
|
|
79
85
|
export interface WechatAdapterOptions {
|
|
@@ -89,7 +95,7 @@ function isFinalReplyText(text: string): boolean {
|
|
|
89
95
|
return text.includes("━━━ 回答结束 ━━━");
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
function compressGeneratingText(text: string): string {
|
|
98
|
+
export function compressGeneratingText(text: string): string {
|
|
93
99
|
const lines = text.split("\n");
|
|
94
100
|
if (lines.length <= 10) return text;
|
|
95
101
|
return [...lines.slice(0, 5), "...", ...lines.slice(-5)].join("\n");
|
|
@@ -104,38 +110,10 @@ async function sendWechatTextRaw(wire: WechatWireSender, chatId: string, text: s
|
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
async function flushPendingClawFinalText(
|
|
108
|
-
chatId: string,
|
|
109
|
-
wire: WechatWireSender | null,
|
|
110
|
-
log: (msg: string) => void,
|
|
111
|
-
): Promise<boolean> {
|
|
112
|
-
const text = pendingClawFinalText.get(chatId);
|
|
113
|
-
if (!text || !wire) return false;
|
|
114
|
-
|
|
115
|
-
try {
|
|
116
|
-
await sendWechatTextRaw(wire, chatId, text);
|
|
117
|
-
pendingClawFinalText.delete(chatId);
|
|
118
|
-
consecutiveSendCount.set(chatId, 1);
|
|
119
|
-
log(`[WECHAT] pending final sent after claw wake: chatId=${chatId} len=${text.length}`);
|
|
120
|
-
return true;
|
|
121
|
-
} catch (err) {
|
|
122
|
-
log(`[WECHAT] pending final send failed: ${(err as Error).message}`);
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
113
|
export function _resetWechatClawStateForTest(): void {
|
|
128
114
|
consecutiveSendCount.clear();
|
|
129
|
-
pendingClawFinalText.clear();
|
|
130
115
|
contextTokenMap.clear();
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
export async function _flushPendingClawFinalTextForTest(
|
|
134
|
-
chatId: string,
|
|
135
|
-
wire: WechatWireSender | null,
|
|
136
|
-
log: (msg: string) => void = () => {},
|
|
137
|
-
): Promise<boolean> {
|
|
138
|
-
return flushPendingClawFinalText(chatId, wire, log);
|
|
116
|
+
lastSendTimeMap.clear();
|
|
139
117
|
}
|
|
140
118
|
|
|
141
119
|
// ---------------------------------------------------------------------------
|
|
@@ -206,29 +184,31 @@ export function createWechatAdapter(
|
|
|
206
184
|
|
|
207
185
|
const isFinal = isFinalReplyText(text);
|
|
208
186
|
|
|
209
|
-
// 第
|
|
210
|
-
if (count ===
|
|
211
|
-
text = text + "\n
|
|
187
|
+
// 第9条且非最终消息:附加 claw 限制提示,此后禁止继续发送生成消息
|
|
188
|
+
if (count === 9 && !isFinal) {
|
|
189
|
+
text = text + "\n---由于微信claw机制限制,不再发送过程,稍后把最终结果发送给你---";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 超过9条后不允许发送非最终消息(生成消息),最终结果消息仍然允许
|
|
193
|
+
if (count > 9 && !isFinal) {
|
|
194
|
+
log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
|
|
195
|
+
return false;
|
|
212
196
|
}
|
|
213
197
|
|
|
214
|
-
//
|
|
215
|
-
if (count >
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
} else {
|
|
221
|
-
log(`[WECHAT] sendText skipped (claw limit): chatId=${chatId} count=${count}`);
|
|
222
|
-
return false;
|
|
198
|
+
// 第5条起限制发送间隔 ≥ 10 秒(前4条保持原有 3s 显示循环节奏)
|
|
199
|
+
if (count > 4) {
|
|
200
|
+
const lastSentAt = lastSendTimeMap.get(chatId) ?? 0;
|
|
201
|
+
const elapsed = Date.now() - lastSentAt;
|
|
202
|
+
if (elapsed < wxMinSendIntervalMs) {
|
|
203
|
+
await new Promise((r) => setTimeout(r, wxMinSendIntervalMs - elapsed));
|
|
223
204
|
}
|
|
224
205
|
}
|
|
225
206
|
|
|
226
207
|
try {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
log(`[WECHAT] sendText OK: chatId=${chatId} len=${sendText.length} count=${count} text="${preview}"`);
|
|
208
|
+
await sendWechatTextRaw(wire, chatId, text);
|
|
209
|
+
lastSendTimeMap.set(chatId, Date.now());
|
|
210
|
+
const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
211
|
+
log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
|
|
232
212
|
return true;
|
|
233
213
|
} catch (err) {
|
|
234
214
|
log(`[WECHAT] sendText failed: ${(err as Error).message}`);
|
|
@@ -621,12 +601,6 @@ async function handleWechatMessage(
|
|
|
621
601
|
// 用户回复,重置 claw 连发计数
|
|
622
602
|
consecutiveSendCount.set(chatId, 0);
|
|
623
603
|
|
|
624
|
-
// 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
|
|
625
|
-
if (pendingClawFinalText.has(chatId)) {
|
|
626
|
-
await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
604
|
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
631
605
|
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
632
606
|
handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|