chatccc 0.2.59 → 0.2.61
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 +1 -1
- package/im-skills/wechat-skill/receive-send-image.md +38 -38
- package/im-skills/wechat-skill/send-image.mjs +79 -79
- package/im-skills/wechat-skill/skill.md +10 -10
- package/package.json +1 -1
- package/src/__tests__/claude-adapter.test.ts +48 -48
- package/src/__tests__/config-reload.test.ts +14 -14
- package/src/__tests__/config-sample.test.ts +22 -22
- package/src/__tests__/web-ui.test.ts +23 -23
- package/src/__tests__/wechat-platform.test.ts +29 -17
- package/src/adapters/claude-adapter.ts +40 -40
- package/src/session.ts +8 -1
- package/src/wechat-platform.ts +26 -50
package/config.sample.json
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
# Receiving & Sending Images (WeChat)
|
|
2
|
-
|
|
3
|
-
## Receive Images
|
|
4
|
-
|
|
5
|
-
Images sent to the bot are **automatically downloaded** to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
6
|
-
|
|
7
|
-
The message text you receive will include the downloaded path in the format:
|
|
8
|
-
```
|
|
9
|
-
[图片] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.png
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
You can read the image file at that path to understand what the user sent.
|
|
13
|
-
|
|
14
|
-
## Send Images
|
|
15
|
-
|
|
16
|
-
### Script (recommended)
|
|
17
|
-
```bash
|
|
18
|
-
node "{{wechat_send_image_script}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the image to the most recent chat.
|
|
22
|
-
|
|
23
|
-
### Direct usage in test scripts
|
|
24
|
-
|
|
25
|
-
The underlying SDK call is:
|
|
26
|
-
```ts
|
|
27
|
-
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
-
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
-
await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "caption");
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
### Rules
|
|
33
|
-
|
|
34
|
-
- Save or choose a local image file first.
|
|
35
|
-
- Use an absolute local path.
|
|
36
|
-
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
-
- Max image size: 10MB (SDK guideline; larger files upload slower).
|
|
38
|
-
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
1
|
+
# Receiving & Sending Images (WeChat)
|
|
2
|
+
|
|
3
|
+
## Receive Images
|
|
4
|
+
|
|
5
|
+
Images sent to the bot are **automatically downloaded** to `~/.chatccc/images/downloads/` with the `wx_` filename prefix.
|
|
6
|
+
|
|
7
|
+
The message text you receive will include the downloaded path in the format:
|
|
8
|
+
```
|
|
9
|
+
[图片] C:\Users\<用户名>\.chatccc\images\downloads\wx_<key>.png
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You can read the image file at that path to understand what the user sent.
|
|
13
|
+
|
|
14
|
+
## Send Images
|
|
15
|
+
|
|
16
|
+
### Script (recommended)
|
|
17
|
+
```bash
|
|
18
|
+
node "{{wechat_send_image_script}}" --path "<absolute image path>" --caption "<optional caption>"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The script reads the current WeChat session's auth token, chat ID, and context token from `~/.chatccc/state/ilink-auth.json`, then sends the image to the most recent chat.
|
|
22
|
+
|
|
23
|
+
### Direct usage in test scripts
|
|
24
|
+
|
|
25
|
+
The underlying SDK call is:
|
|
26
|
+
```ts
|
|
27
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
28
|
+
const wire = new OpenIlinkWire(token, { base_url: baseUrl });
|
|
29
|
+
await wire.sendMediaFile(chatId, contextToken, imageBuffer, "filename.png", "caption");
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Rules
|
|
33
|
+
|
|
34
|
+
- Save or choose a local image file first.
|
|
35
|
+
- Use an absolute local path.
|
|
36
|
+
- Supported formats: .png, .jpg, .jpeg, .webp, .gif, .bmp.
|
|
37
|
+
- Max image size: 10MB (SDK guideline; larger files upload slower).
|
|
38
|
+
- Only send an image when the user asked for one or when it materially helps the answer.
|
|
39
39
|
- **Claw 限制**: 图片发送也计入微信 claw 连发计数,连续 10 条未回复会截断。
|
|
@@ -1,80 +1,80 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { basename, extname, join } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
|
|
6
|
-
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
-
|
|
8
|
-
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
-
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
-
const ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
11
|
-
|
|
12
|
-
function parseArgs(argv) {
|
|
13
|
-
const result = {};
|
|
14
|
-
for (let i = 0; i < argv.length; i++) {
|
|
15
|
-
const key = argv[i];
|
|
16
|
-
if (!key.startsWith("--")) continue;
|
|
17
|
-
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
-
result[key.slice(2)] = value;
|
|
19
|
-
}
|
|
20
|
-
return result;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function usage() {
|
|
24
|
-
console.error(`Usage:
|
|
25
|
-
node ${basename(process.argv[1])} --path <absolute image path> [--caption <text>]`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function main() {
|
|
29
|
-
const args = parseArgs(process.argv.slice(2));
|
|
30
|
-
const imagePath = args.path;
|
|
31
|
-
const caption = args.caption || "";
|
|
32
|
-
|
|
33
|
-
if (!imagePath) {
|
|
34
|
-
usage();
|
|
35
|
-
process.exit(1);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const ext = extname(imagePath).toLowerCase();
|
|
39
|
-
if (!ALLOWED_EXTS.has(ext)) {
|
|
40
|
-
console.error(`Unsupported image extension: ${ext || "(none)"}`);
|
|
41
|
-
process.exit(1);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const st = statSync(imagePath);
|
|
45
|
-
if (!st.isFile()) {
|
|
46
|
-
console.error("Image path is not a file");
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
if (st.size > MAX_IMAGE_BYTES) {
|
|
50
|
-
console.error("Image file exceeds 10MB limit");
|
|
51
|
-
process.exit(1);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
55
|
-
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
56
|
-
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
61
|
-
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
62
|
-
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const imgData = readFileSync(imagePath);
|
|
67
|
-
const fileName = basename(imagePath);
|
|
68
|
-
|
|
69
|
-
const wire = new OpenIlinkWire(snap.token, {
|
|
70
|
-
base_url: snap.baseUrl,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, imgData, fileName, caption);
|
|
74
|
-
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
main().catch((err) => {
|
|
78
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
-
process.exit(1);
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { Client as OpenIlinkWire } from "@openilink/openilink-sdk-node";
|
|
7
|
+
|
|
8
|
+
const ILINK_AUTH_PATH = join(homedir(), ".chatccc", "state", "ilink-auth.json");
|
|
9
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
const ALLOWED_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const key = argv[i];
|
|
16
|
+
if (!key.startsWith("--")) continue;
|
|
17
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "true";
|
|
18
|
+
result[key.slice(2)] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.error(`Usage:
|
|
25
|
+
node ${basename(process.argv[1])} --path <absolute image path> [--caption <text>]`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
const args = parseArgs(process.argv.slice(2));
|
|
30
|
+
const imagePath = args.path;
|
|
31
|
+
const caption = args.caption || "";
|
|
32
|
+
|
|
33
|
+
if (!imagePath) {
|
|
34
|
+
usage();
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ext = extname(imagePath).toLowerCase();
|
|
39
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
40
|
+
console.error(`Unsupported image extension: ${ext || "(none)"}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const st = statSync(imagePath);
|
|
45
|
+
if (!st.isFile()) {
|
|
46
|
+
console.error("Image path is not a file");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
if (st.size > MAX_IMAGE_BYTES) {
|
|
50
|
+
console.error("Image file exceeds 10MB limit");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!existsSync(ILINK_AUTH_PATH)) {
|
|
55
|
+
console.error(`Auth snapshot not found: ${ILINK_AUTH_PATH}`);
|
|
56
|
+
console.error("Make sure the WeChat iLink platform is logged in.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const snap = JSON.parse(readFileSync(ILINK_AUTH_PATH, "utf8"));
|
|
61
|
+
if (!snap.token || !snap.lastChatId || !snap.contextToken) {
|
|
62
|
+
console.error("Auth snapshot missing token, lastChatId, or contextToken.");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const imgData = readFileSync(imagePath);
|
|
67
|
+
const fileName = basename(imagePath);
|
|
68
|
+
|
|
69
|
+
const wire = new OpenIlinkWire(snap.token, {
|
|
70
|
+
base_url: snap.baseUrl,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await wire.sendMediaFile(snap.lastChatId, snap.contextToken, imgData, fileName, caption);
|
|
74
|
+
console.log(JSON.stringify({ ok: true, sentTo: 1 }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
79
|
+
process.exit(1);
|
|
80
80
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: wechat-skill
|
|
3
|
-
description: WeChat iLink local skills for sending and receiving images.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Current working directory: {{cwd}}
|
|
7
|
-
|
|
8
|
-
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper scripts below instead.
|
|
9
|
-
|
|
10
|
-
- **Receive images**: Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[图片] <absolute path>`.
|
|
1
|
+
---
|
|
2
|
+
name: wechat-skill
|
|
3
|
+
description: WeChat iLink local skills for sending and receiving images.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Current working directory: {{cwd}}
|
|
7
|
+
|
|
8
|
+
WeChat images are handled through the iLink SDK. The local server does NOT have HTTP RPC endpoints for WeChat; use the helper scripts below instead.
|
|
9
|
+
|
|
10
|
+
- **Receive images**: Images sent to the bot are automatically downloaded to `~/.chatccc/images/downloads/` with the filename prefix `wx_`. The message text will include the local path as `[图片] <absolute path>`.
|
|
11
11
|
- **Send images**: Use the send-image helper script — read `{{im_skills_cache_dir}}/wechat-skill/receive-send-image.md`
|
package/package.json
CHANGED
|
@@ -771,10 +771,10 @@ describe("createClaudeAdapter — env 注入", () => {
|
|
|
771
771
|
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://api.deepseek.com/anthropic");
|
|
772
772
|
});
|
|
773
773
|
|
|
774
|
-
it("apiKey + baseUrl 都设置 → 同时覆盖", async () => {
|
|
775
|
-
setupMockCreateSession();
|
|
776
|
-
const adapter = createClaudeAdapter({
|
|
777
|
-
model: "",
|
|
774
|
+
it("apiKey + baseUrl 都设置 → 同时覆盖", async () => {
|
|
775
|
+
setupMockCreateSession();
|
|
776
|
+
const adapter = createClaudeAdapter({
|
|
777
|
+
model: "",
|
|
778
778
|
effort: "",
|
|
779
779
|
isEmpty: (v) => v.trim() === "",
|
|
780
780
|
apiKey: "sk-x",
|
|
@@ -784,50 +784,50 @@ describe("createClaudeAdapter — env 注入", () => {
|
|
|
784
784
|
await adapter.createSession("/cwd");
|
|
785
785
|
|
|
786
786
|
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
787
|
-
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-x");
|
|
788
|
-
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
it("官方 API 模式下 subagentModel 不注入 env,也不覆盖 Claude 登录环境", async () => {
|
|
792
|
-
setupMockCreateSession();
|
|
793
|
-
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
|
|
794
|
-
const adapter = createClaudeAdapter({
|
|
795
|
-
model: "claude-sonnet-4-6",
|
|
796
|
-
subagentModel: "claude-haiku-4-5-20251001",
|
|
797
|
-
effort: "",
|
|
798
|
-
isEmpty: (v) => v.trim() === "",
|
|
799
|
-
apiKey: "",
|
|
800
|
-
baseUrl: "",
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
await adapter.createSession("/cwd");
|
|
804
|
-
|
|
805
|
-
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
806
|
-
expect(opts).not.toHaveProperty("env");
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
it("第三方 API 模式下 subagentModel 通过 CLAUDE_CODE_SUBAGENT_MODEL 覆盖 Haiku", async () => {
|
|
810
|
-
setupMockCreateSession();
|
|
811
|
-
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
|
|
812
|
-
const adapter = createClaudeAdapter({
|
|
813
|
-
model: "claude-sonnet-4-6",
|
|
814
|
-
subagentModel: "claude-haiku-4-5-20251001",
|
|
815
|
-
effort: "",
|
|
816
|
-
isEmpty: (v) => v.trim() === "",
|
|
817
|
-
apiKey: "sk-thirdparty",
|
|
818
|
-
baseUrl: "https://gateway.example/anthropic",
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
await adapter.createSession("/cwd");
|
|
822
|
-
|
|
823
|
-
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
824
|
-
expect(opts.model).toBe("claude-sonnet-4-6");
|
|
825
|
-
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-thirdparty");
|
|
826
|
-
expect(opts.env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
|
|
827
|
-
expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it("ChatCCC API config is isolated from Claude settings auth/model env", async () => {
|
|
787
|
+
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-x");
|
|
788
|
+
expect(opts.env.ANTHROPIC_BASE_URL).toBe("https://gateway.example/anthropic");
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("官方 API 模式下 subagentModel 不注入 env,也不覆盖 Claude 登录环境", async () => {
|
|
792
|
+
setupMockCreateSession();
|
|
793
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
|
|
794
|
+
const adapter = createClaudeAdapter({
|
|
795
|
+
model: "claude-sonnet-4-6",
|
|
796
|
+
subagentModel: "claude-haiku-4-5-20251001",
|
|
797
|
+
effort: "",
|
|
798
|
+
isEmpty: (v) => v.trim() === "",
|
|
799
|
+
apiKey: "",
|
|
800
|
+
baseUrl: "",
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await adapter.createSession("/cwd");
|
|
804
|
+
|
|
805
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
806
|
+
expect(opts).not.toHaveProperty("env");
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it("第三方 API 模式下 subagentModel 通过 CLAUDE_CODE_SUBAGENT_MODEL 覆盖 Haiku", async () => {
|
|
810
|
+
setupMockCreateSession();
|
|
811
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-login";
|
|
812
|
+
const adapter = createClaudeAdapter({
|
|
813
|
+
model: "claude-sonnet-4-6",
|
|
814
|
+
subagentModel: "claude-haiku-4-5-20251001",
|
|
815
|
+
effort: "",
|
|
816
|
+
isEmpty: (v) => v.trim() === "",
|
|
817
|
+
apiKey: "sk-thirdparty",
|
|
818
|
+
baseUrl: "https://gateway.example/anthropic",
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await adapter.createSession("/cwd");
|
|
822
|
+
|
|
823
|
+
const opts = sdk.unstable_v2_createSession.mock.calls[0][0];
|
|
824
|
+
expect(opts.model).toBe("claude-sonnet-4-6");
|
|
825
|
+
expect(opts.env.ANTHROPIC_API_KEY).toBe("sk-thirdparty");
|
|
826
|
+
expect(opts.env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
|
|
827
|
+
expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("ChatCCC API config is isolated from Claude settings auth/model env", async () => {
|
|
831
831
|
setupMockCreateSession();
|
|
832
832
|
process.env.ANTHROPIC_AUTH_TOKEN = "token-from-claude-settings";
|
|
833
833
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-from-claude-settings";
|
|
@@ -10,9 +10,9 @@ import {
|
|
|
10
10
|
CHATCCC_PORT,
|
|
11
11
|
CLAUDE_API_KEY,
|
|
12
12
|
CLAUDE_BASE_URL,
|
|
13
|
-
CLAUDE_EFFORT,
|
|
14
|
-
CLAUDE_MODEL,
|
|
15
|
-
CLAUDE_SUBAGENT_MODEL,
|
|
13
|
+
CLAUDE_EFFORT,
|
|
14
|
+
CLAUDE_MODEL,
|
|
15
|
+
CLAUDE_SUBAGENT_MODEL,
|
|
16
16
|
CURSOR_AGENT_ARGS,
|
|
17
17
|
CURSOR_AGENT_COMMAND,
|
|
18
18
|
FEISHU_ENABLED,
|
|
@@ -44,10 +44,10 @@ const baseAppConfig: AppConfig = {
|
|
|
44
44
|
allowInterrupt: false,
|
|
45
45
|
claude: {
|
|
46
46
|
enabled: true,
|
|
47
|
-
defaultAgent: true,
|
|
48
|
-
model: "initial-model",
|
|
49
|
-
subagentModel: "initial-subagent-model",
|
|
50
|
-
effort: "initial-effort",
|
|
47
|
+
defaultAgent: true,
|
|
48
|
+
model: "initial-model",
|
|
49
|
+
subagentModel: "initial-subagent-model",
|
|
50
|
+
effort: "initial-effort",
|
|
51
51
|
apiKey: "sk-initial",
|
|
52
52
|
baseUrl: "https://initial.gw/anthropic",
|
|
53
53
|
},
|
|
@@ -80,22 +80,22 @@ describe("applyLoadedConfig — 刷新 export let 常量", () => {
|
|
|
80
80
|
expect(APP_SECRET).toBe("NEW_APP_SECRET");
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it("更新 Claude 配置(model / subagentModel / effort / apiKey / baseUrl)", () => {
|
|
83
|
+
it("更新 Claude 配置(model / subagentModel / effort / apiKey / baseUrl)", () => {
|
|
84
84
|
applyLoadedConfig({
|
|
85
85
|
...structuredClone(baseAppConfig),
|
|
86
86
|
claude: {
|
|
87
87
|
enabled: true,
|
|
88
|
-
defaultAgent: true,
|
|
89
|
-
model: "claude-sonnet-4-6",
|
|
90
|
-
subagentModel: "claude-haiku-4-5-20251001",
|
|
91
|
-
effort: "high",
|
|
88
|
+
defaultAgent: true,
|
|
89
|
+
model: "claude-sonnet-4-6",
|
|
90
|
+
subagentModel: "claude-haiku-4-5-20251001",
|
|
91
|
+
effort: "high",
|
|
92
92
|
apiKey: "sk-newkey",
|
|
93
93
|
baseUrl: "https://gw2.example/anthropic",
|
|
94
94
|
},
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
|
|
98
|
-
expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
|
|
97
|
+
expect(CLAUDE_MODEL).toBe("claude-sonnet-4-6");
|
|
98
|
+
expect(CLAUDE_SUBAGENT_MODEL).toBe("claude-haiku-4-5-20251001");
|
|
99
99
|
expect(CLAUDE_EFFORT).toBe("high");
|
|
100
100
|
expect(CLAUDE_API_KEY).toBe("sk-newkey");
|
|
101
101
|
expect(CLAUDE_BASE_URL).toBe("https://gw2.example/anthropic");
|
|
@@ -6,25 +6,25 @@ import { describe, expect, it } from "vitest";
|
|
|
6
6
|
describe("config.sample.json", () => {
|
|
7
7
|
it("enables Feishu and WeChat iLink by default", () => {
|
|
8
8
|
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
9
|
-
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
10
|
-
platforms?: {
|
|
11
|
-
feishu?: { enabled?: unknown };
|
|
12
|
-
ilink?: { enabled?: unknown };
|
|
13
|
-
};
|
|
14
|
-
claude?: { model?: unknown; subagentModel?: unknown };
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
expect(sample.platforms?.feishu?.enabled).toBe(true);
|
|
18
|
-
expect(sample.platforms?.ilink?.enabled).toBe(true);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("uses an Anthropic official main model and leaves subagent override empty by default", () => {
|
|
22
|
-
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
23
|
-
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
24
|
-
claude?: { model?: unknown; subagentModel?: unknown };
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
expect(sample.claude?.model).toBe("claude-sonnet-4-6");
|
|
28
|
-
expect(sample.claude?.subagentModel).toBe("");
|
|
29
|
-
});
|
|
30
|
-
});
|
|
9
|
+
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
10
|
+
platforms?: {
|
|
11
|
+
feishu?: { enabled?: unknown };
|
|
12
|
+
ilink?: { enabled?: unknown };
|
|
13
|
+
};
|
|
14
|
+
claude?: { model?: unknown; subagentModel?: unknown };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
expect(sample.platforms?.feishu?.enabled).toBe(true);
|
|
18
|
+
expect(sample.platforms?.ilink?.enabled).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses an Anthropic official main model and leaves subagent override empty by default", () => {
|
|
22
|
+
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
23
|
+
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
24
|
+
claude?: { model?: unknown; subagentModel?: unknown };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
expect(sample.claude?.model).toBe("claude-sonnet-4-6");
|
|
28
|
+
expect(sample.claude?.subagentModel).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
applyClaudeApiMode,
|
|
4
|
-
chooseStartPath,
|
|
5
|
-
detectClaudeApiMode,
|
|
6
|
-
unflattenConfig,
|
|
7
|
-
} from "../web-ui.ts";
|
|
3
|
+
applyClaudeApiMode,
|
|
4
|
+
chooseStartPath,
|
|
5
|
+
detectClaudeApiMode,
|
|
6
|
+
unflattenConfig,
|
|
7
|
+
} from "../web-ui.ts";
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// detectClaudeApiMode — 加载已有 config 时如何判定 UI 初始模式
|
|
@@ -57,7 +57,7 @@ describe("detectClaudeApiMode", () => {
|
|
|
57
57
|
// - mode 未传 默认按 official 兌底(不保留可能误填的密钥)
|
|
58
58
|
// ---------------------------------------------------------------------------
|
|
59
59
|
|
|
60
|
-
describe("applyClaudeApiMode", () => {
|
|
60
|
+
describe("applyClaudeApiMode", () => {
|
|
61
61
|
it("mode=official 时清空 CLAUDE_API_KEY / CLAUDE_BASE_URL(即使 vars 没传)", () => {
|
|
62
62
|
const out = applyClaudeApiMode({ CHATCCC_APP_ID: "x" }, "official");
|
|
63
63
|
expect(out).toEqual({
|
|
@@ -128,23 +128,23 @@ describe("applyClaudeApiMode", () => {
|
|
|
128
128
|
expect(input).toEqual({ CLAUDE_API_KEY: "sk-x" }); // 原对象未变
|
|
129
129
|
expect(out).not.toBe(input);
|
|
130
130
|
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
describe("unflattenConfig", () => {
|
|
134
|
-
it("maps Claude subagent model into claude.subagentModel", () => {
|
|
135
|
-
expect(
|
|
136
|
-
unflattenConfig({
|
|
137
|
-
CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
138
|
-
CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
|
|
139
|
-
}),
|
|
140
|
-
).toEqual({
|
|
141
|
-
claude: {
|
|
142
|
-
model: "claude-sonnet-4-6",
|
|
143
|
-
subagentModel: "claude-haiku-4-5-20251001",
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("unflattenConfig", () => {
|
|
134
|
+
it("maps Claude subagent model into claude.subagentModel", () => {
|
|
135
|
+
expect(
|
|
136
|
+
unflattenConfig({
|
|
137
|
+
CHATCCC_ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
138
|
+
CHATCCC_ANTHROPIC_SUBAGENT_MODEL: "claude-haiku-4-5-20251001",
|
|
139
|
+
}),
|
|
140
|
+
).toEqual({
|
|
141
|
+
claude: {
|
|
142
|
+
model: "claude-sonnet-4-6",
|
|
143
|
+
subagentModel: "claude-haiku-4-5-20251001",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
148
|
|
|
149
149
|
// ---------------------------------------------------------------------------
|
|
150
150
|
// chooseStartPath — /api/start 的路径选择
|
|
@@ -2,14 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { buildHelpCard } from "../cards.ts";
|
|
4
4
|
import {
|
|
5
|
-
_flushPendingClawFinalTextForTest,
|
|
6
5
|
_resetWechatClawStateForTest,
|
|
6
|
+
_setWxMinSendIntervalMsForTest,
|
|
7
7
|
createWechatAdapter,
|
|
8
8
|
} from "../wechat-platform.ts";
|
|
9
9
|
|
|
10
10
|
describe("createWechatAdapter", () => {
|
|
11
11
|
beforeEach(() => {
|
|
12
12
|
_resetWechatClawStateForTest();
|
|
13
|
+
_setWxMinSendIntervalMsForTest(0); // 测试中禁用发送间隔限制
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
it("degrades raw cards to plain text messages", async () => {
|
|
@@ -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,18 +53,28 @@ 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
|
-
|
|
60
|
-
expect(
|
|
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机制限制,不再发送过程,稍后把最终结果发送给你");
|
|
61
72
|
expect(log).toHaveBeenCalledWith(
|
|
62
|
-
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=
|
|
73
|
+
"[WECHAT] sendText skipped (claw limit): chatId=wx-chat-limit count=10",
|
|
63
74
|
);
|
|
64
75
|
});
|
|
65
76
|
|
|
66
|
-
it("
|
|
77
|
+
it("allows final messages through after the 9th message claw limit", async () => {
|
|
67
78
|
const wire = {
|
|
68
79
|
push: vi.fn(async (_chatId: string, _text: string) => "msg-id"),
|
|
69
80
|
sendText: vi.fn(
|
|
@@ -76,24 +87,25 @@ describe("createWechatAdapter", () => {
|
|
|
76
87
|
getWire: () => wire,
|
|
77
88
|
log,
|
|
78
89
|
});
|
|
79
|
-
const chatId = "wx-chat-final-
|
|
90
|
+
const chatId = "wx-chat-final-allow";
|
|
80
91
|
const finalText = "done\n━━━ 回答结束 ━━━";
|
|
81
92
|
|
|
82
|
-
|
|
93
|
+
// 前8条正常发送
|
|
94
|
+
for (let i = 0; i < 8; i++) {
|
|
83
95
|
await expect(platform.sendText(chatId, `chunk ${i}`)).resolves.toBe(true);
|
|
84
96
|
}
|
|
85
97
|
|
|
98
|
+
// 第9条非最终消息带后缀
|
|
99
|
+
await expect(platform.sendText(chatId, "chunk 8")).resolves.toBe(true);
|
|
100
|
+
|
|
101
|
+
// 第10条:最终消息应允许发送(不被阻止也不被 queuing)
|
|
86
102
|
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
103
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
expect(
|
|
104
|
+
expect(wire.push).toHaveBeenCalledTimes(10);
|
|
105
|
+
const lastCall = wire.push.mock.calls[9];
|
|
106
|
+
expect(lastCall[1]).toBe(finalText);
|
|
95
107
|
expect(log).toHaveBeenCalledWith(
|
|
96
|
-
|
|
108
|
+
expect.stringContaining("[WECHAT] sendText OK"),
|
|
97
109
|
);
|
|
98
110
|
});
|
|
99
111
|
});
|
|
@@ -86,33 +86,33 @@ function buildSdkEnv(
|
|
|
86
86
|
baseUrl: string | undefined,
|
|
87
87
|
subagentModel: string | undefined,
|
|
88
88
|
): Record<string, string | undefined> | undefined {
|
|
89
|
-
const apiKeyTrim = (apiKey ?? "").trim();
|
|
90
|
-
const baseUrlTrim = (baseUrl ?? "").trim();
|
|
91
|
-
const subagentModelTrim = (subagentModel ?? "").trim();
|
|
92
|
-
const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
|
|
93
|
-
if (!hasApiOverride) return undefined;
|
|
94
|
-
|
|
95
|
-
const env: Record<string, string | undefined> = { ...process.env };
|
|
96
|
-
// ChatCCC's third-party Claude API config is authoritative when present.
|
|
97
|
-
// Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
|
|
98
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
99
|
-
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
100
|
-
delete env.ANTHROPIC_MODEL;
|
|
101
|
-
delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
102
|
-
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
103
|
-
delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
104
|
-
delete env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
105
|
-
delete env.CLAUDE_CODE_EFFORT_LEVEL;
|
|
106
|
-
|
|
107
|
-
if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
|
|
108
|
-
if (baseUrlTrim) {
|
|
109
|
-
env.ANTHROPIC_BASE_URL = baseUrlTrim;
|
|
110
|
-
} else {
|
|
111
|
-
delete env.ANTHROPIC_BASE_URL;
|
|
112
|
-
}
|
|
113
|
-
if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
|
|
114
|
-
return env;
|
|
115
|
-
}
|
|
89
|
+
const apiKeyTrim = (apiKey ?? "").trim();
|
|
90
|
+
const baseUrlTrim = (baseUrl ?? "").trim();
|
|
91
|
+
const subagentModelTrim = (subagentModel ?? "").trim();
|
|
92
|
+
const hasApiOverride = Boolean(apiKeyTrim || baseUrlTrim);
|
|
93
|
+
if (!hasApiOverride) return undefined;
|
|
94
|
+
|
|
95
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
96
|
+
// ChatCCC's third-party Claude API config is authoritative when present.
|
|
97
|
+
// Remove Claude Code/user settings env that can silently override gateway/auth/model choice.
|
|
98
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
99
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
100
|
+
delete env.ANTHROPIC_MODEL;
|
|
101
|
+
delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
102
|
+
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
103
|
+
delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
104
|
+
delete env.CLAUDE_CODE_SUBAGENT_MODEL;
|
|
105
|
+
delete env.CLAUDE_CODE_EFFORT_LEVEL;
|
|
106
|
+
|
|
107
|
+
if (apiKeyTrim) env.ANTHROPIC_API_KEY = apiKeyTrim;
|
|
108
|
+
if (baseUrlTrim) {
|
|
109
|
+
env.ANTHROPIC_BASE_URL = baseUrlTrim;
|
|
110
|
+
} else {
|
|
111
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
112
|
+
}
|
|
113
|
+
if (subagentModelTrim) env.CLAUDE_CODE_SUBAGENT_MODEL = subagentModelTrim;
|
|
114
|
+
return env;
|
|
115
|
+
}
|
|
116
116
|
|
|
117
117
|
function resolveSettingSources(
|
|
118
118
|
_apiKey: string | undefined,
|
|
@@ -291,19 +291,19 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
291
291
|
this.baseUrl,
|
|
292
292
|
this.subagentModel,
|
|
293
293
|
);
|
|
294
|
-
const session = unstable_v2_resumeSession(
|
|
295
|
-
sessionId,
|
|
296
|
-
sessionOpts as any,
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
if (signal?.aborted) {
|
|
300
|
-
session.close();
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
const onAbort = () => { session.close(); };
|
|
304
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
305
|
-
|
|
306
|
-
await session.send(userText);
|
|
294
|
+
const session = unstable_v2_resumeSession(
|
|
295
|
+
sessionId,
|
|
296
|
+
sessionOpts as any,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
if (signal?.aborted) {
|
|
300
|
+
session.close();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const onAbort = () => { session.close(); };
|
|
304
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
305
|
+
|
|
306
|
+
await session.send(userText);
|
|
307
307
|
|
|
308
308
|
const stream = session.stream();
|
|
309
309
|
|
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/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,25 +184,29 @@ 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
208
|
await sendWechatTextRaw(wire, chatId, text);
|
|
209
|
+
lastSendTimeMap.set(chatId, Date.now());
|
|
228
210
|
const preview = text.length > 60 ? text.slice(0, 60) + "..." : text;
|
|
229
211
|
log(`[WECHAT] sendText OK: chatId=${chatId} len=${text.length} count=${count} text="${preview}"`);
|
|
230
212
|
return true;
|
|
@@ -619,12 +601,6 @@ async function handleWechatMessage(
|
|
|
619
601
|
// 用户回复,重置 claw 连发计数
|
|
620
602
|
consecutiveSendCount.set(chatId, 0);
|
|
621
603
|
|
|
622
|
-
// 如果上一轮最终消息因 claw 被暂存,这次用户消息只作为唤醒使用。
|
|
623
|
-
if (pendingClawFinalText.has(chatId)) {
|
|
624
|
-
await flushPendingClawFinalText(chatId, ilinkWire, platformLog);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
604
|
// WeChat 中所有会话都视为 p2p,/new 复用 p2p 路径(等同飞书 /newh 效果)
|
|
629
605
|
// 不 await:避免长 prompt 阻塞后续消息处理(如 /cd、/stop 等命令)
|
|
630
606
|
handler(fullText, chatId, chatId, msgTimestamp, "p2p").catch((err) => {
|