@yeaft/webchat-agent 0.0.234 → 0.0.236

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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Crew — 人工交互
3
+ * handleCrewHumanInput, processHumanQueue
4
+ */
5
+ import { dispatchToRole } from './routing.js';
6
+ import { sendStatusUpdate } from './ui-messages.js';
7
+
8
+ /**
9
+ * 处理人的输入
10
+ */
11
+ export async function handleCrewHumanInput(msg) {
12
+ // Lazy import to avoid circular dependency
13
+ const { crewSessions } = await import('./session.js');
14
+
15
+ const { sessionId, content, targetRole, files } = msg;
16
+ const session = crewSessions.get(sessionId);
17
+ if (!session) {
18
+ console.warn(`[Crew] Session not found: ${sessionId}`);
19
+ return;
20
+ }
21
+
22
+ // Build dispatch content (supports image attachments)
23
+ function buildHumanContent(prefix, text) {
24
+ if (files && files.length > 0) {
25
+ const blocks = [];
26
+ for (const file of files) {
27
+ if (file.isImage || file.mimeType?.startsWith('image/')) {
28
+ blocks.push({
29
+ type: 'image',
30
+ source: { type: 'base64', media_type: file.mimeType, data: file.data }
31
+ });
32
+ }
33
+ }
34
+ blocks.push({ type: 'text', text: `${prefix}\n${text}` });
35
+ return blocks;
36
+ }
37
+ return `${prefix}\n${text}`;
38
+ }
39
+
40
+ // 记录到 uiMessages 用于恢复时重放
41
+ session.uiMessages.push({
42
+ role: 'human', roleIcon: '', roleName: '你',
43
+ type: 'text', content,
44
+ timestamp: Date.now()
45
+ });
46
+
47
+ // 如果在等待人工介入
48
+ if (session.status === 'waiting_human') {
49
+ const waitingContext = session.waitingHumanContext;
50
+ session.status = 'running';
51
+ session.waitingHumanContext = null;
52
+ sendStatusUpdate(session);
53
+
54
+ const target = targetRole || waitingContext?.fromRole || session.decisionMaker;
55
+ await dispatchToRole(session, target, buildHumanContent('人工回复:', content), 'human');
56
+ return;
57
+ }
58
+
59
+ // 解析 @role 指令
60
+ const atMatch = content.match(/^@(\S+)\s*([\s\S]*)/);
61
+ if (atMatch) {
62
+ const atTarget = atMatch[1];
63
+ const message = atMatch[2].trim() || content;
64
+
65
+ let target = null;
66
+ for (const [name, role] of session.roles) {
67
+ if (name === atTarget.toLowerCase()) {
68
+ target = name;
69
+ break;
70
+ }
71
+ if (role.displayName === atTarget) {
72
+ target = name;
73
+ break;
74
+ }
75
+ }
76
+
77
+ if (target) {
78
+ await dispatchToRole(session, target, buildHumanContent('人工消息:', message), 'human');
79
+ return;
80
+ }
81
+ }
82
+
83
+ // 默认发给决策者
84
+ const target = targetRole || session.decisionMaker;
85
+ await dispatchToRole(session, target, buildHumanContent('人工消息:', content), 'human');
86
+ }
87
+
88
+ /**
89
+ * 处理排队的人的消息
90
+ */
91
+ export async function processHumanQueue(session) {
92
+ if (session.humanMessageQueue.length === 0) return;
93
+ if (session._processingHumanQueue) return;
94
+ session._processingHumanQueue = true;
95
+ try {
96
+ const msgs = session.humanMessageQueue.splice(0);
97
+ if (msgs.length === 1) {
98
+ const humanPrompt = `人工消息:\n${msgs[0].content}`;
99
+ await dispatchToRole(session, msgs[0].target, humanPrompt, 'human');
100
+ } else {
101
+ const byTarget = new Map();
102
+ for (const m of msgs) {
103
+ if (!byTarget.has(m.target)) byTarget.set(m.target, []);
104
+ byTarget.get(m.target).push(m.content);
105
+ }
106
+ for (const [target, contents] of byTarget) {
107
+ const combined = contents.join('\n\n---\n\n');
108
+ const humanPrompt = `人工消息:\n你有 ${contents.length} 条待处理消息,请一并分析并用多个 ROUTE 块并行分配:\n\n${combined}`;
109
+ await dispatchToRole(session, target, humanPrompt, 'human');
110
+ }
111
+ }
112
+ } finally {
113
+ session._processingHumanQueue = false;
114
+ }
115
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Crew — 持久化管理
3
+ * Session 索引 (~/.claude/crew-sessions.json)、session 元数据、消息分片
4
+ */
5
+ import { promises as fs } from 'fs';
6
+ import { join } from 'path';
7
+ import { homedir } from 'os';
8
+
9
+ // =====================================================================
10
+ // Crew Session Index (~/.claude/crew-sessions.json)
11
+ // =====================================================================
12
+
13
+ const CREW_INDEX_PATH = join(homedir(), '.claude', 'crew-sessions.json');
14
+
15
+ // 写入锁:防止并发写入导致文件损坏
16
+ let _indexWriteLock = Promise.resolve();
17
+
18
+ export async function loadCrewIndex() {
19
+ try { return JSON.parse(await fs.readFile(CREW_INDEX_PATH, 'utf-8')); }
20
+ catch { return []; }
21
+ }
22
+
23
+ async function saveCrewIndex(index) {
24
+ const doWrite = async () => {
25
+ await fs.mkdir(join(homedir(), '.claude'), { recursive: true });
26
+ const data = JSON.stringify(index, null, 2);
27
+ // 先写临时文件再 rename,保证原子性
28
+ const tmpPath = CREW_INDEX_PATH + '.tmp';
29
+ await fs.writeFile(tmpPath, data);
30
+ await fs.rename(tmpPath, CREW_INDEX_PATH);
31
+ };
32
+ // 串行化写入
33
+ _indexWriteLock = _indexWriteLock.then(doWrite, doWrite);
34
+ return _indexWriteLock;
35
+ }
36
+
37
+ function sessionToIndexEntry(session) {
38
+ return {
39
+ sessionId: session.id,
40
+ projectDir: session.projectDir,
41
+ sharedDir: session.sharedDir,
42
+ status: session.status,
43
+ name: session.name || '',
44
+ userId: session.userId,
45
+ username: session.username,
46
+ agentId: session.agentId || null,
47
+ createdAt: session.createdAt,
48
+ updatedAt: Date.now()
49
+ };
50
+ }
51
+
52
+ export async function upsertCrewIndex(session) {
53
+ const index = await loadCrewIndex();
54
+ const entry = sessionToIndexEntry(session);
55
+ const idx = index.findIndex(e => e.sessionId === session.id);
56
+ if (idx >= 0) index[idx] = entry; else index.push(entry);
57
+ await saveCrewIndex(index);
58
+ }
59
+
60
+ export async function removeFromCrewIndex(sessionId) {
61
+ // Lazy import to avoid circular dependency
62
+ const { crewSessions } = await import('./session.js');
63
+
64
+ const index = await loadCrewIndex();
65
+ const entry = index.find(e => e.sessionId === sessionId);
66
+ const filtered = index.filter(e => e.sessionId !== sessionId);
67
+ if (filtered.length !== index.length) {
68
+ await saveCrewIndex(filtered);
69
+ console.log(`[Crew] Removed session ${sessionId} from index`);
70
+ }
71
+ // 从内存中也移除(防止 sendConversationList 重新加入)
72
+ if (crewSessions.has(sessionId)) {
73
+ crewSessions.delete(sessionId);
74
+ console.log(`[Crew] Removed session ${sessionId} from active sessions`);
75
+ }
76
+ // 删除磁盘上的 session 数据文件
77
+ const sharedDir = entry?.sharedDir;
78
+ if (sharedDir) {
79
+ try {
80
+ for (const file of ['session.json', 'messages.json']) {
81
+ await fs.unlink(join(sharedDir, file)).catch(() => {});
82
+ }
83
+ // Clean up message shard files
84
+ await cleanupMessageShards(sharedDir);
85
+ console.log(`[Crew] Cleaned session files in ${sharedDir}`);
86
+ } catch (e) {
87
+ console.warn(`[Crew] Failed to clean session files:`, e.message);
88
+ }
89
+ }
90
+ }
91
+
92
+ // =====================================================================
93
+ // Session Metadata (.crew/session.json)
94
+ // =====================================================================
95
+
96
+ const MESSAGE_SHARD_SIZE = 256 * 1024; // 256KB per shard
97
+
98
+ export async function saveSessionMeta(session) {
99
+ const meta = {
100
+ sessionId: session.id,
101
+ projectDir: session.projectDir,
102
+ sharedDir: session.sharedDir,
103
+ name: session.name || '',
104
+ status: session.status,
105
+ roles: Array.from(session.roles.values()).map(r => ({
106
+ name: r.name, displayName: r.displayName, icon: r.icon,
107
+ description: r.description, isDecisionMaker: r.isDecisionMaker || false,
108
+ groupIndex: r.groupIndex, roleType: r.roleType, model: r.model
109
+ })),
110
+ decisionMaker: session.decisionMaker,
111
+ round: session.round,
112
+ createdAt: session.createdAt,
113
+ updatedAt: Date.now(),
114
+ userId: session.userId,
115
+ username: session.username,
116
+ agentId: session.agentId || null,
117
+ teamType: session.teamType || 'dev',
118
+ language: session.language || 'zh-CN',
119
+ costUsd: session.costUsd,
120
+ totalInputTokens: session.totalInputTokens,
121
+ totalOutputTokens: session.totalOutputTokens,
122
+ features: Array.from(session.features.values()),
123
+ _completedTaskIds: Array.from(session._completedTaskIds || [])
124
+ };
125
+ await fs.writeFile(join(session.sharedDir, 'session.json'), JSON.stringify(meta, null, 2));
126
+ // 保存 UI 消息历史(用于恢复时重放)
127
+ if (session.uiMessages && session.uiMessages.length > 0) {
128
+ // 清理 _streaming 标记后保存
129
+ const cleaned = session.uiMessages.map(m => {
130
+ const { _streaming, ...rest } = m;
131
+ return rest;
132
+ });
133
+ const json = JSON.stringify(cleaned);
134
+ // 超过阈值时直接归档(rotateMessages 内部写两个文件,避免双写)
135
+ if (json.length > MESSAGE_SHARD_SIZE && !session._rotating) {
136
+ await rotateMessages(session, cleaned);
137
+ } else {
138
+ await fs.writeFile(join(session.sharedDir, 'messages.json'), json);
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * 归档旧消息到分片文件(logrotate 风格)
145
+ */
146
+ async function rotateMessages(session, cleaned) {
147
+ session._rotating = true;
148
+ try {
149
+ const halfLen = Math.floor(cleaned.length / 2);
150
+ let splitIdx = halfLen;
151
+ for (let i = halfLen; i > Math.max(0, halfLen - 20); i--) {
152
+ if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
153
+ splitIdx = i + 1;
154
+ break;
155
+ }
156
+ }
157
+ if (splitIdx === halfLen) {
158
+ for (let i = halfLen + 1; i < Math.min(cleaned.length - 1, halfLen + 20); i++) {
159
+ if (cleaned[i].type === 'route' || cleaned[i].type === 'system') {
160
+ splitIdx = i + 1;
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ splitIdx = Math.max(1, Math.min(splitIdx, cleaned.length - 1));
166
+
167
+ const archivePart = cleaned.slice(0, splitIdx);
168
+ const remainPart = cleaned.slice(splitIdx);
169
+
170
+ const maxShard = await getMaxShardIndex(session.sharedDir);
171
+ for (let i = maxShard; i >= 1; i--) {
172
+ const src = join(session.sharedDir, `messages.${i}.json`);
173
+ const dst = join(session.sharedDir, `messages.${i + 1}.json`);
174
+ await fs.rename(src, dst).catch(() => {});
175
+ }
176
+
177
+ await fs.writeFile(join(session.sharedDir, 'messages.1.json'), JSON.stringify(archivePart));
178
+ await fs.writeFile(join(session.sharedDir, 'messages.json'), JSON.stringify(remainPart));
179
+ session.uiMessages = remainPart.map(m => ({ ...m }));
180
+
181
+ console.log(`[Crew] Rotated messages: archived ${archivePart.length} msgs to shard 1, kept ${remainPart.length} in active`);
182
+ } finally {
183
+ session._rotating = false;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 获取当前最大分片编号
189
+ */
190
+ export async function getMaxShardIndex(sharedDir) {
191
+ let max = 0;
192
+ try {
193
+ const files = await fs.readdir(sharedDir);
194
+ for (const f of files) {
195
+ const match = f.match(/^messages\.(\d+)\.json$/);
196
+ if (match) {
197
+ const idx = parseInt(match[1], 10);
198
+ if (idx > max) max = idx;
199
+ }
200
+ }
201
+ } catch { /* dir may not exist */ }
202
+ return max;
203
+ }
204
+
205
+ /**
206
+ * 删除所有消息分片文件
207
+ */
208
+ export async function cleanupMessageShards(sharedDir) {
209
+ try {
210
+ const files = await fs.readdir(sharedDir);
211
+ for (const f of files) {
212
+ if (/^messages\.\d+\.json$/.test(f)) {
213
+ await fs.unlink(join(sharedDir, f)).catch(() => {});
214
+ }
215
+ }
216
+ } catch { /* dir may not exist */ }
217
+ }
218
+
219
+ export async function loadSessionMeta(sharedDir) {
220
+ try { return JSON.parse(await fs.readFile(join(sharedDir, 'session.json'), 'utf-8')); }
221
+ catch { return null; }
222
+ }
223
+
224
+ export async function loadSessionMessages(sharedDir) {
225
+ let messages = [];
226
+ try { messages = JSON.parse(await fs.readFile(join(sharedDir, 'messages.json'), 'utf-8')); }
227
+ catch { /* file may not exist */ }
228
+ let hasOlderMessages = false;
229
+ try {
230
+ await fs.access(join(sharedDir, 'messages.1.json'));
231
+ hasOlderMessages = true;
232
+ } catch { /* no older shards */ }
233
+ return { messages, hasOlderMessages };
234
+ }
235
+
236
+ /**
237
+ * 加载历史消息分片(前端上滑到顶部时按需请求)
238
+ */
239
+ export async function handleLoadCrewHistory(msg) {
240
+ const { sessionId, requestId } = msg;
241
+ const shardIndex = parseInt(msg.shardIndex, 10);
242
+
243
+ // Lazy import to avoid circular dependency
244
+ const { crewSessions } = await import('./session.js');
245
+ const { sendCrewMessage } = await import('./ui-messages.js');
246
+
247
+ if (!Number.isFinite(shardIndex) || shardIndex < 1) {
248
+ sendCrewMessage({
249
+ type: 'crew_history_loaded',
250
+ sessionId,
251
+ shardIndex: msg.shardIndex,
252
+ requestId,
253
+ messages: [],
254
+ hasMore: false
255
+ });
256
+ return;
257
+ }
258
+ const session = crewSessions.get(sessionId);
259
+ if (!session) {
260
+ sendCrewMessage({
261
+ type: 'crew_history_loaded',
262
+ sessionId,
263
+ shardIndex,
264
+ requestId,
265
+ messages: [],
266
+ hasMore: false
267
+ });
268
+ return;
269
+ }
270
+
271
+ const shardPath = join(session.sharedDir, `messages.${shardIndex}.json`);
272
+ let messages = [];
273
+ try {
274
+ messages = JSON.parse(await fs.readFile(shardPath, 'utf-8'));
275
+ } catch { /* shard file doesn't exist */ }
276
+
277
+ const hasMore = shardIndex < await getMaxShardIndex(session.sharedDir);
278
+
279
+ sendCrewMessage({
280
+ type: 'crew_history_loaded',
281
+ sessionId,
282
+ shardIndex,
283
+ requestId,
284
+ messages,
285
+ hasMore
286
+ });
287
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Crew — 动态角色管理
3
+ * addRoleToSession, removeRoleFromSession
4
+ */
5
+ import { initRoleDir, updateSharedClaudeMd } from './shared-dir.js';
6
+ import { saveRoleSessionId } from './role-query.js';
7
+ import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
8
+
9
+ /** Format role label */
10
+ function roleLabel(r) {
11
+ return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
12
+ }
13
+
14
+ /**
15
+ * 向现有 session 动态添加角色
16
+ */
17
+ export async function addRoleToSession(msg) {
18
+ // Lazy import to avoid circular dependency
19
+ const { crewSessions, expandRoles } = await import('./session.js');
20
+
21
+ const { sessionId, role } = msg;
22
+ const session = crewSessions.get(sessionId);
23
+ if (!session) {
24
+ console.warn(`[Crew] Session not found: ${sessionId}`);
25
+ return;
26
+ }
27
+
28
+ const rolesToAdd = expandRoles([role]);
29
+
30
+ for (const r of rolesToAdd) {
31
+ if (session.roles.has(r.name)) {
32
+ console.warn(`[Crew] Role already exists: ${r.name}`);
33
+ continue;
34
+ }
35
+
36
+ session.roles.set(r.name, r);
37
+
38
+ if (r.isDecisionMaker) {
39
+ session.decisionMaker = r.name;
40
+ }
41
+ if (!session.decisionMaker) {
42
+ session.decisionMaker = r.name;
43
+ }
44
+
45
+ await initRoleDir(session.sharedDir, r, session.language || 'zh-CN');
46
+
47
+ console.log(`[Crew] Role added: ${r.name} (${r.displayName}) to session ${sessionId}`);
48
+
49
+ sendCrewMessage({
50
+ type: 'crew_role_added',
51
+ sessionId,
52
+ role: {
53
+ name: r.name,
54
+ displayName: r.displayName,
55
+ icon: r.icon,
56
+ description: r.description,
57
+ isDecisionMaker: r.isDecisionMaker || false,
58
+ model: r.model,
59
+ roleType: r.roleType,
60
+ groupIndex: r.groupIndex
61
+ },
62
+ decisionMaker: session.decisionMaker
63
+ });
64
+
65
+ sendCrewOutput(session, 'system', 'system', {
66
+ type: 'assistant',
67
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(r)} 加入了群聊` }] }
68
+ });
69
+ }
70
+
71
+ await updateSharedClaudeMd(session);
72
+ sendStatusUpdate(session);
73
+ }
74
+
75
+ /**
76
+ * 从 session 移除角色
77
+ */
78
+ export async function removeRoleFromSession(msg) {
79
+ const { crewSessions } = await import('./session.js');
80
+
81
+ const { sessionId, roleName } = msg;
82
+ const session = crewSessions.get(sessionId);
83
+ if (!session) {
84
+ console.warn(`[Crew] Session not found: ${sessionId}`);
85
+ return;
86
+ }
87
+
88
+ const role = session.roles.get(roleName);
89
+ if (!role) {
90
+ console.warn(`[Crew] Role not found: ${roleName}`);
91
+ return;
92
+ }
93
+
94
+ // 停止角色的 query
95
+ const roleState = session.roleStates.get(roleName);
96
+ if (roleState) {
97
+ if (roleState.claudeSessionId) {
98
+ await saveRoleSessionId(session.sharedDir, roleName, roleState.claudeSessionId);
99
+ }
100
+ if (roleState.abortController) {
101
+ roleState.abortController.abort();
102
+ }
103
+ session.roleStates.delete(roleName);
104
+ }
105
+
106
+ session.roles.delete(roleName);
107
+
108
+ if (session.decisionMaker === roleName) {
109
+ const remaining = Array.from(session.roles.values());
110
+ const newDM = remaining.find(r => r.isDecisionMaker) || remaining[0];
111
+ session.decisionMaker = newDM?.name || null;
112
+ }
113
+
114
+ await updateSharedClaudeMd(session);
115
+
116
+ console.log(`[Crew] Role removed: ${roleName} from session ${sessionId}`);
117
+
118
+ sendCrewMessage({
119
+ type: 'crew_role_removed',
120
+ sessionId,
121
+ roleName,
122
+ decisionMaker: session.decisionMaker
123
+ });
124
+
125
+ sendCrewOutput(session, 'system', 'system', {
126
+ type: 'assistant',
127
+ message: { role: 'assistant', content: [{ type: 'text', text: `${roleLabel(role)} 离开了群聊` }] }
128
+ });
129
+
130
+ sendStatusUpdate(session);
131
+ }