@yeaft/webchat-agent 1.0.186 → 1.0.189

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.
Files changed (121) hide show
  1. package/cli.js +12 -0
  2. package/index.js +10 -2
  3. package/local-run.js +218 -0
  4. package/local-runtime/server/.env.example +54 -0
  5. package/local-runtime/server/api.js +111 -0
  6. package/local-runtime/server/auth/aad.js +235 -0
  7. package/local-runtime/server/auth/login.js +156 -0
  8. package/local-runtime/server/auth/oauth-flow.js +277 -0
  9. package/local-runtime/server/auth/password-reset.js +134 -0
  10. package/local-runtime/server/auth/providers/alipay.js +125 -0
  11. package/local-runtime/server/auth/providers/github.js +82 -0
  12. package/local-runtime/server/auth/providers/google.js +60 -0
  13. package/local-runtime/server/auth/providers/microsoft.js +71 -0
  14. package/local-runtime/server/auth/providers/types.js +57 -0
  15. package/local-runtime/server/auth/providers/wechat.js +68 -0
  16. package/local-runtime/server/auth/register.js +91 -0
  17. package/local-runtime/server/auth/session-store.js +57 -0
  18. package/local-runtime/server/auth/token.js +85 -0
  19. package/local-runtime/server/auth/totp-auth.js +133 -0
  20. package/local-runtime/server/auth/utils.js +42 -0
  21. package/local-runtime/server/auth.js +8 -0
  22. package/local-runtime/server/check-node-version.js +74 -0
  23. package/local-runtime/server/config.js +298 -0
  24. package/local-runtime/server/context.js +140 -0
  25. package/local-runtime/server/create-user.js +59 -0
  26. package/local-runtime/server/database.js +12 -0
  27. package/local-runtime/server/db/connection.js +963 -0
  28. package/local-runtime/server/db/expert-db.js +171 -0
  29. package/local-runtime/server/db/identity-db.js +92 -0
  30. package/local-runtime/server/db/invitation-db.js +38 -0
  31. package/local-runtime/server/db/message-db.js +257 -0
  32. package/local-runtime/server/db/session-db.js +118 -0
  33. package/local-runtime/server/db/user-db.js +165 -0
  34. package/local-runtime/server/db/user-stats-db.js +185 -0
  35. package/local-runtime/server/db/yeaft-session-db.js +258 -0
  36. package/local-runtime/server/email.js +96 -0
  37. package/local-runtime/server/encryption.js +105 -0
  38. package/local-runtime/server/handlers/agent-conversation.js +347 -0
  39. package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
  40. package/local-runtime/server/handlers/agent-output.js +854 -0
  41. package/local-runtime/server/handlers/agent-sync.js +399 -0
  42. package/local-runtime/server/handlers/agent-work-center.js +27 -0
  43. package/local-runtime/server/handlers/client-conversation.js +1182 -0
  44. package/local-runtime/server/handlers/client-misc.js +254 -0
  45. package/local-runtime/server/handlers/client-work-center.js +269 -0
  46. package/local-runtime/server/handlers/client-workbench.js +146 -0
  47. package/local-runtime/server/handlers/session-pin-router.js +61 -0
  48. package/local-runtime/server/heartbeat-policy.js +46 -0
  49. package/local-runtime/server/index.js +275 -0
  50. package/local-runtime/server/package.json +55 -0
  51. package/local-runtime/server/perf-trace.js +154 -0
  52. package/local-runtime/server/proxy.js +273 -0
  53. package/local-runtime/server/routes/admin-routes.js +207 -0
  54. package/local-runtime/server/routes/auth-routes.js +322 -0
  55. package/local-runtime/server/routes/expert-routes.js +117 -0
  56. package/local-runtime/server/routes/invitation-routes.js +60 -0
  57. package/local-runtime/server/routes/session-routes.js +112 -0
  58. package/local-runtime/server/routes/upload-routes.js +109 -0
  59. package/local-runtime/server/routes/user-routes.js +241 -0
  60. package/local-runtime/server/totp.js +74 -0
  61. package/local-runtime/server/work-item-attachment-policy.js +56 -0
  62. package/local-runtime/server/ws-agent.js +319 -0
  63. package/local-runtime/server/ws-client.js +214 -0
  64. package/local-runtime/server/ws-utils.js +394 -0
  65. package/local-runtime/server/yeaft-asset-store.js +339 -0
  66. package/local-runtime/version.json +1 -0
  67. package/local-runtime/web/app.bundle.js +7673 -0
  68. package/local-runtime/web/app.bundle.js.gz +0 -0
  69. package/local-runtime/web/assets/avatars/README.md +34 -0
  70. package/local-runtime/web/assets/avatars/ada.svg +1 -0
  71. package/local-runtime/web/assets/avatars/alan.svg +1 -0
  72. package/local-runtime/web/assets/avatars/alice.svg +1 -0
  73. package/local-runtime/web/assets/avatars/anders.svg +1 -0
  74. package/local-runtime/web/assets/avatars/bezos.svg +1 -0
  75. package/local-runtime/web/assets/avatars/borges.svg +1 -0
  76. package/local-runtime/web/assets/avatars/buffett.svg +1 -0
  77. package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
  78. package/local-runtime/web/assets/avatars/dalio.svg +1 -0
  79. package/local-runtime/web/assets/avatars/dieter.svg +1 -0
  80. package/local-runtime/web/assets/avatars/drucker.svg +1 -0
  81. package/local-runtime/web/assets/avatars/einstein.svg +1 -0
  82. package/local-runtime/web/assets/avatars/grace.svg +1 -0
  83. package/local-runtime/web/assets/avatars/harari.svg +1 -0
  84. package/local-runtime/web/assets/avatars/jung.svg +1 -0
  85. package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
  86. package/local-runtime/web/assets/avatars/ken.svg +1 -0
  87. package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
  88. package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
  89. package/local-runtime/web/assets/avatars/linus.svg +1 -0
  90. package/local-runtime/web/assets/avatars/luxun.svg +1 -0
  91. package/local-runtime/web/assets/avatars/margaret.svg +1 -0
  92. package/local-runtime/web/assets/avatars/martin.svg +1 -0
  93. package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
  94. package/local-runtime/web/assets/avatars/munger.svg +1 -0
  95. package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
  96. package/local-runtime/web/assets/avatars/norman.svg +1 -0
  97. package/local-runtime/web/assets/avatars/shannon.svg +1 -0
  98. package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
  99. package/local-runtime/web/assets/avatars/socrates.svg +1 -0
  100. package/local-runtime/web/assets/avatars/steve.svg +1 -0
  101. package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
  102. package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
  103. package/local-runtime/web/docx-preview.min.js +2 -0
  104. package/local-runtime/web/docx-preview.min.js.gz +0 -0
  105. package/local-runtime/web/html-to-image.min.js +2 -0
  106. package/local-runtime/web/html-to-image.min.js.gz +0 -0
  107. package/local-runtime/web/index.html +21 -0
  108. package/local-runtime/web/jszip.min.js +13 -0
  109. package/local-runtime/web/jszip.min.js.gz +0 -0
  110. package/local-runtime/web/mermaid.min.js +2843 -0
  111. package/local-runtime/web/mermaid.min.js.gz +0 -0
  112. package/local-runtime/web/msal-browser.min.js +69 -0
  113. package/local-runtime/web/msal-browser.min.js.gz +0 -0
  114. package/local-runtime/web/style.bundle.css +10 -0
  115. package/local-runtime/web/style.bundle.css.gz +0 -0
  116. package/local-runtime/web/vendor.bundle.js +1522 -0
  117. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  118. package/local-runtime/web/xlsx.min.js +24 -0
  119. package/local-runtime/web/xlsx.min.js.gz +0 -0
  120. package/package.json +13 -3
  121. package/scripts/prepare-local-runtime.js +25 -0
