chatccc 0.2.52 → 0.2.54
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/README.md +95 -256
- package/bin/chatccc.mjs +23 -23
- package/demo/ilink_echo_probe.ts +222 -222
- package/im-skills/feishu-skill/download-video.mjs +162 -162
- package/im-skills/feishu-skill/receive-send-file.md +66 -66
- package/im-skills/feishu-skill/receive-send-image.md +27 -27
- package/im-skills/feishu-skill/send-file.mjs +50 -50
- package/im-skills/feishu-skill/send-image.mjs +50 -50
- package/im-skills/feishu-skill/skill.md +10 -10
- package/package.json +59 -59
- package/src/__tests__/agent-image-rpc.test.ts +33 -33
- package/src/__tests__/agent-rpc-body.test.ts +41 -41
- package/src/__tests__/card-plain-text.test.ts +42 -42
- package/src/__tests__/codex-adapter.test.ts +304 -304
- package/src/__tests__/config-reload.test.ts +26 -26
- package/src/__tests__/config-sample.test.ts +19 -19
- package/src/__tests__/crash-logging.test.ts +62 -62
- package/src/__tests__/fixtures/codex_simple_text.jsonl +4 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +6 -6
- package/src/__tests__/im-skills.test.ts +46 -46
- package/src/__tests__/privacy.test.ts +143 -0
- package/src/__tests__/session.test.ts +57 -2
- package/src/__tests__/sim-agent.test.ts +173 -173
- package/src/__tests__/sim-platform.test.ts +76 -76
- package/src/__tests__/sim-store.test.ts +213 -213
- package/src/__tests__/stream-state.test.ts +134 -134
- package/src/__tests__/wechat-platform.test.ts +57 -32
- package/src/adapters/codex-adapter.ts +294 -294
- package/src/adapters/codex-session-meta-store.ts +130 -130
- package/src/agent-file-rpc.ts +156 -156
- package/src/agent-image-rpc.ts +152 -152
- package/src/agent-rpc-body.ts +48 -48
- package/src/card-plain-text.ts +108 -108
- package/src/feishu-api.ts +7 -3
- package/src/im-skills.ts +109 -109
- package/src/platform-adapter.ts +60 -60
- package/src/privacy.ts +68 -0
- package/src/session-chat-binding.ts +6 -1
- package/src/session.ts +40 -35
- package/src/sim-agent.ts +167 -167
- package/src/trace.ts +50 -50
- package/src/wechat-platform.ts +4 -1
package/src/im-skills.ts
CHANGED
|
@@ -1,109 +1,109 @@
|
|
|
1
|
-
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join, dirname } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { PROJECT_ROOT } from "./config.ts";
|
|
5
|
-
|
|
6
|
-
export const DEFAULT_IM_SKILLS_DIR = join(PROJECT_ROOT, "im-skills");
|
|
7
|
-
|
|
8
|
-
export interface ImSkillVariableMap {
|
|
9
|
-
[key: string]: string | undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface BuildImSkillsPromptInput {
|
|
13
|
-
skillsDir?: string;
|
|
14
|
-
variables: ImSkillVariableMap;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface ImSkillDoc {
|
|
18
|
-
name: string;
|
|
19
|
-
content: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function renderTemplate(template: string, variables: ImSkillVariableMap): string {
|
|
23
|
-
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => {
|
|
24
|
-
return variables[key] ?? "";
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function loadSkillDocs(skillsDir: string): Promise<ImSkillDoc[]> {
|
|
29
|
-
let entries;
|
|
30
|
-
try {
|
|
31
|
-
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
32
|
-
} catch {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const docs = await Promise.all(
|
|
37
|
-
entries
|
|
38
|
-
.filter((entry) => entry.isDirectory())
|
|
39
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
40
|
-
.map(async (entry): Promise<ImSkillDoc | null> => {
|
|
41
|
-
const skillPath = join(skillsDir, entry.name, "skill.md");
|
|
42
|
-
try {
|
|
43
|
-
const content = await readFile(skillPath, "utf-8");
|
|
44
|
-
return { name: entry.name, content };
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}),
|
|
49
|
-
);
|
|
50
|
-
return docs.filter((doc): doc is ImSkillDoc => doc !== null);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function buildImSkillsPrompt(input: BuildImSkillsPromptInput): Promise<string> {
|
|
54
|
-
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
55
|
-
const docs = await loadSkillDocs(skillsDir);
|
|
56
|
-
return docs
|
|
57
|
-
.map((doc) => {
|
|
58
|
-
const rendered = renderTemplate(doc.content, input.variables).trim();
|
|
59
|
-
return [
|
|
60
|
-
`[ChatCCC IM skill: ${doc.name}]`,
|
|
61
|
-
rendered,
|
|
62
|
-
`[/ChatCCC IM skill: ${doc.name}]`,
|
|
63
|
-
].join("\n");
|
|
64
|
-
})
|
|
65
|
-
.join("\n\n");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 渲染技能目录下的子文档(skill.md 除外)并写入 outputDir。
|
|
70
|
-
* 返回写入的文件路径列表。通常在会话初始化时调用,写入的文档供 Agent 按需读取。
|
|
71
|
-
*/
|
|
72
|
-
export async function exportSkillSubDocs(
|
|
73
|
-
input: BuildImSkillsPromptInput,
|
|
74
|
-
outputDir: string,
|
|
75
|
-
): Promise<string[]> {
|
|
76
|
-
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
77
|
-
const files: string[] = [];
|
|
78
|
-
|
|
79
|
-
let entries;
|
|
80
|
-
try {
|
|
81
|
-
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
82
|
-
} catch {
|
|
83
|
-
return files;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const entry of entries) {
|
|
87
|
-
if (!entry.isDirectory()) continue;
|
|
88
|
-
const skillPath = join(skillsDir, entry.name);
|
|
89
|
-
|
|
90
|
-
let subFiles;
|
|
91
|
-
try {
|
|
92
|
-
subFiles = await readdir(skillPath);
|
|
93
|
-
} catch {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
for (const sf of subFiles) {
|
|
98
|
-
if (sf === "skill.md" || !sf.endsWith(".md")) continue;
|
|
99
|
-
const content = await readFile(join(skillPath, sf), "utf-8");
|
|
100
|
-
const rendered = renderTemplate(content, input.variables);
|
|
101
|
-
const outPath = join(outputDir, entry.name, sf);
|
|
102
|
-
await mkdir(dirname(outPath), { recursive: true });
|
|
103
|
-
await writeFile(outPath, rendered, "utf-8");
|
|
104
|
-
files.push(outPath);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return files;
|
|
109
|
-
}
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { PROJECT_ROOT } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_IM_SKILLS_DIR = join(PROJECT_ROOT, "im-skills");
|
|
7
|
+
|
|
8
|
+
export interface ImSkillVariableMap {
|
|
9
|
+
[key: string]: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BuildImSkillsPromptInput {
|
|
13
|
+
skillsDir?: string;
|
|
14
|
+
variables: ImSkillVariableMap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ImSkillDoc {
|
|
18
|
+
name: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderTemplate(template: string, variables: ImSkillVariableMap): string {
|
|
23
|
+
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key: string) => {
|
|
24
|
+
return variables[key] ?? "";
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function loadSkillDocs(skillsDir: string): Promise<ImSkillDoc[]> {
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const docs = await Promise.all(
|
|
37
|
+
entries
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
40
|
+
.map(async (entry): Promise<ImSkillDoc | null> => {
|
|
41
|
+
const skillPath = join(skillsDir, entry.name, "skill.md");
|
|
42
|
+
try {
|
|
43
|
+
const content = await readFile(skillPath, "utf-8");
|
|
44
|
+
return { name: entry.name, content };
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
return docs.filter((doc): doc is ImSkillDoc => doc !== null);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function buildImSkillsPrompt(input: BuildImSkillsPromptInput): Promise<string> {
|
|
54
|
+
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
55
|
+
const docs = await loadSkillDocs(skillsDir);
|
|
56
|
+
return docs
|
|
57
|
+
.map((doc) => {
|
|
58
|
+
const rendered = renderTemplate(doc.content, input.variables).trim();
|
|
59
|
+
return [
|
|
60
|
+
`[ChatCCC IM skill: ${doc.name}]`,
|
|
61
|
+
rendered,
|
|
62
|
+
`[/ChatCCC IM skill: ${doc.name}]`,
|
|
63
|
+
].join("\n");
|
|
64
|
+
})
|
|
65
|
+
.join("\n\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 渲染技能目录下的子文档(skill.md 除外)并写入 outputDir。
|
|
70
|
+
* 返回写入的文件路径列表。通常在会话初始化时调用,写入的文档供 Agent 按需读取。
|
|
71
|
+
*/
|
|
72
|
+
export async function exportSkillSubDocs(
|
|
73
|
+
input: BuildImSkillsPromptInput,
|
|
74
|
+
outputDir: string,
|
|
75
|
+
): Promise<string[]> {
|
|
76
|
+
const skillsDir = input.skillsDir ?? DEFAULT_IM_SKILLS_DIR;
|
|
77
|
+
const files: string[] = [];
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await readdir(skillsDir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isDirectory()) continue;
|
|
88
|
+
const skillPath = join(skillsDir, entry.name);
|
|
89
|
+
|
|
90
|
+
let subFiles;
|
|
91
|
+
try {
|
|
92
|
+
subFiles = await readdir(skillPath);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const sf of subFiles) {
|
|
98
|
+
if (sf === "skill.md" || !sf.endsWith(".md")) continue;
|
|
99
|
+
const content = await readFile(join(skillPath, sf), "utf-8");
|
|
100
|
+
const rendered = renderTemplate(content, input.variables);
|
|
101
|
+
const outPath = join(outputDir, entry.name, sf);
|
|
102
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
103
|
+
await writeFile(outPath, rendered, "utf-8");
|
|
104
|
+
files.push(outPath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return files;
|
|
109
|
+
}
|
package/src/platform-adapter.ts
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* platform-adapter.ts — IM 平台适配器接口
|
|
3
|
-
*
|
|
4
|
-
* 独立于 orchestrator.ts,避免 orchestrator ↔ session 循环依赖。
|
|
5
|
-
* 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export interface PlatformAdapter {
|
|
9
|
-
/** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
|
|
10
|
-
kind?: string;
|
|
11
|
-
|
|
12
|
-
/** 发送纯文本回复 */
|
|
13
|
-
sendText(chatId: string, text: string): Promise<boolean>;
|
|
14
|
-
|
|
15
|
-
/** 发送卡片回复(标题 + 内容 + 颜色模板) */
|
|
16
|
-
sendCard(
|
|
17
|
-
chatId: string,
|
|
18
|
-
title: string,
|
|
19
|
-
content: string,
|
|
20
|
-
template: string,
|
|
21
|
-
): Promise<boolean>;
|
|
22
|
-
|
|
23
|
-
/** 发送原始卡片 JSON */
|
|
24
|
-
sendRawCard(chatId: string, cardJson: string): Promise<boolean>;
|
|
25
|
-
|
|
26
|
-
/** 创建群聊,返回新群 ID */
|
|
27
|
-
createGroup(name: string, userIds: string[]): Promise<string>;
|
|
28
|
-
|
|
29
|
-
/** 更新群名称和描述 */
|
|
30
|
-
updateChatInfo(
|
|
31
|
-
chatId: string,
|
|
32
|
-
name: string,
|
|
33
|
-
description: string,
|
|
34
|
-
): Promise<void>;
|
|
35
|
-
|
|
36
|
-
/** 获取群信息 */
|
|
37
|
-
getChatInfo(chatId: string): Promise<{ name: string; description: string }>;
|
|
38
|
-
|
|
39
|
-
/** 解散群聊 */
|
|
40
|
-
disbandChat(chatId: string): Promise<void>;
|
|
41
|
-
|
|
42
|
-
/** 设置群头像 */
|
|
43
|
-
setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
|
|
44
|
-
|
|
45
|
-
/** 从群描述中提取 session 信息 */
|
|
46
|
-
extractSessionInfo(
|
|
47
|
-
description: string,
|
|
48
|
-
): { sessionId: string; tool: string } | null;
|
|
49
|
-
|
|
50
|
-
// ---- 进度展示(display loop 使用) ----
|
|
51
|
-
// 不同平台能力不同:飞书用 CardKit 实时卡片,微信降级为纯文本。
|
|
52
|
-
|
|
53
|
-
/** 创建进度展示实体,返回唯一标识;不支持进度展示的平台返回 null */
|
|
54
|
-
cardCreate(cardJson: string): Promise<string | null>;
|
|
55
|
-
|
|
56
|
-
/** 将进度展示发送到指定会话,返回 message_id */
|
|
57
|
-
cardSend(chatId: string, cardId: string): Promise<string>;
|
|
58
|
-
|
|
59
|
-
/** 更新已发送的进度展示,sequence 保证有序 */
|
|
60
|
-
cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
|
|
1
|
+
/**
|
|
2
|
+
* platform-adapter.ts — IM 平台适配器接口
|
|
3
|
+
*
|
|
4
|
+
* 独立于 orchestrator.ts,避免 orchestrator ↔ session 循环依赖。
|
|
5
|
+
* 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PlatformAdapter {
|
|
9
|
+
/** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
|
|
10
|
+
kind?: string;
|
|
11
|
+
|
|
12
|
+
/** 发送纯文本回复 */
|
|
13
|
+
sendText(chatId: string, text: string): Promise<boolean>;
|
|
14
|
+
|
|
15
|
+
/** 发送卡片回复(标题 + 内容 + 颜色模板) */
|
|
16
|
+
sendCard(
|
|
17
|
+
chatId: string,
|
|
18
|
+
title: string,
|
|
19
|
+
content: string,
|
|
20
|
+
template: string,
|
|
21
|
+
): Promise<boolean>;
|
|
22
|
+
|
|
23
|
+
/** 发送原始卡片 JSON */
|
|
24
|
+
sendRawCard(chatId: string, cardJson: string): Promise<boolean>;
|
|
25
|
+
|
|
26
|
+
/** 创建群聊,返回新群 ID */
|
|
27
|
+
createGroup(name: string, userIds: string[]): Promise<string>;
|
|
28
|
+
|
|
29
|
+
/** 更新群名称和描述 */
|
|
30
|
+
updateChatInfo(
|
|
31
|
+
chatId: string,
|
|
32
|
+
name: string,
|
|
33
|
+
description: string,
|
|
34
|
+
): Promise<void>;
|
|
35
|
+
|
|
36
|
+
/** 获取群信息 */
|
|
37
|
+
getChatInfo(chatId: string): Promise<{ name: string; description: string }>;
|
|
38
|
+
|
|
39
|
+
/** 解散群聊 */
|
|
40
|
+
disbandChat(chatId: string): Promise<void>;
|
|
41
|
+
|
|
42
|
+
/** 设置群头像 */
|
|
43
|
+
setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
|
|
44
|
+
|
|
45
|
+
/** 从群描述中提取 session 信息 */
|
|
46
|
+
extractSessionInfo(
|
|
47
|
+
description: string,
|
|
48
|
+
): { sessionId: string; tool: string } | null;
|
|
49
|
+
|
|
50
|
+
// ---- 进度展示(display loop 使用) ----
|
|
51
|
+
// 不同平台能力不同:飞书用 CardKit 实时卡片,微信降级为纯文本。
|
|
52
|
+
|
|
53
|
+
/** 创建进度展示实体,返回唯一标识;不支持进度展示的平台返回 null */
|
|
54
|
+
cardCreate(cardJson: string): Promise<string | null>;
|
|
55
|
+
|
|
56
|
+
/** 将进度展示发送到指定会话,返回 message_id */
|
|
57
|
+
cardSend(chatId: string, cardId: string): Promise<string>;
|
|
58
|
+
|
|
59
|
+
/** 更新已发送的进度展示,sequence 保证有序 */
|
|
60
|
+
cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
|
|
61
61
|
}
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { USER_DATA_DIR } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// 隐私替换规则
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
interface PrivacyRules {
|
|
10
|
+
[key: string]: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let rules: PrivacyRules | null = null;
|
|
14
|
+
let loaded = false;
|
|
15
|
+
|
|
16
|
+
function loadRules(): PrivacyRules {
|
|
17
|
+
const filePath = join(USER_DATA_DIR, "privacy.json");
|
|
18
|
+
if (!existsSync(filePath)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
25
|
+
console.error(`[PRIVACY] privacy.json 格式错误:应为对象`);
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
29
|
+
if (typeof v !== "string") {
|
|
30
|
+
console.error(`[PRIVACY] privacy.json 值必须为字符串,跳过 "${k}"`);
|
|
31
|
+
delete (parsed as Record<string, unknown>)[k];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return parsed as PrivacyRules;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[PRIVACY] 读取 privacy.json 失败: ${(err as Error).message}`);
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getPrivacyRules(): PrivacyRules {
|
|
42
|
+
if (!loaded) {
|
|
43
|
+
rules = loadRules();
|
|
44
|
+
loaded = true;
|
|
45
|
+
}
|
|
46
|
+
return rules!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 重新加载规则(热更新用) */
|
|
50
|
+
export function reloadPrivacyRules(): void {
|
|
51
|
+
loaded = false;
|
|
52
|
+
rules = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 对文本应用隐私替换规则。
|
|
57
|
+
* 若无规则或文本为空,直接返回原文。
|
|
58
|
+
*/
|
|
59
|
+
export function applyPrivacy(text: string): string {
|
|
60
|
+
const r = getPrivacyRules();
|
|
61
|
+
if (Object.keys(r).length === 0 || !text) return text;
|
|
62
|
+
let result = text;
|
|
63
|
+
for (const [from, to] of Object.entries(r)) {
|
|
64
|
+
// 用 split+join 替代 replaceAll,避免正则特殊字符问题
|
|
65
|
+
result = result.split(from).join(to);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
@@ -110,7 +110,12 @@ export interface DisplayCardState {
|
|
|
110
110
|
lastSentContent: string;
|
|
111
111
|
streamErrorNotified: boolean;
|
|
112
112
|
/** 卡片轮转时记录的基线,轮转后只展示增量 */
|
|
113
|
-
|
|
113
|
+
rotationAccLen?: number;
|
|
114
|
+
rotationFinalReply?: string;
|
|
115
|
+
/** WeChat delta: 上次发送时 accumulatedContent 的长度 */
|
|
116
|
+
lastSentAccLen?: number;
|
|
117
|
+
/** WeChat delta: 上次发送时的 finalReply */
|
|
118
|
+
lastSentFinalReply?: string;
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
export const displayCards = new Map<string, DisplayCardState>();
|
package/src/session.ts
CHANGED
|
@@ -917,14 +917,17 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
917
917
|
if (isTerminal) {
|
|
918
918
|
if (isWechat) {
|
|
919
919
|
// WeChat: 没有卡片需要终结,用 delta 逻辑发剩余内容,避免重发已推送的部分
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
920
|
+
// 分开追踪 accumulatedContent 和 finalReply,而非拼接后对比
|
|
921
|
+
const prevAccLen = display?.lastSentAccLen ?? 0;
|
|
922
|
+
const prevFinalReply = display?.lastSentFinalReply ?? "";
|
|
923
|
+
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
924
|
+
let replyDelta: string;
|
|
925
|
+
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
926
|
+
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
925
927
|
} else {
|
|
926
|
-
|
|
928
|
+
replyDelta = state.finalReply;
|
|
927
929
|
}
|
|
930
|
+
const remaining = (accDelta + replyDelta).trim();
|
|
928
931
|
const tail = "━━━ 回答结束 ━━━";
|
|
929
932
|
const finalMsg = remaining ? remaining + "\n" + tail : tail;
|
|
930
933
|
await p.sendText(chatId, finalMsg).catch(() => {});
|
|
@@ -966,21 +969,29 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
966
969
|
const d = displayCards.get(chatId);
|
|
967
970
|
if (!d || d.cardBusy) return;
|
|
968
971
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
972
|
+
// 分开追踪 accumulatedContent 和 finalReply 的已发送位置。
|
|
973
|
+
// 如果只用 rawFull.startsWith(prevRaw),当新的 tool_use/tool_result
|
|
974
|
+
// 插入到 accumulatedContent 中间时,rawFull 不再以 prevRaw 开头,
|
|
975
|
+
// 会回退到发送完整内容 → 产生大量重复。
|
|
976
|
+
const prevAccLen = d.lastSentAccLen ?? 0;
|
|
977
|
+
const prevFinalReply = d.lastSentFinalReply ?? "";
|
|
978
|
+
const accDelta = state.accumulatedContent.slice(prevAccLen);
|
|
979
|
+
let replyDelta: string;
|
|
980
|
+
if (prevFinalReply && state.finalReply.startsWith(prevFinalReply)) {
|
|
981
|
+
replyDelta = state.finalReply.slice(prevFinalReply.length);
|
|
974
982
|
} else {
|
|
975
|
-
|
|
983
|
+
replyDelta = state.finalReply;
|
|
976
984
|
}
|
|
985
|
+
const delta = (accDelta + replyDelta).trim();
|
|
977
986
|
if (!delta) return;
|
|
978
987
|
|
|
979
988
|
d.cardBusy = true;
|
|
980
989
|
try {
|
|
981
990
|
const ok = await p.sendText(chatId, delta);
|
|
982
991
|
if (ok) {
|
|
983
|
-
d.
|
|
992
|
+
d.lastSentAccLen = state.accumulatedContent.length;
|
|
993
|
+
d.lastSentFinalReply = state.finalReply;
|
|
994
|
+
d.lastSentContent = delta;
|
|
984
995
|
} else {
|
|
985
996
|
// 发送失败(限流等),不更新光标,下次合并重试
|
|
986
997
|
return;
|
|
@@ -1026,8 +1037,10 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1026
1037
|
display.cardId = newCardId;
|
|
1027
1038
|
display.sequence = 1;
|
|
1028
1039
|
display.cardCreatedAt = Date.now();
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1040
|
+
// 记录基线:分开追踪 accumulatedContent 长度和 finalReply,
|
|
1041
|
+
// 避免 tool 内容中间插入时前缀匹配失败 → 回退发完整内容
|
|
1042
|
+
display.rotationAccLen = state.accumulatedContent.length;
|
|
1043
|
+
display.rotationFinalReply = state.finalReply;
|
|
1031
1044
|
display.lastSentContent = "";
|
|
1032
1045
|
display.streamErrorNotified = false;
|
|
1033
1046
|
}
|
|
@@ -1039,27 +1052,19 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1039
1052
|
return;
|
|
1040
1053
|
}
|
|
1041
1054
|
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
display.cardBusy = true;
|
|
1053
|
-
try {
|
|
1054
|
-
await p.cardUpdate(display.cardId, fbCard, display.sequence + 1);
|
|
1055
|
-
display.sequence++;
|
|
1056
|
-
} finally {
|
|
1057
|
-
display.cardBusy = false;
|
|
1058
|
-
}
|
|
1059
|
-
return;
|
|
1055
|
+
// 轮转后:分开追踪 accumulatedContent 和 finalReply 增量,
|
|
1056
|
+
// 避免 tool 内容插入中间时前缀匹配失败
|
|
1057
|
+
if (display.rotationAccLen !== undefined) {
|
|
1058
|
+
const accDelta = state.accumulatedContent.slice(display.rotationAccLen);
|
|
1059
|
+
const rotReply = display.rotationFinalReply ?? "";
|
|
1060
|
+
let replyDelta: string;
|
|
1061
|
+
if (rotReply && state.finalReply.startsWith(rotReply)) {
|
|
1062
|
+
replyDelta = state.finalReply.slice(rotReply.length);
|
|
1063
|
+
} else {
|
|
1064
|
+
replyDelta = state.finalReply;
|
|
1060
1065
|
}
|
|
1061
|
-
const delta =
|
|
1062
|
-
if (delta === display.lastSentContent) return;
|
|
1066
|
+
const delta = (accDelta + replyDelta).trim();
|
|
1067
|
+
if (!delta || delta === display.lastSentContent) return;
|
|
1063
1068
|
display.lastSentContent = delta;
|
|
1064
1069
|
const deltaCard = buildProgressCard(truncateContent(delta) || "处理中...", { showStop: true, headerTitle: "生成中..." });
|
|
1065
1070
|
display.cardBusy = true;
|