chatccc 0.2.50 → 0.2.52
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 +51 -4
- package/config.sample.json +34 -30
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +42 -0
- package/src/__tests__/claude-adapter.test.ts +54 -1
- package/src/__tests__/config-reload.test.ts +64 -47
- package/src/__tests__/config-sample.test.ts +19 -0
- package/src/__tests__/session.test.ts +346 -1
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/__tests__/wechat-platform.test.ts +32 -0
- package/src/adapters/claude-adapter.ts +23 -2
- package/src/card-plain-text.ts +108 -0
- package/src/cards.ts +4 -4
- package/src/config.ts +775 -742
- package/src/feishu-api.ts +6 -0
- package/src/index.ts +172 -781
- package/src/orchestrator.ts +1032 -0
- package/src/platform-adapter.ts +61 -0
- package/src/session-chat-binding.ts +2 -0
- package/src/session.ts +431 -114
- package/src/sim-agent.ts +42 -31
- package/src/stream-state.ts +140 -93
- package/src/web-ui.ts +1891 -1718
- package/src/wechat-platform.ts +517 -0
package/src/sim-agent.ts
CHANGED
|
@@ -71,6 +71,45 @@ export function createSimAgent(userId: string): SimAgent {
|
|
|
71
71
|
// 确保用户有至少一个会话(bot 私聊自动创建)
|
|
72
72
|
simStore.createP2pChat(userId);
|
|
73
73
|
|
|
74
|
+
function onEvent(event: "message", handler: MessageEventCallback): void;
|
|
75
|
+
function onEvent(event: "invited_to_group", handler: InvitedToGroupCallback): void;
|
|
76
|
+
function onEvent(
|
|
77
|
+
event: "message" | "invited_to_group",
|
|
78
|
+
handler: MessageEventCallback | InvitedToGroupCallback,
|
|
79
|
+
): void {
|
|
80
|
+
if (event === "message") {
|
|
81
|
+
const messageHandler = handler as MessageEventCallback;
|
|
82
|
+
const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
|
|
83
|
+
// 只推送给该用户所在群的消息
|
|
84
|
+
const chat = simStore.getChat(payload.chatId);
|
|
85
|
+
if (chat && chat.memberIds.includes(userId)) {
|
|
86
|
+
messageHandler(payload.chatId, payload.message);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
// 保存映射以便 off 能移除
|
|
90
|
+
(handler as any).__filtered = filteredHandler;
|
|
91
|
+
simStore.on("message", filteredHandler);
|
|
92
|
+
} else {
|
|
93
|
+
const invitedHandler = handler as InvitedToGroupCallback;
|
|
94
|
+
const filteredHandler = (payload: { chatId: string; userId: string }) => {
|
|
95
|
+
if (payload.userId === userId) {
|
|
96
|
+
invitedHandler(payload.chatId, payload.userId);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
(handler as any).__filtered = filteredHandler;
|
|
100
|
+
simStore.on("member_added", filteredHandler);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function offEvent(event: string, handler: (...args: any[]) => void): void {
|
|
105
|
+
const filtered = (handler as any).__filtered;
|
|
106
|
+
if (event === "message" && filtered) {
|
|
107
|
+
simStore.off("message", filtered);
|
|
108
|
+
} else if (event === "invited_to_group" && filtered) {
|
|
109
|
+
simStore.off("member_added", filtered);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
74
113
|
const agent: SimAgent = {
|
|
75
114
|
userId,
|
|
76
115
|
account,
|
|
@@ -101,37 +140,9 @@ export function createSimAgent(userId: string): SimAgent {
|
|
|
101
140
|
}));
|
|
102
141
|
},
|
|
103
142
|
|
|
104
|
-
on
|
|
105
|
-
if (event === "message") {
|
|
106
|
-
const filteredHandler = (payload: { chatId: string; message: SimMessage }) => {
|
|
107
|
-
// 只推送给该用户所在群的消息
|
|
108
|
-
const chat = simStore.getChat(payload.chatId);
|
|
109
|
-
if (chat && chat.memberIds.includes(userId)) {
|
|
110
|
-
handler(payload.chatId, payload.message);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
// 保存映射以便 off 能移除
|
|
114
|
-
(handler as any).__filtered = filteredHandler;
|
|
115
|
-
simStore.on("message", filteredHandler);
|
|
116
|
-
} else if (event === "invited_to_group") {
|
|
117
|
-
const filteredHandler = (payload: { chatId: string; userId: string }) => {
|
|
118
|
-
if (payload.userId === userId) {
|
|
119
|
-
handler(payload.chatId, payload.userId);
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
(handler as any).__filtered = filteredHandler;
|
|
123
|
-
simStore.on("member_added", filteredHandler);
|
|
124
|
-
}
|
|
125
|
-
},
|
|
143
|
+
on: onEvent,
|
|
126
144
|
|
|
127
|
-
off
|
|
128
|
-
const filtered = (handler as any).__filtered;
|
|
129
|
-
if (event === "message" && filtered) {
|
|
130
|
-
simStore.off("message", filtered);
|
|
131
|
-
} else if (event === "invited_to_group" && filtered) {
|
|
132
|
-
simStore.off("member_added", filtered);
|
|
133
|
-
}
|
|
134
|
-
},
|
|
145
|
+
off: offEvent,
|
|
135
146
|
|
|
136
147
|
waitForReply(chatId, timeoutMs = 30000) {
|
|
137
148
|
return new Promise((resolve) => {
|
|
@@ -153,4 +164,4 @@ export function createSimAgent(userId: string): SimAgent {
|
|
|
153
164
|
};
|
|
154
165
|
|
|
155
166
|
return agent;
|
|
156
|
-
}
|
|
167
|
+
}
|
package/src/stream-state.ts
CHANGED
|
@@ -1,94 +1,141 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
-
|
|
12
|
-
export interface StreamState {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
status: "running" | "done" | "stopped" | "error";
|
|
15
|
-
accumulatedContent: string;
|
|
16
|
-
finalReply: string;
|
|
17
|
-
chunkCount: number;
|
|
18
|
-
turnCount: number;
|
|
19
|
-
contextTokens: number;
|
|
20
|
-
updatedAt: number;
|
|
21
|
-
cwd: string;
|
|
22
|
-
tool: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getStreamStatePath(sessionId: string): string {
|
|
26
|
-
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
-
try {
|
|
31
|
-
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
-
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
-
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
-
return null;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
+
|
|
12
|
+
export interface StreamState {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
status: "running" | "done" | "stopped" | "error";
|
|
15
|
+
accumulatedContent: string;
|
|
16
|
+
finalReply: string;
|
|
17
|
+
chunkCount: number;
|
|
18
|
+
turnCount: number;
|
|
19
|
+
contextTokens: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
cwd: string;
|
|
22
|
+
tool: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStreamStatePath(sessionId: string): string {
|
|
26
|
+
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
+
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
+
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// rename 的可注入实现——仅供测试模拟"Windows EPERM 降级"路径。
|
|
41
|
+
// 生产代码使用 node:fs/promises.rename。
|
|
42
|
+
let renameImpl: typeof rename = rename;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 仅供单测:注入自定义 rename 实现以验证降级分支。
|
|
46
|
+
* 调用方负责测试结束后用 _resetRenameForTest 还原。
|
|
47
|
+
*/
|
|
48
|
+
export function _setRenameForTest(impl: typeof rename): void {
|
|
49
|
+
renameImpl = impl;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function _resetRenameForTest(): void {
|
|
53
|
+
renameImpl = rename;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把 StreamState 持久化到磁盘——尽量原子地写。
|
|
58
|
+
*
|
|
59
|
+
* 为什么需要原子写:display loop 每 3 秒读一次 stream-state.json,而
|
|
60
|
+
* runAgentSession 每 2 秒写一次。如果用 `writeFile(filePath, ...)` 直接
|
|
61
|
+
* 覆盖,reader 在写入过程中读会拿到半截 JSON,JSON.parse 报错——虽然
|
|
62
|
+
* readStreamState 包了 try/catch 返回 null 不会崩,但相当于"丢一帧"。
|
|
63
|
+
*
|
|
64
|
+
* 实现:
|
|
65
|
+
* 1) 先把内容写到 `<filePath>.tmp`(reader 不会读 .tmp)
|
|
66
|
+
* 2) 再用 `rename` 把 .tmp 替换成正式文件——`rename` 在 POSIX 上是真原子
|
|
67
|
+
* 操作,reader 任何时刻读到的都是某个完整的旧/新版本
|
|
68
|
+
*
|
|
69
|
+
* Windows 兼容性:Windows 上 `rename` 偶尔会因为目标文件正被 reader 打开
|
|
70
|
+
* 抛 EPERM/EBUSY(实测罕见,但非零概率)。降级方案:直接 writeFile 覆盖
|
|
71
|
+
* 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
|
|
72
|
+
* 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
|
|
73
|
+
*/
|
|
74
|
+
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
75
|
+
const filePath = getStreamStatePath(state.sessionId);
|
|
76
|
+
const payload = JSON.stringify(state, null, 2);
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
79
|
+
const tmpPath = filePath + ".tmp";
|
|
80
|
+
await writeFile(tmpPath, payload, "utf-8");
|
|
81
|
+
try {
|
|
82
|
+
await renameImpl(tmpPath, filePath);
|
|
83
|
+
} catch (renameErr) {
|
|
84
|
+
// Windows 偶发 EPERM/EBUSY:rename 失败时降级为直接覆盖写。
|
|
85
|
+
// 此次写入失去原子性(reader 可能读到半截 JSON,但 readStreamState
|
|
86
|
+
// 的 try/catch 会兜底返回 null,只是丢一帧 display 更新)。
|
|
87
|
+
console.warn(
|
|
88
|
+
`[${ts()}] [STREAM-STATE] rename failed for ${state.sessionId}, ` +
|
|
89
|
+
`fallback to overwrite: ${(renameErr as Error).message}`,
|
|
90
|
+
);
|
|
91
|
+
await writeFile(filePath, payload, "utf-8");
|
|
92
|
+
// 尽力清理 .tmp,失败也无所谓——下次 write 会覆盖它
|
|
93
|
+
await unlink(tmpPath).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
101
|
+
return {
|
|
102
|
+
sessionId,
|
|
103
|
+
status: "running",
|
|
104
|
+
accumulatedContent: "",
|
|
105
|
+
finalReply: "",
|
|
106
|
+
chunkCount: 0,
|
|
107
|
+
turnCount,
|
|
108
|
+
contextTokens: 0,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
cwd,
|
|
111
|
+
tool,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 进程重启时修正虚假的 "running" 状态 */
|
|
116
|
+
export async function fixStaleStreamStates(): Promise<void> {
|
|
117
|
+
// 启动时将残留的 running 标记为 error
|
|
118
|
+
// 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
|
|
119
|
+
try {
|
|
120
|
+
const { readdir } = await import("node:fs/promises");
|
|
121
|
+
let entries: string[];
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(STREAMS_DIR);
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.endsWith(".json")) continue;
|
|
129
|
+
const state = await readStreamState(entry.replace(".json", ""));
|
|
130
|
+
if (state && state.status === "running") {
|
|
131
|
+
state.status = "error";
|
|
132
|
+
state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
|
|
133
|
+
state.updatedAt = Date.now();
|
|
134
|
+
await writeStreamState(state);
|
|
135
|
+
console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
140
|
+
}
|
|
94
141
|
}
|