@yeaft/webchat-agent 1.0.188 → 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,854 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { messageDb, yeaftSessionDb } from '../database.js';
3
+ import { broadcastAgentList, forwardToClients, sendToAgent, sendToWebClient } from '../ws-utils.js';
4
+ import { webClients, previewFiles } from '../context.js';
5
+ import { CONFIG } from '../config.js';
6
+ import { yeaftAssetStore } from '../yeaft-asset-store.js';
7
+ import { recordPerfTraceEvent } from '../perf-trace.js';
8
+
9
+
10
+ export function decorateYeaftSessionsWithPinned(agentId, sessions) {
11
+ const rawRows = Array.isArray(sessions) ? sessions : [];
12
+ const rowsById = new Map();
13
+ let authoritative = true;
14
+ try {
15
+ for (const row of yeaftSessionDb.getByAgent(agentId)) {
16
+ if (row && row.id) rowsById.set(row.id, row);
17
+ }
18
+ } catch (e) {
19
+ authoritative = false;
20
+ console.warn(`[Server] yeaft session metadata decorate read failed for agent ${agentId}:`, e?.message || e);
21
+ }
22
+ if (!authoritative) return rawRows;
23
+ return rawRows.map(s => {
24
+ if (!s || !s.id) return s;
25
+ const row = rowsById.get(s.id);
26
+ const pinned = !!row?.isPinned;
27
+ return {
28
+ ...s,
29
+ pinned,
30
+ isPinned: pinned,
31
+ ...(Number.isFinite(row?.sortOrder) ? { sortOrder: row.sortOrder } : {}),
32
+ };
33
+ });
34
+ }
35
+
36
+ function hydrateMessagePreviewData(message) {
37
+ const attachments = message?.attachments;
38
+ if (!Array.isArray(attachments) || attachments.length === 0) return message;
39
+ let changed = false;
40
+ const hydrated = attachments.map((att) => {
41
+ const previewData = att?.previewData;
42
+ if (!previewData?.data || att.preview) return att;
43
+ const fileId = randomUUID();
44
+ const token = randomUUID();
45
+ previewFiles.set(fileId, {
46
+ buffer: Buffer.from(previewData.data, 'base64'),
47
+ mimeType: previewData.mimeType || att.mimeType || 'application/octet-stream',
48
+ filename: previewData.filename || att.name || `attachment-${Date.now()}`,
49
+ createdAt: Date.now(),
50
+ token,
51
+ });
52
+ changed = true;
53
+ const { previewData: _previewData, ...rest } = att;
54
+ return {
55
+ ...rest,
56
+ preview: `/api/preview/${fileId}?token=${encodeURIComponent(token)}`,
57
+ };
58
+ });
59
+ return changed ? { ...message, attachments: hydrated } : message;
60
+ }
61
+
62
+ function hydrateInlinePreviewData(data) {
63
+ if (!data?.message) return data;
64
+ const message = hydrateMessagePreviewData(data.message);
65
+ return message === data.message ? data : { ...data, message };
66
+ }
67
+
68
+ function projectConfirmedAssetImages(messages, { ownerId, agentId, sessionId }) {
69
+ const turnIds = messages
70
+ .filter(message => message?.role === 'assistant' && message.imageAssetAnchor === true)
71
+ .map(message => message.turnId || message.threadId || null)
72
+ .filter(Boolean);
73
+ const imagesByTurn = yeaftAssetStore.describeTurns({ ownerId, agentId, sessionId, turnIds });
74
+ return messages.map((message) => {
75
+ if (!message || message.role !== 'assistant') return message;
76
+ const { images: _pendingImages, ...rest } = message;
77
+ if (message.imageAssetAnchor !== true) return rest;
78
+ const turnId = message.turnId || message.threadId || null;
79
+ const images = turnId ? imagesByTurn.get(turnId) || [] : [];
80
+ return images.length > 0 ? { ...rest, images } : rest;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Handle CLI/Yeaft output and interaction messages from agent.
86
+ * Types: claude_output (legacy Claude/Copilot CLI output frame),
87
+ * yeaft_output / yeaft_session_output / session_output,
88
+ * chat_image, context_usage, execution_cancelled,
89
+ * background_task_started, background_task_output,
90
+ * slash_commands_update, compact_status, ask_user_question,
91
+ * conversation_mcp_update, error,
92
+ * btw_stream, btw_done, btw_error
93
+ */
94
+ export async function handleAgentOutput(agentId, agent, msg) {
95
+ switch (msg.type) {
96
+ case 'claude_output':
97
+ // 保存消息到数据库
98
+ try {
99
+ const data = msg.data;
100
+ if (data && msg.conversationId) {
101
+ if (data.type === 'user' && data.message?.content) {
102
+ const rawContent = data.message.content;
103
+ const content = typeof rawContent === 'string'
104
+ ? rawContent
105
+ : (Array.isArray(rawContent) ? rawContent.map(b => b.text || '').join('') : JSON.stringify(rawContent));
106
+ // Skip empty content — tool_result arrays produce '' which shouldn't be stored as user messages
107
+ if (content) {
108
+ // 检查 convInfo 上暂存的 expertSelections,保存为 metadata
109
+ const conv = agent.conversations.get(msg.conversationId);
110
+ // fix-usermsg-dup: pull the client-stamped id stashed by the
111
+ // `chat` handler so the echo carries it back to the web for
112
+ // optimistic-dedup, AND so the DB row preserves it for
113
+ // post-refresh `sync_messages_result` replay. Without the DB
114
+ // half, refresh → resend would lose the dedup key and fall
115
+ // back to the unreliable content-equality path.
116
+ const clientMessageId = conv?._pendingClientMessageId || null;
117
+ // Review M2 (Torvalds): build the payload first, then
118
+ // clear the stash. Mixing mutation with build made the
119
+ // delete look conditional on the JSON construction.
120
+ const metaParts = {};
121
+ if (conv?._pendingExperts) metaParts.experts = conv._pendingExperts;
122
+ if (clientMessageId) metaParts.clientMessageId = clientMessageId;
123
+ if (conv) {
124
+ delete conv._pendingExperts;
125
+ delete conv._pendingClientMessageId;
126
+ }
127
+ const metadata = Object.keys(metaParts).length > 0 ? JSON.stringify(metaParts) : null;
128
+ const dbId = messageDb.add(msg.conversationId, 'user', content, 'user', null, null, metadata);
129
+ msg.data.dbMessageId = dbId;
130
+ if (clientMessageId) {
131
+ msg.data.clientMessageId = clientMessageId;
132
+ }
133
+ // User-turn stats are recorded at the web send boundary so
134
+ // inbound payload bytes are counted exactly once.
135
+ }
136
+ }
137
+ if (data.type === 'assistant' && data.message?.content) {
138
+ let content;
139
+ if (typeof data.message.content === 'string') {
140
+ content = data.message.content;
141
+ } else if (Array.isArray(data.message.content)) {
142
+ content = data.message.content
143
+ .filter(block => block.type === 'text' && block.text)
144
+ .map(block => block.text)
145
+ .join('');
146
+ } else {
147
+ content = JSON.stringify(data.message.content);
148
+ }
149
+ if (content) {
150
+ const dbId = messageDb.add(msg.conversationId, 'assistant', content, 'assistant');
151
+ msg.data.dbMessageId = dbId;
152
+ }
153
+ }
154
+ if (data.type === 'assistant' && data.message?.content) {
155
+ const contents = Array.isArray(data.message.content)
156
+ ? data.message.content
157
+ : [data.message.content];
158
+ for (const item of contents) {
159
+ if (item.type === 'tool_use') {
160
+ messageDb.add(
161
+ msg.conversationId,
162
+ 'assistant',
163
+ JSON.stringify(item.input || {}),
164
+ 'tool_use',
165
+ item.name,
166
+ JSON.stringify(item.input || {})
167
+ );
168
+ }
169
+ }
170
+ }
171
+ if (data.type === 'result') {
172
+ const resultContent = typeof data.result === 'string'
173
+ ? data.result
174
+ : JSON.stringify(data.result);
175
+ const truncated = resultContent.length > 10000
176
+ ? resultContent.slice(0, 10000) + '...[truncated]'
177
+ : resultContent;
178
+ messageDb.add(msg.conversationId, 'tool', truncated, 'tool_result');
179
+ }
180
+ }
181
+ } catch (e) {
182
+ // 静默处理保存错误,不影响正常流程
183
+ }
184
+ await forwardToClients(agentId, msg.conversationId, {
185
+ type: 'claude_output',
186
+ conversationId: msg.conversationId,
187
+ data: msg.data
188
+ });
189
+ break;
190
+
191
+ case 'context_usage':
192
+ await forwardToClients(agentId, msg.conversationId, {
193
+ type: 'context_usage',
194
+ conversationId: msg.conversationId,
195
+ inputTokens: msg.inputTokens,
196
+ maxTokens: msg.maxTokens,
197
+ percentage: msg.percentage
198
+ });
199
+ break;
200
+
201
+ case 'execution_cancelled': {
202
+ const cancelledConv = agent.conversations.get(msg.conversationId);
203
+ if (cancelledConv) {
204
+ cancelledConv.processing = false;
205
+ }
206
+ await forwardToClients(agentId, msg.conversationId, {
207
+ type: 'execution_cancelled',
208
+ conversationId: msg.conversationId
209
+ });
210
+ await broadcastAgentList();
211
+ break;
212
+ }
213
+
214
+ case 'background_task_started':
215
+ console.log(`[Background] Task started: ${msg.task?.id} in conversation ${msg.conversationId}`);
216
+ await forwardToClients(agentId, msg.conversationId, {
217
+ type: 'background_task_started',
218
+ conversationId: msg.conversationId,
219
+ task: msg.task
220
+ });
221
+ break;
222
+
223
+ case 'background_task_output':
224
+ await forwardToClients(agentId, msg.conversationId, {
225
+ type: 'background_task_output',
226
+ conversationId: msg.conversationId,
227
+ taskId: msg.taskId,
228
+ task: msg.task,
229
+ newOutput: msg.newOutput
230
+ });
231
+ break;
232
+
233
+ case 'subagent_started':
234
+ case 'subagent_message':
235
+ case 'subagent_completed':
236
+ await forwardToClients(agentId, msg.conversationId, msg);
237
+ break;
238
+
239
+ case 'slash_commands_update':
240
+ // 缓存到 agent 对象上,供 web 端选择 agent 时立即获取
241
+ agent.slashCommands = msg.slashCommands || [];
242
+ if (msg.slashCommandDescriptions) {
243
+ agent.slashCommandDescriptions = { ...agent.slashCommandDescriptions, ...msg.slashCommandDescriptions };
244
+ }
245
+ if (msg.conversationId === '__preload__') {
246
+ // Agent-level preload: broadcast to all owner clients with agentId
247
+ for (const [, client] of webClients) {
248
+ if (client.authenticated && (CONFIG.skipAuth || agent.ownerId === client.userId)) {
249
+ await sendToWebClient(client, {
250
+ type: 'slash_commands_update',
251
+ agentId,
252
+ slashCommands: msg.slashCommands,
253
+ slashCommandDescriptions: agent.slashCommandDescriptions || {}
254
+ });
255
+ }
256
+ }
257
+ } else {
258
+ // Per-conversation update. Stamp agentId too so
259
+ // Yeaft's temporary/local conversation id cannot hide the agent-level
260
+ // fallback command list in the web store.
261
+ await forwardToClients(agentId, msg.conversationId, {
262
+ type: 'slash_commands_update',
263
+ agentId,
264
+ conversationId: msg.conversationId,
265
+ slashCommands: msg.slashCommands,
266
+ slashCommandDescriptions: agent.slashCommandDescriptions || {}
267
+ });
268
+ }
269
+ break;
270
+
271
+ case 'compact_status':
272
+ console.log(`[Compact] Status: ${msg.status} for conversation ${msg.conversationId}`);
273
+ await forwardToClients(agentId, msg.conversationId, {
274
+ type: 'compact_status',
275
+ conversationId: msg.conversationId,
276
+ status: msg.status,
277
+ message: msg.message
278
+ });
279
+ break;
280
+
281
+ case 'ask_user_question':
282
+ console.log(`[AskUser] Question for conversation ${msg.conversationId}, requestId: ${msg.requestId}`);
283
+ // Persist requestId + questions into the AskUserQuestion tool_use DB record
284
+ try {
285
+ if (msg.conversationId && msg.requestId) {
286
+ // Search enough messages to find the tool_use record (may be far from latest
287
+ // if there was a lot of streaming output between tool_use and ask_user_question)
288
+ const recent = messageDb.getRecent(msg.conversationId, 100);
289
+ // Find the LATEST unlinked AskUserQuestion (search from newest to oldest)
290
+ let askMsg = null;
291
+ for (let i = recent.length - 1; i >= 0; i--) {
292
+ const m = recent[i];
293
+ if (m.message_type === 'tool_use' && m.tool_name === 'AskUserQuestion') {
294
+ // Check if already linked to a different requestId
295
+ if (m.metadata) {
296
+ try {
297
+ const existing = JSON.parse(m.metadata);
298
+ if (existing.askRequestId && existing.askRequestId !== msg.requestId) continue;
299
+ } catch { /* proceed */ }
300
+ }
301
+ askMsg = m;
302
+ break;
303
+ }
304
+ }
305
+ if (askMsg) {
306
+ messageDb.updateMetadata(askMsg.id, JSON.stringify({
307
+ askRequestId: msg.requestId,
308
+ askQuestions: msg.questions
309
+ }));
310
+ }
311
+ }
312
+ } catch (e) {
313
+ // Silent — don't block the main flow
314
+ }
315
+ await forwardToClients(agentId, msg.conversationId, {
316
+ type: 'ask_user_question',
317
+ conversationId: msg.conversationId,
318
+ requestId: msg.requestId,
319
+ questions: msg.questions
320
+ });
321
+ break;
322
+
323
+ case 'conversation_mcp_update':
324
+ await forwardToClients(agentId, msg.conversationId, {
325
+ type: 'conversation_mcp_update',
326
+ conversationId: msg.conversationId,
327
+ servers: msg.servers,
328
+ serverTools: msg.serverTools
329
+ });
330
+ break;
331
+
332
+ case 'error':
333
+ await forwardToClients(agentId, msg.conversationId, {
334
+ type: 'error',
335
+ conversationId: msg.conversationId,
336
+ message: msg.message
337
+ });
338
+ break;
339
+
340
+ case 'btw_stream':
341
+ await forwardToClients(agentId, msg.conversationId, {
342
+ type: 'btw_stream',
343
+ conversationId: msg.conversationId,
344
+ delta: msg.delta
345
+ });
346
+ break;
347
+
348
+ case 'btw_done':
349
+ await forwardToClients(agentId, msg.conversationId, {
350
+ type: 'btw_done',
351
+ conversationId: msg.conversationId
352
+ });
353
+ break;
354
+
355
+ case 'btw_error':
356
+ await forwardToClients(agentId, msg.conversationId, {
357
+ type: 'btw_error',
358
+ conversationId: msg.conversationId,
359
+ error: msg.error
360
+ });
361
+ break;
362
+
363
+ case 'pong_session': {
364
+ // Forward pong to specific client (if clientId provided) or broadcast
365
+ if (msg.clientId) {
366
+ const targetClient = webClients.get(msg.clientId);
367
+ if (targetClient) {
368
+ await sendToWebClient(targetClient, {
369
+ type: 'pong_session',
370
+ conversationId: msg.conversationId,
371
+ status: msg.status,
372
+ isProcessing: msg.isProcessing,
373
+ currentTool: msg.currentTool
374
+ });
375
+ }
376
+ } else {
377
+ await forwardToClients(agentId, msg.conversationId, {
378
+ type: 'pong_session',
379
+ conversationId: msg.conversationId,
380
+ status: msg.status,
381
+ isProcessing: msg.isProcessing,
382
+ currentTool: msg.currentTool
383
+ });
384
+ }
385
+ break;
386
+ }
387
+
388
+ case 'chat_image': {
389
+ // Image from Claude response (tool screenshots, etc.) — persisted on agent side,
390
+ // cached on server in previewFiles for web client serving via /api/preview/:fileId
391
+ const dataSize = msg.data ? Buffer.byteLength(msg.data, 'base64') : 0;
392
+ if (dataSize > 10 * 1024 * 1024) {
393
+ console.warn(`[Server] Chat image too large: ${dataSize} bytes, skipping`);
394
+ break;
395
+ }
396
+ const fileId = randomUUID();
397
+ const token = randomUUID();
398
+ previewFiles.set(fileId, {
399
+ buffer: Buffer.from(msg.data, 'base64'),
400
+ mimeType: msg.mimeType,
401
+ filename: msg.filename || `chat-${Date.now()}.png`,
402
+ createdAt: Date.now(),
403
+ token
404
+ });
405
+ await forwardToClients(agentId, msg.conversationId, {
406
+ type: 'chat_image',
407
+ conversationId: msg.conversationId,
408
+ fileId,
409
+ previewToken: token,
410
+ mimeType: msg.mimeType
411
+ });
412
+ console.log(`[Server] Cached chat image: fileId=${fileId}, conv=${msg.conversationId}, mime=${msg.mimeType}`);
413
+ break;
414
+ }
415
+
416
+ case 'yeaft_asset_put': {
417
+ let ok = false;
418
+ let stored = false;
419
+ let error = null;
420
+ try {
421
+ if (!agent.ownerId || !msg.sessionId || !msg.image?.previewData?.data) {
422
+ throw new Error('Incomplete Yeaft asset payload');
423
+ }
424
+ const image = yeaftAssetStore.put({
425
+ ownerId: agent.ownerId, agentId, sessionId: msg.sessionId,
426
+ assetId: msg.metadata?.assetId || msg.image.assetId, data: msg.image.previewData.data,
427
+ mimeType: msg.metadata?.mimeType || msg.image.mimeType || msg.image.previewData.mimeType,
428
+ filename: msg.metadata?.filename || msg.image.filename || msg.image.previewData.filename,
429
+ width: msg.metadata?.width ?? msg.image.width,
430
+ height: msg.metadata?.height ?? msg.image.height,
431
+ turnId: msg.turnId || null,
432
+ vpId: msg.vpId || null,
433
+ threadId: msg.threadId || null,
434
+ });
435
+ stored = true;
436
+ await forwardToClients(agentId, msg.conversationId, {
437
+ type: 'yeaft_asset_ready', conversationId: msg.conversationId,
438
+ agentId, sessionId: msg.sessionId,
439
+ ...(msg.vpId ? { vpId: msg.vpId } : {}),
440
+ ...(msg.turnId ? { turnId: msg.turnId } : {}),
441
+ ...(msg.threadId ? { threadId: msg.threadId } : {}),
442
+ image, _requestUserId: agent.ownerId,
443
+ });
444
+ ok = true;
445
+ } catch (err) {
446
+ error = err?.message || String(err);
447
+ console.warn(`[Server] Failed to store Yeaft asset: ${error}`);
448
+ }
449
+ if (msg.deliveryId) {
450
+ await sendToAgent(agent, {
451
+ type: 'yeaft_asset_ack',
452
+ deliveryId: msg.deliveryId,
453
+ ok: ok || stored,
454
+ ...(!stored && /quota exceeded|invalid|unsupported|MIME|must be between|does not match|incomplete/i.test(error || '') ? { permanent: true } : {}),
455
+ ...(error ? { error } : {}),
456
+ });
457
+ }
458
+ break;
459
+ }
460
+
461
+ case 'yeaft_output':
462
+ case 'yeaft_session_output':
463
+ case 'session_output': {
464
+ const data = hydrateInlinePreviewData(msg.data);
465
+ if (msg.event?.type === 'yeaft_status') {
466
+ agent.yeaftStatus = msg.event;
467
+ await broadcastAgentList();
468
+ }
469
+ if (msg.perfTraceId) {
470
+ recordPerfTraceEvent({
471
+ traceId: msg.perfTraceId,
472
+ source: 'server',
473
+ phase: 'relay.agent_output_received',
474
+ at: Date.now(),
475
+ userId: agent.ownerId || null,
476
+ agentId,
477
+ sessionId: msg.sessionId || null,
478
+ vpId: msg.vpId || null,
479
+ turnId: msg.turnId || null,
480
+ threadId: msg.threadId || null,
481
+ messageType: data?.type || msg.event?.type || msg.type,
482
+ bytes: Buffer.byteLength(JSON.stringify(msg)),
483
+ });
484
+ }
485
+ // Forward Yeaft Session output to all authenticated clients of this agent's owner.
486
+ // Payload carries { conversationId, data } (assistant output frame) or { event } (metadata).
487
+ //
488
+ // Compatibility: accept neutral aliases from newer agents, but always
489
+ // relay the legacy `yeaft_output` envelope so older web bundles continue
490
+ // to render during rolling upgrades.
491
+ //
492
+ // Envelope passthrough — every field the agent stamped on the envelope
493
+ // (groupId, vpId, turnId) MUST be forwarded verbatim. The
494
+ // frontend uses vpId + turnId in `handleYeaftOutput` to set
495
+ // `_currentYeaftVpId` / `_currentYeaftTurnId`, which in turn drive
496
+ // `stampSpeakerOnVpMessage` so streaming assistant deltas get a
497
+ // `speakerVpId`. Without that, MessageList falls through from
498
+ // VpTurnBlock (with avatar) to a plain AssistantTurn (no avatar) —
499
+ // the bug v0.1.756 fixed. Keep this spread in sync with the agent
500
+ // side `sendSessionOutputFrame` / `sendSessionEvent` in agent/yeaft/web-bridge.js.
501
+ //
502
+ // (2026-05-13: `featureId` field dropped from the envelope along
503
+ // with the rest of the Feature system.)
504
+ //
505
+ // Forward semantics: `!= null` (not truthy) — a relay should
506
+ // pass through whatever was stamped, including empty strings. IDs
507
+ // are non-empty in practice, but we don't want the relay silently
508
+ // eating a legitimate "" or 0 if one ever shows up.
509
+ for (const [cId, c] of webClients) {
510
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
511
+ await sendToWebClient(c, {
512
+ type: 'yeaft_output',
513
+ conversationId: msg.conversationId,
514
+ ...(msg.perfTraceId != null ? { perfTraceId: msg.perfTraceId } : {}),
515
+ // Stamp the source agent so the web sessions store can keep
516
+ // per-agent rosters (cross-agent listing in the unified
517
+ // sidebar). Older web bundles ignore the extra field.
518
+ agentId: agentId,
519
+ ...(msg.sessionId != null ? { sessionId: msg.sessionId } : {}),
520
+ ...(msg.vpId != null ? { vpId: msg.vpId } : {}),
521
+ ...(msg.turnId != null ? { turnId: msg.turnId } : {}),
522
+ ...(msg.threadId != null ? { threadId: msg.threadId } : {}),
523
+ data,
524
+ event: msg.event,
525
+ });
526
+ if (msg.perfTraceId) {
527
+ recordPerfTraceEvent({
528
+ traceId: msg.perfTraceId,
529
+ source: 'server',
530
+ phase: 'relay.forward_to_web',
531
+ at: Date.now(),
532
+ userId: c.userId || null,
533
+ agentId,
534
+ sessionId: msg.sessionId || null,
535
+ vpId: msg.vpId || null,
536
+ turnId: msg.turnId || null,
537
+ threadId: msg.threadId || null,
538
+ messageType: data?.type || msg.event?.type || msg.type,
539
+ });
540
+ }
541
+ }
542
+ }
543
+ break;
544
+ }
545
+
546
+ case 'yeaft_history_search_result': {
547
+ const targetClient = msg._requestClientId ? webClients.get(msg._requestClientId) : null;
548
+ if (targetClient?.authenticated && (CONFIG.skipAuth || targetClient.userId === agent.ownerId)) {
549
+ await sendToWebClient(targetClient, {
550
+ type: 'yeaft_history_search_result',
551
+ agentId,
552
+ requestId: msg.requestId ?? null,
553
+ sessionId: msg.sessionId ?? null,
554
+ query: msg.query || '',
555
+ results: Array.isArray(msg.results) ? msg.results : [],
556
+ hasMore: !!msg.hasMore,
557
+ nextBeforeSeq: msg.nextBeforeSeq ?? null,
558
+ ...(msg.error ? { error: msg.error } : {}),
559
+ });
560
+ }
561
+ break;
562
+ }
563
+
564
+ case 'yeaft_history_window': {
565
+ const targetClient = msg._requestClientId ? webClients.get(msg._requestClientId) : null;
566
+ if (targetClient?.authenticated && (CONFIG.skipAuth || targetClient.userId === agent.ownerId)) {
567
+ const messages = Array.isArray(msg.messages) ? projectConfirmedAssetImages(
568
+ msg.messages.map(hydrateMessagePreviewData),
569
+ { ownerId: agent.ownerId, agentId, sessionId: msg.sessionId },
570
+ ) : [];
571
+ await sendToWebClient(targetClient, {
572
+ type: 'yeaft_history_window',
573
+ agentId,
574
+ requestId: msg.requestId ?? null,
575
+ conversationId: msg.conversationId ?? null,
576
+ sessionId: msg.sessionId ?? null,
577
+ anchorMessageId: msg.anchorMessageId ?? null,
578
+ anchorSeq: msg.anchorSeq ?? null,
579
+ messages,
580
+ oldestSeq: msg.oldestSeq ?? null,
581
+ hasMoreBefore: !!msg.hasMoreBefore,
582
+ ...(msg.error ? { error: msg.error } : {}),
583
+ });
584
+ }
585
+ break;
586
+ }
587
+
588
+ case 'yeaft_history_chunk': {
589
+ const messages = Array.isArray(msg.messages)
590
+ ? projectConfirmedAssetImages(
591
+ msg.messages.map(hydrateMessagePreviewData),
592
+ { ownerId: agent.ownerId, agentId, sessionId: msg.sessionId },
593
+ )
594
+ : [];
595
+ // Forward a "load older messages" pagination chunk to the same
596
+ // authenticated clients. Distinct from `yeaft_output` because the
597
+ // frontend needs to PREPEND these older messages above the current
598
+ // history (whereas yeaft_output flows through an append pipeline).
599
+ if (msg.perfTraceId) {
600
+ recordPerfTraceEvent({
601
+ traceId: msg.perfTraceId,
602
+ source: 'server',
603
+ phase: 'relay.agent_output_received',
604
+ at: Date.now(),
605
+ userId: agent.ownerId || null,
606
+ agentId,
607
+ sessionId: msg.sessionId || null,
608
+ messageType: msg.type,
609
+ bytes: Buffer.byteLength(JSON.stringify(msg)),
610
+ detail: { mode: msg.mode || 'older', count: messages.length },
611
+ });
612
+ }
613
+ for (const [cId, c] of webClients) {
614
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
615
+ await sendToWebClient(c, {
616
+ type: 'yeaft_history_chunk',
617
+ conversationId: msg.conversationId,
618
+ ...(msg.perfTraceId != null ? { perfTraceId: msg.perfTraceId } : {}),
619
+ ...(msg.sessionId != null ? { sessionId: msg.sessionId } : {}),
620
+ messages,
621
+ mode: msg.mode || 'older',
622
+ oldestSeq: msg.oldestSeq ?? null,
623
+ hasMore: !!msg.hasMore,
624
+ latestSeq: msg.latestSeq ?? null,
625
+ afterSeq: msg.afterSeq ?? null,
626
+ turns: msg.turns ?? null,
627
+ });
628
+ if (msg.perfTraceId) {
629
+ recordPerfTraceEvent({
630
+ traceId: msg.perfTraceId,
631
+ source: 'server',
632
+ phase: 'relay.forward_to_web',
633
+ at: Date.now(),
634
+ userId: c.userId || null,
635
+ agentId,
636
+ sessionId: msg.sessionId || null,
637
+ messageType: msg.type,
638
+ });
639
+ }
640
+ }
641
+ }
642
+ break;
643
+ }
644
+
645
+ case 'yeaft_dream_status':
646
+ case 'yeaft_dream_result':
647
+ // Forward Dream lifecycle envelopes to the web client.
648
+ //
649
+ // The bug this fixes: `handleYeaftDreamTrigger` in
650
+ // `agent/yeaft/web-bridge.js` emits these as BARE top-level
651
+ // messages when the user clicks "Run dream now" (in the topbar or
652
+ // group settings). Without a case here, the switch hit
653
+ // `default: return false` and silently dropped the message,
654
+ // leaving the UI stuck on "Running…" even after the dream pass
655
+ // completed.
656
+ //
657
+ // Whitelist spread matches the pattern used by sibling cases
658
+ // (`yeaft_output`, `yeaft_history_chunk`) so a future agent-side
659
+ // bug that tags an internal field onto a dream envelope can't
660
+ // leak it to the web client. Keep in sync with the agent emit at
661
+ // `handleYeaftDreamTrigger`.
662
+ for (const [, c] of webClients) {
663
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
664
+ await sendToWebClient(c, {
665
+ type: msg.type,
666
+ ...(msg.sessionId != null ? { sessionId: msg.sessionId } : {}),
667
+ ...(msg.vpId != null ? { vpId: msg.vpId } : {}),
668
+ ...(msg.status != null ? { status: msg.status } : {}),
669
+ ...(typeof msg.success === 'boolean' ? { success: msg.success } : {}),
670
+ ...(typeof msg.entriesCreated === 'number' ? { entriesCreated: msg.entriesCreated } : {}),
671
+ ...(msg.lastDreamAt != null ? { lastDreamAt: msg.lastDreamAt } : {}),
672
+ ...(msg.skipped ? { skipped: true } : {}),
673
+ ...(msg.error != null ? { error: msg.error } : {}),
674
+ ...(Array.isArray(msg.groups) ? { groups: msg.groups } : {}),
675
+ ...(Array.isArray(msg.targets) ? { targets: msg.targets } : {}),
676
+ ...(msg.startedAt != null ? { startedAt: msg.startedAt } : {}),
677
+ });
678
+ }
679
+ }
680
+ break;
681
+
682
+ case 'yeaft_tool_stats':
683
+ // 2026-05-13: relay tool-call counters from the agent to the web
684
+ // client that requested them. Whitelist `snapshot`/`registered`/
685
+ // `unused` so a future schema bump can't leak unrelated fields,
686
+ // and tolerate a `error` field for graceful failure replies.
687
+ for (const [, c] of webClients) {
688
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
689
+ await sendToWebClient(c, {
690
+ type: 'yeaft_tool_stats',
691
+ ...(msg.snapshot && typeof msg.snapshot === 'object' ? { snapshot: msg.snapshot } : { snapshot: {} }),
692
+ ...(Array.isArray(msg.registered) ? { registered: msg.registered } : { registered: [] }),
693
+ ...(Array.isArray(msg.unused) ? { unused: msg.unused } : { unused: [] }),
694
+ ...(msg.error != null ? { error: msg.error } : {}),
695
+ });
696
+ }
697
+ }
698
+ break;
699
+
700
+ case 'session_list_updated': {
701
+ // Server-side persistence for yeaft sessions. Mirror the agent's
702
+ // authoritative snapshot into the `yeaft_sessions` table so the
703
+ // unified sidebar can show this user's yeaft sessions across all
704
+ // their agents (online or not) and survive reload. Pure shadow
705
+ // store — agent's on-disk `~/.yeaft/sessions/` remains canonical.
706
+ // Forwarding to the web continues unchanged below.
707
+ try {
708
+ const rows = Array.isArray(msg.sessions) ? msg.sessions : [];
709
+ if (agent.ownerId) {
710
+ yeaftSessionDb.reconcileFromSnapshot(agent.ownerId, agentId, rows);
711
+ }
712
+ } catch (e) {
713
+ console.warn(`[Server] yeaft session persist failed for agent ${agentId}:`, e?.message || e);
714
+ }
715
+ // fix-yeaft-session-list-and-menu: decorate the outgoing snapshot
716
+ // with the server-side pin state. The agent has no notion of
717
+ // pinning (pin is UI metadata that lives in the `yeaft_sessions`
718
+ // table); the web sessions store reads `pinned: true` off each
719
+ // row in applySnapshot and mirrors it into chatStore.pinnedSessions
720
+ // so chat + yeaft share a single pin registry for sort logic.
721
+ //
722
+ // Use one batch read (`getByAgent`) and a Set lookup instead of an
723
+ // N+1 `get(id)` per row — relays this size hot path on every
724
+ // snapshot per connected client.
725
+ const decoratedSessions = decorateYeaftSessionsWithPinned(agentId, msg.sessions);
726
+ // Relay verbatim to web (agentId stamped so the web sessions store
727
+ // can merge per-agent rosters).
728
+ for (const [, c] of webClients) {
729
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
730
+ await sendToWebClient(c, {
731
+ type: msg.type,
732
+ agentId: agentId,
733
+ sessions: decoratedSessions,
734
+ });
735
+ }
736
+ }
737
+ break;
738
+ }
739
+
740
+ case 'session_roster_changed': {
741
+ // Delta event — update only the affected session row. Same as
742
+ // above: keep the DB shadow + forward to web.
743
+ try {
744
+ if (agent.ownerId && msg) {
745
+ const sessionId = msg.sessionId;
746
+ if (sessionId) {
747
+ const existing = yeaftSessionDb.getForAgent(agent.ownerId, agentId, sessionId);
748
+ // Roster-delta path is a cache update, not authoritative
749
+ // creation. If we've never seen this row before, skip and
750
+ // wait for the next full snapshot (which carries truthful
751
+ // createdAt / workDir / config).
752
+ if (!existing) break;
753
+ const merged = {
754
+ id: sessionId,
755
+ name: msg.name != null ? msg.name : (existing?.name || sessionId),
756
+ roster: Array.isArray(msg.roster)
757
+ ? msg.roster
758
+ : (existing?.roster || []),
759
+ defaultVpId: msg.defaultVpId != null
760
+ ? msg.defaultVpId
761
+ : (existing?.defaultVpId || null),
762
+ workDir: existing?.workDir || '',
763
+ config: existing?.config || {},
764
+ announcement: typeof msg.announcement === 'string'
765
+ ? msg.announcement
766
+ : (existing?.announcement || ''),
767
+ createdAt: existing?.createdAt || Date.now(),
768
+ };
769
+ yeaftSessionDb.upsertFromSnapshot(agent.ownerId, agentId, merged);
770
+ }
771
+ }
772
+ } catch (e) {
773
+ console.warn(`[Server] yeaft roster persist failed for agent ${agentId}:`, e?.message || e);
774
+ }
775
+ for (const [, c] of webClients) {
776
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
777
+ await sendToWebClient(c, { ...msg, agentId: agentId });
778
+ }
779
+ }
780
+ break;
781
+ }
782
+
783
+ case 'session_crud_result': {
784
+ // Surface op result to the web (the only client that cares about
785
+ // requestId routing). Keep the server shadow table in sync for list
786
+ // probes too: entering Yeaft asks each connected agent for its opened
787
+ // sessions, and that response must carry persisted server-side pin
788
+ // state before it hits the web store.
789
+ let outboundMsg = msg;
790
+ try {
791
+ const op = msg.op;
792
+ const sessionId = msg.sessionId;
793
+ if (msg.ok && op === 'list' && Array.isArray(msg.sessions)) {
794
+ if (agent.ownerId) yeaftSessionDb.reconcileFromSnapshot(agent.ownerId, agentId, msg.sessions);
795
+ outboundMsg = { ...msg, sessions: decorateYeaftSessionsWithPinned(agentId, msg.sessions) };
796
+ } else if (msg.ok && sessionId && (op === 'delete' || op === 'archive')) {
797
+ if (op === 'archive') {
798
+ yeaftSessionDb.setArchivedForAgent(agent.ownerId, agentId, sessionId, true);
799
+ } else {
800
+ try {
801
+ yeaftSessionDb.deleteForAgent(agent.ownerId, agentId, sessionId);
802
+ } catch (e) {
803
+ console.warn('[Server] Yeaft Session metadata cleanup failed:', e?.message || e);
804
+ }
805
+ try {
806
+ yeaftAssetStore.deleteScope({ ownerId: agent.ownerId, agentId, sessionId });
807
+ } catch (e) {
808
+ console.warn('[Server] Yeaft asset cleanup failed:', e?.message || e);
809
+ }
810
+ }
811
+ }
812
+ } catch (e) {
813
+ console.warn(`[Server] yeaft crud-result persist failed:`, e?.message || e);
814
+ }
815
+ for (const [, c] of webClients) {
816
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
817
+ await sendToWebClient(c, { ...outboundMsg, agentId: agentId });
818
+ }
819
+ }
820
+ break;
821
+ }
822
+
823
+ case 'yeaft_debug_history':
824
+ // Relay the file-backed debug trace snapshot from the agent to the web
825
+ // client that requested it via `yeaft_fetch_debug_history`. Keep the
826
+ // paging/detail flags intact; otherwise the store cannot distinguish a
827
+ // bounded index refresh from a single-request detail hydration.
828
+ for (const [, c] of webClients) {
829
+ if (c.authenticated && (CONFIG.skipAuth || c.userId === agent.ownerId)) {
830
+ await sendToWebClient(c, {
831
+ type: 'yeaft_debug_history',
832
+ loops: Array.isArray(msg.loops) ? msg.loops : [],
833
+ turns: Array.isArray(msg.turns) ? msg.turns : [],
834
+ dreamEvents: Array.isArray(msg.dreamEvents) ? msg.dreamEvents : [],
835
+ ...(msg.sessionId != null ? { sessionId: msg.sessionId } : {}),
836
+ ...(msg.threadId != null ? { threadId: msg.threadId } : {}),
837
+ ...(msg.requestId != null ? { requestId: msg.requestId } : {}),
838
+ ...(msg.requestKind != null ? { requestKind: msg.requestKind } : {}),
839
+ ...(msg.search != null ? { search: msg.search } : {}),
840
+ ...(msg.hasMore != null ? { hasMore: !!msg.hasMore } : {}),
841
+ ...(msg.limit != null ? { limit: msg.limit } : {}),
842
+ ...(msg.indexOnly != null ? { indexOnly: !!msg.indexOnly } : {}),
843
+ ...(msg.detailTurnId != null ? { detailTurnId: msg.detailTurnId } : {}),
844
+ ...(msg.error != null ? { error: msg.error } : {}),
845
+ });
846
+ }
847
+ }
848
+ break;
849
+
850
+ default:
851
+ return false; // Not handled
852
+ }
853
+ return true; // Handled
854
+ }