@@ -0,0 +1,105 @@
1
+ import tweetnacl from 'tweetnacl';
2
+ import tweetnaclUtil from 'tweetnacl-util';
3
+ import zlib from 'zlib';
4
+ import { promisify } from 'util';
5
+
6
+ const gzip = promisify(zlib.gzip);
7
+ const gunzip = promisify(zlib.gunzip);
8
+
9
+ const { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } = tweetnaclUtil;
10
+
11
+ // Compression threshold: only compress if data > 512 bytes
12
+ const COMPRESS_THRESHOLD = 512;
13
+
14
+ /**
15
+ * Generate a new 32-byte session key
16
+ * @returns {Uint8Array} 32-byte random key
17
+ */
18
+ export function generateSessionKey() {
19
+ return tweetnacl.randomBytes(32);
20
+ }
21
+
22
+ /**
23
+ * Encrypt data using TweetNaCl secretbox (XSalsa20-Poly1305)
24
+ * Compresses data with gzip before encryption if > threshold
25
+ * @param {any} data - Data to encrypt (will be JSON stringified)
26
+ * @param {Uint8Array} key - 32-byte encryption key
27
+ * @returns {Promise<{n: string, c: string, z?: boolean}>} Object with base64 nonce, ciphertext, and optional compressed flag
28
+ */
29
+ export async function encrypt(data, key) {
30
+ const nonce = tweetnacl.randomBytes(24);
31
+ const jsonStr = JSON.stringify(data);
32
+
33
+ let message;
34
+ let compressed = false;
35
+
36
+ if (jsonStr.length > COMPRESS_THRESHOLD) {
37
+ // Compress before encryption
38
+ const compressedBuf = await gzip(Buffer.from(jsonStr, 'utf8'));
39
+ message = new Uint8Array(compressedBuf);
40
+ compressed = true;
41
+ } else {
42
+ message = decodeUTF8(jsonStr);
43
+ }
44
+
45
+ const encrypted = tweetnacl.secretbox(message, nonce, key);
46
+ const result = {
47
+ n: encodeBase64(nonce),
48
+ c: encodeBase64(encrypted)
49
+ };
50
+ if (compressed) result.z = true;
51
+ return result;
52
+ }
53
+
54
+ /**
55
+ * Decrypt data using TweetNaCl secretbox
56
+ * Decompresses data with gunzip after decryption if compressed
57
+ * @param {{n: string, c: string, z?: boolean}} encrypted - Object with base64 nonce, ciphertext, and optional compressed flag
58
+ * @param {Uint8Array} key - 32-byte encryption key
59
+ * @returns {Promise<any|null>} Decrypted and parsed data, or null if decryption fails
60
+ */
61
+ export async function decrypt(encrypted, key) {
62
+ try {
63
+ const nonce = decodeBase64(encrypted.n);
64
+ const ciphertext = decodeBase64(encrypted.c);
65
+ const decrypted = tweetnacl.secretbox.open(ciphertext, nonce, key);
66
+ if (!decrypted) return null;
67
+
68
+ if (encrypted.z) {
69
+ // Decompress after decryption
70
+ const decompressed = await gunzip(Buffer.from(decrypted));
71
+ return JSON.parse(decompressed.toString('utf8'));
72
+ } else {
73
+ return JSON.parse(encodeUTF8(decrypted));
74
+ }
75
+ } catch (err) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check if a message is encrypted (has n and c properties)
82
+ * @param {any} msg - Message to check
83
+ * @returns {boolean} True if message appears to be encrypted
84
+ */
85
+ export function isEncrypted(msg) {
86
+ return msg && typeof msg === 'object' && typeof msg.n === 'string' && typeof msg.c === 'string';
87
+ }
88
+
89
+ /**
90
+ * Encode session key to base64 for transmission
91
+ * @param {Uint8Array} key - Session key
92
+ * @returns {string} Base64 encoded key
93
+ */
94
+ export function encodeKey(key) {
95
+ return encodeBase64(key);
96
+ }
97
+
98
+ /**
99
+ * Decode session key from base64
100
+ * @param {string} encodedKey - Base64 encoded key
101
+ * @returns {Uint8Array} Session key
102
+ */
103
+ export function decodeKey(encodedKey) {
104
+ return decodeBase64(encodedKey);
105
+ }
@@ -0,0 +1,347 @@
1
+ import { sessionDb, messageDb } from '../database.js';
2
+ import {
3
+ broadcastAgentList, notifyConversationUpdate, forwardToClients
4
+ } from '../ws-utils.js';
5
+ import { agents } from '../context.js';
6
+ import { CONFIG } from '../config.js';
7
+
8
+ /**
9
+ * Handle conversation lifecycle messages from agent.
10
+ * Types: conversation_list, conversation_created, conversation_resumed,
11
+ * session_id_update, turn_completed, conversation_closed,
12
+ * conversation_deleted, history_sessions_list, folders_list,
13
+ * conversation_refresh, conversation_settings_updated
14
+ */
15
+ export async function handleAgentConversation(agentId, agent, msg) {
16
+ switch (msg.type) {
17
+ case 'conversation_list': {
18
+ // Agent 发送的 conversation 列表 - 合并而非覆盖,保留已有的 userId/username
19
+ const incomingIds = new Set(msg.conversations.map(c => c.id));
20
+ for (const [id, conv] of agent.conversations) {
21
+ // 保留从 DB 恢复的历史 conversations(agent 不会上报已完成的会话)
22
+ if (!incomingIds.has(id) && !conv.fromDb) {
23
+ agent.conversations.delete(id);
24
+ }
25
+ }
26
+ for (const conv of msg.conversations) {
27
+ // fix-session-dup: if the DB says this conv now belongs to a
28
+ // DIFFERENT agent (the user resumed it elsewhere), drop the
29
+ // agent's stale local copy. Without this guard, an agent
30
+ // restart can re-broadcast a transferred conv under its own
31
+ // banner, undoing the resume-time transfer and recreating
32
+ // the "same conv on two agents" condition.
33
+ const dbForConv = sessionDb.get(conv.id);
34
+ if (dbForConv && dbForConv.agent_id && dbForConv.agent_id !== agentId) {
35
+ if (CONFIG.debug) {
36
+ console.log(`[conversation_list] dropping conv ${conv.id} from agent ${agentId} — DB owner is ${dbForConv.agent_id}`);
37
+ }
38
+ agent.conversations.delete(conv.id);
39
+ continue;
40
+ }
41
+ const existing = agent.conversations.get(conv.id);
42
+ if (existing) {
43
+ // 原地更新属性而非替换对象,避免其他持有旧引用的代码失效
44
+ existing.workDir = conv.workDir || existing.workDir;
45
+ existing.claudeSessionId = conv.claudeSessionId || existing.claudeSessionId;
46
+ existing.createdAt = conv.createdAt || existing.createdAt;
47
+ if (conv.processing !== undefined) existing.processing = conv.processing;
48
+ // Agent 主动上报了这个 conversation,清除 DB 恢复标记
49
+ delete existing.fromDb;
50
+ if (conv.type) existing.type = conv.type;
51
+ // Security: 不信任 agent 上报的 userId/username,保留 server 端已有值
52
+ if (!existing.userId) {
53
+ const dbSession = sessionDb.get(conv.id);
54
+ existing.userId = dbSession?.user_id || agent.ownerId || null;
55
+ existing.username = dbSession?.username || agent.ownerUsername || null;
56
+ }
57
+ // fix-chat-title-sticky: hydrate the persisted title +
58
+ // sticky-bit on every list refresh. The agent doesn't track
59
+ // titles, so without this the in-memory `convInfo.customTitle`
60
+ // is stuck at `undefined` and the per-message auto-title write
61
+ // happily overwrites the user's renamed title.
62
+ if (existing.customTitle === undefined) {
63
+ const dbSession = sessionDb.get(conv.id);
64
+ if (dbSession) {
65
+ if (dbSession.title) existing.title = dbSession.title;
66
+ existing.customTitle = !!dbSession.customTitle;
67
+ }
68
+ }
69
+ } else {
70
+ // 新 conversation — 从 DB 或 agent.ownerId 获取 userId,不信任 agent 上报
71
+ const dbSession = sessionDb.get(conv.id);
72
+ const trustedUserId = dbSession?.user_id || agent.ownerId || null;
73
+ const trustedUsername = dbSession?.username || agent.ownerUsername || null;
74
+ agent.conversations.set(conv.id, {
75
+ ...conv,
76
+ userId: trustedUserId,
77
+ username: trustedUsername,
78
+ // Hydrate sticky title bits if the DB has them.
79
+ title: dbSession?.title || conv.title || null,
80
+ customTitle: !!dbSession?.customTitle,
81
+ });
82
+ }
83
+ }
84
+ await broadcastAgentList();
85
+ break;
86
+ }
87
+
88
+ case 'conversation_created':
89
+ case 'conversation_resumed': {
90
+ // 清理同 claudeSessionId 的旧条目(避免重复恢复同一个 session 累积)
91
+ if (msg.type === 'conversation_resumed' && msg.claudeSessionId) {
92
+ for (const [id, conv] of agent.conversations) {
93
+ if (id !== msg.conversationId && conv.claudeSessionId === msg.claudeSessionId) {
94
+ agent.conversations.delete(id);
95
+ }
96
+ }
97
+ }
98
+
99
+ // fix-session-dup: if this conv is currently held in another
100
+ // agent's in-memory Map (e.g. user resumed it against a
101
+ // different machine after the original agent went offline),
102
+ // remove the stale copy AND re-point the DB row at the new
103
+ // owner. Without this:
104
+ // 1) the next get_agents restore would re-seat the conv
105
+ // under the OLD agent's Map again from DB.agent_id,
106
+ // 2) the next broadcastAgentList would expose the conv
107
+ // via two different agent entries, producing the
108
+ // "one conversation, two sidebar rows with different
109
+ // badges" symptom this fix targets.
110
+ // We only run the transfer when the OWNER actually changes —
111
+ // resuming a conv on its own agent is a no-op for ownership.
112
+ for (const [otherAgentId, otherAgent] of agents) {
113
+ if (otherAgentId === agentId) continue;
114
+ if (otherAgent.conversations.has(msg.conversationId)) {
115
+ if (CONFIG.debug) {
116
+ console.log(`[conversation_resumed] transferring conv ${msg.conversationId} from agent ${otherAgentId} → ${agentId}`);
117
+ }
118
+ otherAgent.conversations.delete(msg.conversationId);
119
+ }
120
+ }
121
+
122
+ // Security: 使用 server 端可信来源的 userId,不信任 agent 回传
123
+ const existingConvData = agent.conversations.get(msg.conversationId);
124
+ const dbSessionData = sessionDb.get(msg.conversationId);
125
+ const trustedUserId = existingConvData?.userId || dbSessionData?.user_id || agent.ownerId || msg.userId || null;
126
+ const trustedUsername = existingConvData?.username || dbSessionData?.username || agent.ownerUsername || msg.username || null;
127
+ // fix-copilot-provider-persist: the agent stamps `provider` on
128
+ // create/resume (claude-code / copilot / ...). Carry it on the
129
+ // in-memory conv AND persist it (below) so an agent process restart
130
+ // doesn't lose the binding — without this the conv reverts to the
131
+ // default provider and copilot sends mis-route to Claude.
132
+ //
133
+ // Defense-in-depth against CLOBBER: the agent's resume handler
134
+ // DEFAULTS an absent provider to 'claude-code', so a provider-less
135
+ // resume (web auto-restore / recovery) reports provider:'claude-code'
136
+ // even for a copilot conv. Treat 'claude-code' as "no explicit
137
+ // provider" here: never let it overwrite a stored non-default. The
138
+ // server resume forward also injects the persisted provider as a
139
+ // belt-and-braces fix, but this guard makes the persist layer
140
+ // independently safe.
141
+ const reportedProvider = (msg.provider && msg.provider !== 'claude-code') ? msg.provider : null;
142
+ const resolvedProvider = reportedProvider || existingConvData?.provider || dbSessionData?.provider || null;
143
+
144
+ agent.conversations.set(msg.conversationId, {
145
+ id: msg.conversationId,
146
+ workDir: msg.workDir,
147
+ claudeSessionId: msg.claudeSessionId,
148
+ userId: trustedUserId,
149
+ username: trustedUsername,
150
+ ...(resolvedProvider ? { provider: resolvedProvider } : {}),
151
+ // fix-chat-title-sticky: hydrate the persisted title + sticky-bit
152
+ // from the DB on resume/recreate. Without this, a user-renamed
153
+ // session that's been resumed has `customTitle = undefined`,
154
+ // so the next user prompt overwrites the title.
155
+ //
156
+ // The OR-merge (`existing || db`) is deliberate: the in-memory
157
+ // value is fresher than the DB on the rare race where
158
+ // update_conversation_settings has just cleared the bit but the
159
+ // resume payload arrived from the agent before that write
160
+ // settled. Both sources move in lockstep on rename and clear,
161
+ // so they cannot disagree for long.
162
+ title: dbSessionData?.title || existingConvData?.title || null,
163
+ customTitle: !!(existingConvData?.customTitle || dbSessionData?.customTitle),
164
+ createdAt: Date.now(),
165
+ processing: false
166
+ });
167
+ try {
168
+ if (msg.type === 'conversation_created') {
169
+ if (!sessionDb.exists(msg.conversationId)) {
170
+ sessionDb.create(msg.conversationId, agentId, agent.name, msg.workDir, msg.claudeSessionId, null, trustedUserId, resolvedProvider);
171
+ } else if (resolvedProvider) {
172
+ sessionDb.setProvider(msg.conversationId, resolvedProvider);
173
+ }
174
+ } else {
175
+ if (sessionDb.exists(msg.conversationId)) {
176
+ sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
177
+ // fix-copilot-provider-persist: keep the persisted provider in
178
+ // sync on resume (e.g. first resume after this column shipped, or
179
+ // a provider change). Null msg.provider leaves it untouched.
180
+ if (resolvedProvider && dbSessionData?.provider !== resolvedProvider) {
181
+ sessionDb.setProvider(msg.conversationId, resolvedProvider);
182
+ }
183
+ // fix-session-dup: also re-point the persisted agent_id
184
+ // to the resuming agent when ownership actually changed.
185
+ // The DB row is the source of truth that the `get_agents`
186
+ // restore path consults, so failing to update it would
187
+ // re-summon the duplicate on the next reload.
188
+ if (dbSessionData && dbSessionData.agent_id !== agentId) {
189
+ if (CONFIG.debug) {
190
+ console.log(`[conversation_resumed] DB.agent_id ${dbSessionData.agent_id} → ${agentId} for ${msg.conversationId}`);
191
+ }
192
+ sessionDb.setAgent(msg.conversationId, agentId, agent.name);
193
+ }
194
+ } else {
195
+ sessionDb.create(msg.conversationId, agentId, agent.name, msg.workDir, msg.claudeSessionId, null, trustedUserId, resolvedProvider);
196
+ }
197
+ }
198
+ sessionDb.setActive(msg.conversationId, true);
199
+ } catch (e) {
200
+ console.error('Failed to save session to database:', e.message);
201
+ }
202
+ // Security: 覆盖 msg 中的 userId 为可信值
203
+ msg.userId = trustedUserId;
204
+ msg.username = trustedUsername;
205
+
206
+ // Phase 6.1: 将 historyMessages 同步到 DB(支持增量 merge)
207
+ if (msg.type === 'conversation_resumed' && msg.historyMessages && msg.historyMessages.length > 0) {
208
+ try {
209
+ const insertedCount = messageDb.bulkAddHistory(msg.conversationId, msg.historyMessages);
210
+ if (insertedCount > 0) {
211
+ console.log(`[conversation_resumed] Synced ${insertedCount} new messages to DB for ${msg.conversationId}`);
212
+ }
213
+ } catch (e) {
214
+ console.error('Failed to sync history to DB:', e.message);
215
+ }
216
+ // 从 DB 读取最后 5 turns 发给前端
217
+ const { messages: recentMessages, hasMore } = messageDb.getRecentTurns(msg.conversationId, 5);
218
+ delete msg.historyMessages;
219
+ msg.dbMessages = recentMessages;
220
+ msg.hasMoreMessages = hasMore;
221
+ }
222
+ msg.dbMessageCount = messageDb.getCount(msg.conversationId);
223
+
224
+ await notifyConversationUpdate(agentId, msg);
225
+ await broadcastAgentList();
226
+ break;
227
+ }
228
+
229
+ case 'session_id_update':
230
+ if (msg.conversationId && msg.claudeSessionId) {
231
+ const existingConv = agent.conversations.get(msg.conversationId);
232
+ if (existingConv) {
233
+ existingConv.claudeSessionId = msg.claudeSessionId;
234
+ }
235
+ try {
236
+ sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
237
+ console.log(`[session_id_update] Updated claudeSessionId for ${msg.conversationId}: ${msg.claudeSessionId}`);
238
+ } catch (e) {
239
+ console.error('Failed to update claudeSessionId in database:', e.message);
240
+ }
241
+ }
242
+ await broadcastAgentList();
243
+ break;
244
+
245
+ case 'turn_completed':
246
+ {
247
+ const turnConv = agent.conversations.get(msg.conversationId);
248
+ // Guard: 如果 processing 已为 false,说明是重复的 turn_completed,跳过
249
+ if (turnConv && !turnConv.processing) {
250
+ console.warn(`[turn_completed] Ignoring duplicate for ${msg.conversationId}`);
251
+ break;
252
+ }
253
+ if (turnConv) {
254
+ turnConv.processing = false;
255
+ if (msg.claudeSessionId) {
256
+ turnConv.claudeSessionId = msg.claudeSessionId;
257
+ }
258
+ if (msg.workDir) {
259
+ turnConv.workDir = msg.workDir;
260
+ }
261
+ }
262
+ try {
263
+ if (msg.claudeSessionId) {
264
+ sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
265
+ }
266
+ } catch (e) {
267
+ console.error('Failed to update session in database:', e.message);
268
+ }
269
+ await forwardToClients(agentId, msg.conversationId, {
270
+ type: 'turn_completed',
271
+ conversationId: msg.conversationId,
272
+ claudeSessionId: msg.claudeSessionId,
273
+ workDir: msg.workDir
274
+ });
275
+
276
+ await broadcastAgentList();
277
+ }
278
+ break;
279
+
280
+ case 'conversation_closed':
281
+ {
282
+ const closedConv = agent.conversations.get(msg.conversationId);
283
+ if (closedConv) {
284
+ closedConv.processing = false;
285
+ if (msg.claudeSessionId) {
286
+ closedConv.claudeSessionId = msg.claudeSessionId;
287
+ }
288
+ if (msg.workDir) {
289
+ closedConv.workDir = msg.workDir;
290
+ }
291
+ }
292
+ try {
293
+ sessionDb.setActive(msg.conversationId, false);
294
+ if (msg.claudeSessionId) {
295
+ sessionDb.update(msg.conversationId, { claudeSessionId: msg.claudeSessionId });
296
+ }
297
+ } catch (e) {
298
+ console.error('Failed to update session in database:', e.message);
299
+ }
300
+ await forwardToClients(agentId, msg.conversationId, {
301
+ type: 'conversation_closed',
302
+ conversationId: msg.conversationId,
303
+ claudeSessionId: msg.claudeSessionId,
304
+ workDir: msg.workDir
305
+ });
306
+
307
+ // ★ Remove from agent's in-memory conversations after marking inactive in DB.
308
+ // This prevents closed sessions from accumulating in agent_list broadcasts
309
+ // and reappearing in the sidebar. Session can be restored from DB if needed.
310
+ agent.conversations.delete(msg.conversationId);
311
+
312
+ await broadcastAgentList();
313
+ }
314
+ break;
315
+
316
+ case 'conversation_deleted':
317
+ agent.conversations.delete(msg.conversationId);
318
+ try {
319
+ sessionDb.setActive(msg.conversationId, false);
320
+ } catch (e) {
321
+ console.error('Failed to update session in database:', e.message);
322
+ }
323
+ await notifyConversationUpdate(agentId, msg);
324
+ await broadcastAgentList();
325
+ break;
326
+
327
+ case 'history_sessions_list':
328
+ case 'folders_list':
329
+ case 'models_list':
330
+ console.log(`[${msg.type}] Received from agent ${agentId}, forwarding to clients...`);
331
+ console.log(`[${msg.type}] count: folders=${msg.folders?.length || 0} models=${msg.models?.length || 0} sessions=${msg.sessions?.length || 0}`);
332
+ await notifyConversationUpdate(agentId, msg);
333
+ break;
334
+
335
+ case 'conversation_refresh':
336
+ await notifyConversationUpdate(agentId, msg);
337
+ break;
338
+
339
+ case 'conversation_settings_updated':
340
+ await forwardToClients(agentId, msg.conversationId, msg);
341
+ break;
342
+
343
+ default:
344
+ return false; // Not handled
345
+ }
346
+ return true; // Handled
347
+ }
@@ -0,0 +1,99 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { previewFiles, webClients } from '../context.js';
3
+ import {
4
+ sendToWebClient, forwardToClients,
5
+ setCachedDir, invalidateParentDirCache, clearAgentDirCache
6
+ } from '../ws-utils.js';
7
+
8
+ /**
9
+ * Handle file, terminal, and git messages from agent.
10
+ * Types: terminal_created, terminal_output, terminal_closed, terminal_error,
11
+ * file_content, file_saved, directory_listing, file_op_result,
12
+ * git_status_result, git_diff_result, git_op_result, file_search_result
13
+ */
14
+ export async function handleAgentFileTerminal(agentId, agent, msg) {
15
+ switch (msg.type) {
16
+ // Terminal messages (forward to web clients)
17
+ case 'terminal_created':
18
+ case 'terminal_output':
19
+ case 'terminal_closed':
20
+ case 'terminal_error':
21
+ await forwardToClients(agentId, msg.conversationId, msg);
22
+ break;
23
+
24
+ // File operation messages
25
+ case 'file_content':
26
+ if (msg.binary) {
27
+ // Binary file: cache on server, forward fileId instead of base64 content
28
+ const fileId = randomUUID();
29
+ const token = randomUUID();
30
+ const filename = msg.filePath.split('/').pop() || 'file';
31
+ previewFiles.set(fileId, {
32
+ buffer: Buffer.from(msg.content, 'base64'),
33
+ mimeType: msg.mimeType,
34
+ filename,
35
+ createdAt: Date.now(),
36
+ token
37
+ });
38
+ console.log(`[Server] Cached binary preview: fileId=${fileId}, mime=${msg.mimeType}, path=${msg.filePath}`);
39
+ const fwdMsg = {
40
+ type: 'file_content',
41
+ conversationId: msg.conversationId,
42
+ _requestUserId: msg._requestUserId,
43
+ filePath: msg.filePath,
44
+ binary: true,
45
+ fileId,
46
+ previewToken: token,
47
+ mimeType: msg.mimeType
48
+ };
49
+ await forwardToClients(agentId, msg.conversationId, fwdMsg);
50
+ } else {
51
+ console.log(`[Server] Forwarding file_content to clients, conv=${msg.conversationId}, path=${msg.filePath}`);
52
+ await forwardToClients(agentId, msg.conversationId, msg);
53
+ }
54
+ break;
55
+
56
+ case 'file_saved': {
57
+ // Phase 4: 文件保存后失效父目录缓存
58
+ invalidateParentDirCache(agentId, msg.filePath);
59
+ await forwardToClients(agentId, msg.conversationId, msg);
60
+ break;
61
+ }
62
+
63
+ case 'directory_listing': {
64
+ // Phase 4: 缓存目录列表结果
65
+ if (msg.dirPath && msg.entries && !msg.error) {
66
+ setCachedDir(agentId, msg.dirPath, msg.entries);
67
+ }
68
+ // 优先定向发送给请求者
69
+ const dirTargetClientId = msg._requestClientId;
70
+ if (dirTargetClientId) {
71
+ const targetClient = webClients.get(dirTargetClientId);
72
+ if (targetClient?.authenticated) {
73
+ const { _requestClientId, ...cleanMsg } = msg;
74
+ await sendToWebClient(targetClient, cleanMsg);
75
+ break;
76
+ }
77
+ }
78
+ await forwardToClients(agentId, msg.conversationId, msg);
79
+ break;
80
+ }
81
+
82
+ case 'file_op_result':
83
+ // Phase 4: 文件创建/删除/移动 — 清空该 agent 的所有目录缓存
84
+ clearAgentDirCache(agentId);
85
+ await forwardToClients(agentId, msg.conversationId, msg);
86
+ break;
87
+
88
+ case 'git_status_result':
89
+ case 'git_diff_result':
90
+ case 'git_op_result':
91
+ case 'file_search_result':
92
+ await forwardToClients(agentId, msg.conversationId, msg);
93
+ break;
94
+
95
+ default:
96
+ return false; // Not handled
97
+ }
98
+ return true; // Handled
99
+ }