@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.
- package/cli.js +12 -0
- package/index.js +10 -2
- package/local-run.js +218 -0
- package/local-runtime/server/.env.example +54 -0
- package/local-runtime/server/api.js +111 -0
- package/local-runtime/server/auth/aad.js +235 -0
- package/local-runtime/server/auth/login.js +156 -0
- package/local-runtime/server/auth/oauth-flow.js +277 -0
- package/local-runtime/server/auth/password-reset.js +134 -0
- package/local-runtime/server/auth/providers/alipay.js +125 -0
- package/local-runtime/server/auth/providers/github.js +82 -0
- package/local-runtime/server/auth/providers/google.js +60 -0
- package/local-runtime/server/auth/providers/microsoft.js +71 -0
- package/local-runtime/server/auth/providers/types.js +57 -0
- package/local-runtime/server/auth/providers/wechat.js +68 -0
- package/local-runtime/server/auth/register.js +91 -0
- package/local-runtime/server/auth/session-store.js +57 -0
- package/local-runtime/server/auth/token.js +85 -0
- package/local-runtime/server/auth/totp-auth.js +133 -0
- package/local-runtime/server/auth/utils.js +42 -0
- package/local-runtime/server/auth.js +8 -0
- package/local-runtime/server/check-node-version.js +74 -0
- package/local-runtime/server/config.js +298 -0
- package/local-runtime/server/context.js +140 -0
- package/local-runtime/server/create-user.js +59 -0
- package/local-runtime/server/database.js +12 -0
- package/local-runtime/server/db/connection.js +963 -0
- package/local-runtime/server/db/expert-db.js +171 -0
- package/local-runtime/server/db/identity-db.js +92 -0
- package/local-runtime/server/db/invitation-db.js +38 -0
- package/local-runtime/server/db/message-db.js +257 -0
- package/local-runtime/server/db/session-db.js +118 -0
- package/local-runtime/server/db/user-db.js +165 -0
- package/local-runtime/server/db/user-stats-db.js +185 -0
- package/local-runtime/server/db/yeaft-session-db.js +258 -0
- package/local-runtime/server/email.js +96 -0
- package/local-runtime/server/encryption.js +105 -0
- package/local-runtime/server/handlers/agent-conversation.js +347 -0
- package/local-runtime/server/handlers/agent-file-terminal.js +99 -0
- package/local-runtime/server/handlers/agent-output.js +854 -0
- package/local-runtime/server/handlers/agent-sync.js +399 -0
- package/local-runtime/server/handlers/agent-work-center.js +27 -0
- package/local-runtime/server/handlers/client-conversation.js +1182 -0
- package/local-runtime/server/handlers/client-misc.js +254 -0
- package/local-runtime/server/handlers/client-work-center.js +269 -0
- package/local-runtime/server/handlers/client-workbench.js +146 -0
- package/local-runtime/server/handlers/session-pin-router.js +61 -0
- package/local-runtime/server/heartbeat-policy.js +46 -0
- package/local-runtime/server/index.js +275 -0
- package/local-runtime/server/package.json +55 -0
- package/local-runtime/server/perf-trace.js +154 -0
- package/local-runtime/server/proxy.js +273 -0
- package/local-runtime/server/routes/admin-routes.js +207 -0
- package/local-runtime/server/routes/auth-routes.js +322 -0
- package/local-runtime/server/routes/expert-routes.js +117 -0
- package/local-runtime/server/routes/invitation-routes.js +60 -0
- package/local-runtime/server/routes/session-routes.js +112 -0
- package/local-runtime/server/routes/upload-routes.js +109 -0
- package/local-runtime/server/routes/user-routes.js +241 -0
- package/local-runtime/server/totp.js +74 -0
- package/local-runtime/server/work-item-attachment-policy.js +56 -0
- package/local-runtime/server/ws-agent.js +319 -0
- package/local-runtime/server/ws-client.js +214 -0
- package/local-runtime/server/ws-utils.js +394 -0
- package/local-runtime/server/yeaft-asset-store.js +339 -0
- package/local-runtime/version.json +1 -0
- package/local-runtime/web/app.bundle.js +7673 -0
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/assets/avatars/README.md +34 -0
- package/local-runtime/web/assets/avatars/ada.svg +1 -0
- package/local-runtime/web/assets/avatars/alan.svg +1 -0
- package/local-runtime/web/assets/avatars/alice.svg +1 -0
- package/local-runtime/web/assets/avatars/anders.svg +1 -0
- package/local-runtime/web/assets/avatars/bezos.svg +1 -0
- package/local-runtime/web/assets/avatars/borges.svg +1 -0
- package/local-runtime/web/assets/avatars/buffett.svg +1 -0
- package/local-runtime/web/assets/avatars/clausewitz.svg +1 -0
- package/local-runtime/web/assets/avatars/dalio.svg +1 -0
- package/local-runtime/web/assets/avatars/dieter.svg +1 -0
- package/local-runtime/web/assets/avatars/drucker.svg +1 -0
- package/local-runtime/web/assets/avatars/einstein.svg +1 -0
- package/local-runtime/web/assets/avatars/grace.svg +1 -0
- package/local-runtime/web/assets/avatars/harari.svg +1 -0
- package/local-runtime/web/assets/avatars/jung.svg +1 -0
- package/local-runtime/web/assets/avatars/kahneman.svg +1 -0
- package/local-runtime/web/assets/avatars/ken.svg +1 -0
- package/local-runtime/web/assets/avatars/kongzi.svg +1 -0
- package/local-runtime/web/assets/avatars/kubrick.svg +1 -0
- package/local-runtime/web/assets/avatars/linus.svg +1 -0
- package/local-runtime/web/assets/avatars/luxun.svg +1 -0
- package/local-runtime/web/assets/avatars/margaret.svg +1 -0
- package/local-runtime/web/assets/avatars/martin.svg +1 -0
- package/local-runtime/web/assets/avatars/miyazaki.svg +1 -0
- package/local-runtime/web/assets/avatars/munger.svg +1 -0
- package/local-runtime/web/assets/avatars/nietzsche.svg +1 -0
- package/local-runtime/web/assets/avatars/norman.svg +1 -0
- package/local-runtime/web/assets/avatars/shannon.svg +1 -0
- package/local-runtime/web/assets/avatars/simaqian.svg +1 -0
- package/local-runtime/web/assets/avatars/socrates.svg +1 -0
- package/local-runtime/web/assets/avatars/steve.svg +1 -0
- package/local-runtime/web/assets/avatars/sudongpo.svg +1 -0
- package/local-runtime/web/assets/avatars/sunzi.svg +1 -0
- package/local-runtime/web/docx-preview.min.js +2 -0
- package/local-runtime/web/docx-preview.min.js.gz +0 -0
- package/local-runtime/web/html-to-image.min.js +2 -0
- package/local-runtime/web/html-to-image.min.js.gz +0 -0
- package/local-runtime/web/index.html +21 -0
- package/local-runtime/web/jszip.min.js +13 -0
- package/local-runtime/web/jszip.min.js.gz +0 -0
- package/local-runtime/web/mermaid.min.js +2843 -0
- package/local-runtime/web/mermaid.min.js.gz +0 -0
- package/local-runtime/web/msal-browser.min.js +69 -0
- package/local-runtime/web/msal-browser.min.js.gz +0 -0
- package/local-runtime/web/style.bundle.css +10 -0
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor.bundle.js +1522 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/local-runtime/web/xlsx.min.js +24 -0
- package/local-runtime/web/xlsx.min.js.gz +0 -0
- package/package.json +13 -3
- package/scripts/prepare-local-runtime.js +25 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { CONFIG } from '../config.js';
|
|
3
|
+
import { sessionDb, messageDb, userDb, yeaftSessionDb } from '../database.js';
|
|
4
|
+
import { agents, pendingFiles, trackUserTurn, webClients } from '../context.js';
|
|
5
|
+
import {
|
|
6
|
+
sendToWebClient, forwardToAgent,
|
|
7
|
+
broadcastAgentList, verifyConversationOwnership, verifyAgentOwnership
|
|
8
|
+
} from '../ws-utils.js';
|
|
9
|
+
import { routeSessionPin } from './session-pin-router.js';
|
|
10
|
+
import { recordPerfTraceEvent } from '../perf-trace.js';
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
function isRetiredCollabSessionId(id) {
|
|
14
|
+
return typeof id === 'string' && id.startsWith('cr' + 'ew_');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function emptyYeaftToolStats(reason = '') {
|
|
18
|
+
const payload = {
|
|
19
|
+
type: 'yeaft_tool_stats',
|
|
20
|
+
snapshot: {},
|
|
21
|
+
registered: [],
|
|
22
|
+
unused: [],
|
|
23
|
+
};
|
|
24
|
+
if (reason) payload.notice = reason;
|
|
25
|
+
return payload;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function broadcastSessionPin(userId, payload) {
|
|
29
|
+
for (const [, target] of webClients) {
|
|
30
|
+
if (!target?.authenticated) continue;
|
|
31
|
+
if (!CONFIG.skipAuth && target.userId !== userId) continue;
|
|
32
|
+
await sendToWebClient(target, payload);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function groupOnlineYeaftSessions(rows, agentRegistry = agents) {
|
|
37
|
+
const byAgent = {};
|
|
38
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
39
|
+
if (!row?.agentId) continue;
|
|
40
|
+
const agent = agentRegistry.get(row.agentId);
|
|
41
|
+
if (!agent || agent.ws?.readyState !== 1) continue;
|
|
42
|
+
(byAgent[row.agentId] ||= []).push(row);
|
|
43
|
+
}
|
|
44
|
+
return byAgent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Review C1 (Fowler): the frontend stamps a `clientMessageId` on
|
|
49
|
+
* every `chat` payload (see web/stores/helpers/conversation.js#
|
|
50
|
+
* makeClientMessageId). The server is REQUIRED to round-trip the
|
|
51
|
+
* id unchanged, but must NOT trust its shape — a hostile client
|
|
52
|
+
* could otherwise stash a 10 MB string on `convInfo`, persist it
|
|
53
|
+
* to the messages.metadata column, broadcast it to every web
|
|
54
|
+
* subscriber, and replay it on every history hydrate.
|
|
55
|
+
*
|
|
56
|
+
* Format constraint matches `crypto.randomUUID()` output prefixed
|
|
57
|
+
* with `cm_`: `cm_<8>-<4>-<4>-<4>-<12>` (36 hex chars + 4 dashes
|
|
58
|
+
* + 3 fixed bytes = 39 chars total inside the prefix → 42 total).
|
|
59
|
+
* Allow a tolerant superset for forward-compat (any id-safe
|
|
60
|
+
* character, capped at 80 chars) so a future format bump doesn't
|
|
61
|
+
* have to ship in lockstep.
|
|
62
|
+
*/
|
|
63
|
+
function isValidClientMessageId(s) {
|
|
64
|
+
return typeof s === 'string' && /^cm_[a-zA-Z0-9_-]{1,80}$/.test(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function skippedYeaftDreamResult(msg, reason) {
|
|
68
|
+
const payload = {
|
|
69
|
+
type: 'yeaft_dream_result',
|
|
70
|
+
success: false,
|
|
71
|
+
skipped: true,
|
|
72
|
+
skippedReason: reason,
|
|
73
|
+
trigger: msg?.trigger || 'manual',
|
|
74
|
+
error: null,
|
|
75
|
+
};
|
|
76
|
+
if (msg?.sessionId) payload.sessionId = msg.sessionId;
|
|
77
|
+
if (msg?.vpId) payload.vpId = msg.vpId;
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle conversation lifecycle messages from web client.
|
|
83
|
+
* Types: get_agents, select_agent, create_conversation, resume_conversation,
|
|
84
|
+
* delete_conversation, select_conversation, sync_messages, chat,
|
|
85
|
+
* get_conversations, list_history_sessions, list_folders,
|
|
86
|
+
* cancel_execution, refresh_conversation,
|
|
87
|
+
* update_conversation_settings, ask_user_answer, btw_question
|
|
88
|
+
*/
|
|
89
|
+
export async function handleClientConversation(clientId, client, msg, checkAgentAccess) {
|
|
90
|
+
switch (msg.type) {
|
|
91
|
+
case 'get_agents':
|
|
92
|
+
// 前端可能附带 conversationIds(server 重启后恢复场景)
|
|
93
|
+
if (msg.conversationIds?.length > 0 && client.userId) {
|
|
94
|
+
for (const convId of msg.conversationIds) {
|
|
95
|
+
if (isRetiredCollabSessionId(convId)) continue;
|
|
96
|
+
const dbSession = sessionDb.get(convId);
|
|
97
|
+
if (!dbSession) continue;
|
|
98
|
+
if (dbSession.user_id && dbSession.user_id !== client.userId && !CONFIG.skipAuth) continue;
|
|
99
|
+
const agent = agents.get(dbSession.agent_id);
|
|
100
|
+
if (!agent) continue;
|
|
101
|
+
if (agent.conversations.has(convId)) continue;
|
|
102
|
+
agent.conversations.set(convId, {
|
|
103
|
+
id: convId,
|
|
104
|
+
workDir: dbSession.work_dir,
|
|
105
|
+
claudeSessionId: dbSession.claude_session_id,
|
|
106
|
+
title: dbSession.title,
|
|
107
|
+
// fix-chat-title-sticky: hydrate sticky bit from DB so the
|
|
108
|
+
// per-message auto-title write at line 351 can't clobber a
|
|
109
|
+
// user-renamed title after a server-restart restore.
|
|
110
|
+
customTitle: !!dbSession.customTitle,
|
|
111
|
+
// fix-copilot-provider-persist: restore the code-agent provider
|
|
112
|
+
// so the UI marker reappears and sends route to the right backend
|
|
113
|
+
// after an agent process restart.
|
|
114
|
+
...(dbSession.provider ? { provider: dbSession.provider } : {}),
|
|
115
|
+
createdAt: dbSession.created_at,
|
|
116
|
+
userId: dbSession.user_id || client.userId,
|
|
117
|
+
username: client.username,
|
|
118
|
+
fromDb: true
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Restore all active Chat sessions for this user from DB (cross-client sync).
|
|
123
|
+
if (client.userId) {
|
|
124
|
+
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
|
|
125
|
+
const cutoff = Date.now() - TWO_DAYS_MS;
|
|
126
|
+
const activeSessions = sessionDb.getActiveByUser(client.userId);
|
|
127
|
+
for (const dbSession of activeSessions) {
|
|
128
|
+
if (isRetiredCollabSessionId(dbSession.id)) continue;
|
|
129
|
+
// Pinned sessions never auto-expire
|
|
130
|
+
if (dbSession.is_pinned) {
|
|
131
|
+
// Still need to restore pinned sessions to agent memory
|
|
132
|
+
} else if (dbSession.updated_at < cutoff) {
|
|
133
|
+
// Auto-deactivate stale sessions (not updated in 2 days)
|
|
134
|
+
try { sessionDb.setActive(dbSession.id, false); } catch (e) { /* ignore */ }
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const agent = agents.get(dbSession.agent_id);
|
|
138
|
+
if (!agent) continue;
|
|
139
|
+
if (agent.conversations.has(dbSession.id)) continue;
|
|
140
|
+
// For sessions with user_id IS NULL: only restore if agent belongs to this user
|
|
141
|
+
// or skipAuth is enabled (prevents leaking orphan sessions to wrong users)
|
|
142
|
+
if (!dbSession.user_id && !CONFIG.skipAuth && agent.ownerId !== client.userId) continue;
|
|
143
|
+
agent.conversations.set(dbSession.id, {
|
|
144
|
+
id: dbSession.id,
|
|
145
|
+
workDir: dbSession.work_dir,
|
|
146
|
+
claudeSessionId: dbSession.claude_session_id,
|
|
147
|
+
title: dbSession.title,
|
|
148
|
+
// fix-chat-title-sticky: same hydration as the
|
|
149
|
+
// conversationIds branch above.
|
|
150
|
+
customTitle: !!dbSession.customTitle,
|
|
151
|
+
// fix-copilot-provider-persist: same provider restore as above.
|
|
152
|
+
...(dbSession.provider ? { provider: dbSession.provider } : {}),
|
|
153
|
+
createdAt: dbSession.created_at,
|
|
154
|
+
userId: dbSession.user_id || client.userId,
|
|
155
|
+
username: client.username,
|
|
156
|
+
fromDb: true
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
await broadcastAgentList();
|
|
161
|
+
// Yeaft session rows are only actionable while their owning Agent is
|
|
162
|
+
// connected. Keep offline rows in the DB for reconnect recovery, but do
|
|
163
|
+
// not hydrate them into the sidebar where remove/settings cannot work.
|
|
164
|
+
// Group by agentId so the web store can keep using per-agent snapshots.
|
|
165
|
+
if (client.userId) {
|
|
166
|
+
try {
|
|
167
|
+
const allRows = yeaftSessionDb.getByUser(client.userId);
|
|
168
|
+
const byAgent = groupOnlineYeaftSessions(allRows);
|
|
169
|
+
for (const [agentId, sessions] of Object.entries(byAgent)) {
|
|
170
|
+
await sendToWebClient(client, {
|
|
171
|
+
type: 'yeaft_session_hydrate',
|
|
172
|
+
agentId,
|
|
173
|
+
sessions,
|
|
174
|
+
fromDb: true,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.warn('[Server] yeaft session hydrate failed:', e?.message || e);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case 'select_agent': {
|
|
184
|
+
if (!await checkAgentAccess(msg.agentId)) break;
|
|
185
|
+
const agent = agents.get(msg.agentId);
|
|
186
|
+
if (agent && agent.ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
187
|
+
client.currentAgent = msg.agentId;
|
|
188
|
+
if (!msg.silent) {
|
|
189
|
+
client.currentConversation = null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (msg.silent) break;
|
|
193
|
+
|
|
194
|
+
const filteredConvs = Array.from(agent.conversations.values()).filter(c =>
|
|
195
|
+
CONFIG.skipAuth || !c.userId || c.userId === client.userId
|
|
196
|
+
).map(c => {
|
|
197
|
+
// fix-chat-title-sticky: lazy-hydrate the title AND the
|
|
198
|
+
// sticky bit on send. This catches conversations rebuilt
|
|
199
|
+
// before the bit was wired through (older code paths,
|
|
200
|
+
if (!c.title || c.customTitle === undefined) {
|
|
201
|
+
const dbSession = sessionDb.get(c.id);
|
|
202
|
+
if (dbSession?.title && !c.title) c.title = dbSession.title;
|
|
203
|
+
if (c.customTitle === undefined) c.customTitle = !!dbSession?.customTitle;
|
|
204
|
+
}
|
|
205
|
+
return c;
|
|
206
|
+
});
|
|
207
|
+
await sendToWebClient(client, {
|
|
208
|
+
type: 'agent_selected',
|
|
209
|
+
agentId: msg.agentId,
|
|
210
|
+
agentName: agent.name,
|
|
211
|
+
workDir: agent.workDir,
|
|
212
|
+
capabilities: agent.capabilities || ['terminal', 'file_editor', 'background_tasks'],
|
|
213
|
+
version: agent.version || null,
|
|
214
|
+
conversations: filteredConvs,
|
|
215
|
+
slashCommands: agent.slashCommands || [],
|
|
216
|
+
slashCommandDescriptions: agent.slashCommandDescriptions || {}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// If slash commands cache is empty, ask Agent to reload from filesystem
|
|
220
|
+
if (!agent.slashCommands?.length) {
|
|
221
|
+
await forwardToAgent(msg.agentId, { type: 'request_slash_commands' });
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent not found or offline' });
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'create_conversation': {
|
|
230
|
+
const createAgentId = msg.agentId || client.currentAgent;
|
|
231
|
+
if (!await checkAgentAccess(createAgentId)) return;
|
|
232
|
+
const createAgent = agents.get(createAgentId);
|
|
233
|
+
if (!createAgent) {
|
|
234
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent not found' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (createAgent.status === 'syncing') {
|
|
238
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent is still syncing, please wait...' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
client.currentAgent = createAgentId;
|
|
242
|
+
await forwardToAgent(createAgentId, {
|
|
243
|
+
type: 'create_conversation',
|
|
244
|
+
conversationId: msg.conversationId || randomUUID(),
|
|
245
|
+
workDir: msg.workDir,
|
|
246
|
+
userId: client.userId,
|
|
247
|
+
username: client.username,
|
|
248
|
+
provider: msg.provider,
|
|
249
|
+
providerOptions: msg.providerOptions,
|
|
250
|
+
disallowedTools: msg.disallowedTools
|
|
251
|
+
});
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'resume_conversation': {
|
|
256
|
+
const resumeAgentId = msg.agentId || client.currentAgent;
|
|
257
|
+
if (!await checkAgentAccess(resumeAgentId)) return;
|
|
258
|
+
const resumeAgent = agents.get(resumeAgentId);
|
|
259
|
+
if (!resumeAgent) {
|
|
260
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent not found' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (resumeAgent.status === 'syncing') {
|
|
264
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent is still syncing, please wait...' });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
client.currentAgent = resumeAgentId;
|
|
268
|
+
// fix-copilot-provider-persist: the web's auto-restore / recovery
|
|
269
|
+
// resume paths (web/stores/helpers/session.js) send no provider, and
|
|
270
|
+
// the agent's resume handler defaults an absent provider to
|
|
271
|
+
// 'claude-code'. Without the persisted fallback here, resuming a
|
|
272
|
+
// copilot conversation would emit conversation_resumed{provider:
|
|
273
|
+
// 'claude-code'} and the persist path would CLOBBER the stored
|
|
274
|
+
// 'copilot' binding — reintroducing the bug, permanently. Inject the
|
|
275
|
+
// persisted provider so a provider-less resume keeps the real one.
|
|
276
|
+
const resumeProvider = msg.provider || sessionDb.get(msg.conversationId)?.provider || undefined;
|
|
277
|
+
await forwardToAgent(resumeAgentId, {
|
|
278
|
+
type: 'resume_conversation',
|
|
279
|
+
conversationId: msg.conversationId || randomUUID(),
|
|
280
|
+
claudeSessionId: msg.claudeSessionId,
|
|
281
|
+
workDir: msg.workDir,
|
|
282
|
+
userId: client.userId,
|
|
283
|
+
username: client.username,
|
|
284
|
+
provider: resumeProvider,
|
|
285
|
+
providerOptions: msg.providerOptions,
|
|
286
|
+
disallowedTools: msg.disallowedTools
|
|
287
|
+
});
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'delete_conversation': {
|
|
292
|
+
if (!client.currentAgent) return;
|
|
293
|
+
|
|
294
|
+
// ★ DB cleanup: always execute regardless of agent online status
|
|
295
|
+
// Use verifyConversationOwnership (checks DB) instead of checkAgentAccess (requires agent in memory)
|
|
296
|
+
// This ensures close/delete works even when the agent is offline
|
|
297
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
|
|
298
|
+
console.warn(`[Security] User ${client.userId} attempted to delete conversation ${msg.conversationId} they don't own`);
|
|
299
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Always deactivate in DB — this is the critical fix
|
|
304
|
+
try {
|
|
305
|
+
sessionDb.setActive(msg.conversationId, false);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
console.error('Failed to deactivate session in database:', e.message);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Remove from agent's in-memory conversations (if agent is online)
|
|
311
|
+
const deleteAgent = agents.get(client.currentAgent);
|
|
312
|
+
if (deleteAgent) {
|
|
313
|
+
deleteAgent.conversations.delete(msg.conversationId);
|
|
314
|
+
}
|
|
315
|
+
await broadcastAgentList();
|
|
316
|
+
|
|
317
|
+
// Forward to agent for resource cleanup (terminals, processes, etc.) — best effort
|
|
318
|
+
// Only attempt if agent is online with an open WebSocket
|
|
319
|
+
if (deleteAgent?.ws?.readyState === 1) {
|
|
320
|
+
await forwardToAgent(client.currentAgent, {
|
|
321
|
+
type: 'delete_conversation',
|
|
322
|
+
conversationId: msg.conversationId
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
case 'select_conversation':
|
|
329
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
|
|
330
|
+
console.warn(`[Security] User ${client.userId} attempted to select conversation ${msg.conversationId} they don't own`);
|
|
331
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
client.currentConversation = msg.conversationId;
|
|
335
|
+
await sendToWebClient(client, {
|
|
336
|
+
type: 'conversation_selected',
|
|
337
|
+
conversationId: msg.conversationId
|
|
338
|
+
});
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
case 'reorder_yeaft_sessions': {
|
|
342
|
+
const globalSessions = Array.isArray(msg.sessions) ? msg.sessions : null;
|
|
343
|
+
const agentId = typeof msg.agentId === 'string' ? msg.agentId : '';
|
|
344
|
+
if (!globalSessions && (!agentId || !Array.isArray(msg.sessionIds))) break;
|
|
345
|
+
if (globalSessions) {
|
|
346
|
+
const agentIds = new Set(globalSessions.map(item => item?.agentId).filter(id => typeof id === 'string' && id));
|
|
347
|
+
const unauthorizedAgentId = [...agentIds].find(id => !verifyAgentOwnership(id, client.userId, client.role));
|
|
348
|
+
if (unauthorizedAgentId) {
|
|
349
|
+
console.warn(`[Server] Unauthorized yeaft session reorder by ${client.userId} for agent ${unauthorizedAgentId}`);
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
} else if (!verifyAgentOwnership(agentId, client.userId, client.role)) {
|
|
353
|
+
console.warn(`[Server] Unauthorized yeaft session reorder by ${client.userId} for agent ${agentId}`);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
let ok = false;
|
|
357
|
+
try {
|
|
358
|
+
ok = globalSessions
|
|
359
|
+
? yeaftSessionDb.setOrderForUser(client.userId, globalSessions)
|
|
360
|
+
: yeaftSessionDb.setOrderForAgent(client.userId, agentId, msg.sessionIds);
|
|
361
|
+
} catch (e) {
|
|
362
|
+
console.warn('[Server] yeaftSessionDb reorder failed:', e?.message || e);
|
|
363
|
+
}
|
|
364
|
+
await sendToWebClient(client, {
|
|
365
|
+
type: 'session_crud_result',
|
|
366
|
+
op: 'reorder',
|
|
367
|
+
requestId: msg.requestId,
|
|
368
|
+
...(agentId ? { agentId } : {}),
|
|
369
|
+
ok,
|
|
370
|
+
});
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case 'pin_session':
|
|
375
|
+
case 'unpin_session': {
|
|
376
|
+
// fix-yeaft-session-list-and-menu: yeaft sessions live in a
|
|
377
|
+
// separate `yeaft_sessions` table (with its own user_id column),
|
|
378
|
+
// not in `sessions`. The pure router decides which table owns
|
|
379
|
+
// the id and whether the caller is authorized; this handler is
|
|
380
|
+
// only responsible for executing the chosen DB write + replying.
|
|
381
|
+
// Tests cover the router directly (see session-pin-router.js).
|
|
382
|
+
const explicitYeaftAgentId = msg.sessionKind === 'yeaft' && msg.agentId ? msg.agentId : null;
|
|
383
|
+
if (explicitYeaftAgentId && !msg.conversationId) break;
|
|
384
|
+
if (explicitYeaftAgentId) {
|
|
385
|
+
if (!verifyAgentOwnership(explicitYeaftAgentId, client.userId, client.role)) {
|
|
386
|
+
console.warn(`[Server] Unauthorized yeaft pin ${msg.conversationId} by ${client.userId}`);
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
const isPinned = msg.type === 'pin_session';
|
|
390
|
+
try {
|
|
391
|
+
const ok = yeaftSessionDb.setPinnedForAgent(
|
|
392
|
+
client.userId,
|
|
393
|
+
explicitYeaftAgentId,
|
|
394
|
+
{
|
|
395
|
+
id: msg.conversationId,
|
|
396
|
+
name: msg.sessionName || msg.conversationId,
|
|
397
|
+
workDir: msg.workDir || '',
|
|
398
|
+
},
|
|
399
|
+
isPinned,
|
|
400
|
+
);
|
|
401
|
+
if (!ok) {
|
|
402
|
+
console.warn(`[Server] Unauthorized yeaft pin ${msg.conversationId} by ${client.userId}`);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
} catch (e) {
|
|
406
|
+
console.warn(`[Server] yeaftSessionDb.setPinnedForAgent failed for ${msg.conversationId}:`, e?.message || e);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
await broadcastSessionPin(client.userId, {
|
|
410
|
+
type: 'session_pinned',
|
|
411
|
+
conversationId: msg.conversationId,
|
|
412
|
+
agentId: explicitYeaftAgentId,
|
|
413
|
+
sessionKind: 'yeaft',
|
|
414
|
+
pinned: isPinned,
|
|
415
|
+
});
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const route = routeSessionPin(
|
|
420
|
+
{
|
|
421
|
+
getYeaftRow: (id) => yeaftSessionDb.get(id),
|
|
422
|
+
verifyChatOwnership: (id, userId) => verifyConversationOwnership(id, userId),
|
|
423
|
+
skipAuth: CONFIG.skipAuth,
|
|
424
|
+
},
|
|
425
|
+
client,
|
|
426
|
+
msg,
|
|
427
|
+
);
|
|
428
|
+
if (route.kind === 'noop') break;
|
|
429
|
+
if (route.kind === 'denied') {
|
|
430
|
+
if (route.reason === 'yeaft-foreign') {
|
|
431
|
+
console.warn(`[Security] User ${client.userId} attempted to pin yeaft session ${route.id} they don't own`);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
if (route.kind === 'yeaft') {
|
|
436
|
+
try { yeaftSessionDb.setPinned(route.id, route.isPinned); }
|
|
437
|
+
catch (e) { console.warn(`[Server] yeaftSessionDb.setPinned failed for ${route.id}:`, e?.message || e); }
|
|
438
|
+
await sendToWebClient(client, { type: 'session_pinned', conversationId: route.id, pinned: route.isPinned });
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
// route.kind === 'chat'
|
|
442
|
+
try {
|
|
443
|
+
sessionDb.setPinned(route.id, route.isPinned);
|
|
444
|
+
// If pinning, also ensure session is active (reactivate if it was auto-deactivated)
|
|
445
|
+
if (route.isPinned) sessionDb.setActive(route.id, true);
|
|
446
|
+
} catch (e) { /* ignore */ }
|
|
447
|
+
await sendToWebClient(client, { type: 'session_pinned', conversationId: route.id, pinned: route.isPinned });
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case 'sync_messages':
|
|
452
|
+
if (msg.conversationId) {
|
|
453
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(msg.conversationId, client.userId)) {
|
|
454
|
+
console.warn(`[Security] User ${client.userId} attempted to sync messages for conversation ${msg.conversationId} they don't own`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
let messages, hasMore;
|
|
459
|
+
|
|
460
|
+
if (msg.turns) {
|
|
461
|
+
if (msg.beforeId) {
|
|
462
|
+
const result = messageDb.getTurnsBeforeId(msg.conversationId, msg.beforeId, msg.turns);
|
|
463
|
+
messages = result.messages;
|
|
464
|
+
hasMore = result.hasMore;
|
|
465
|
+
} else {
|
|
466
|
+
const result = messageDb.getRecentTurns(msg.conversationId, msg.turns);
|
|
467
|
+
messages = result.messages;
|
|
468
|
+
hasMore = result.hasMore;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
const limit = msg.limit || 100;
|
|
472
|
+
if (msg.beforeId) {
|
|
473
|
+
messages = messageDb.getBeforeId(msg.conversationId, msg.beforeId, limit);
|
|
474
|
+
} else if (msg.afterMessageId !== undefined && msg.afterMessageId !== null) {
|
|
475
|
+
// perf-chat-session-switch-cache: explicit nullish check so a
|
|
476
|
+
// legitimate cursor of 0 (unlikely with AUTOINCREMENT but no
|
|
477
|
+
// reason to encode the assumption here) doesn't fall through
|
|
478
|
+
// to the cold-load branch and re-send the entire window.
|
|
479
|
+
messages = messageDb.getAfterId(msg.conversationId, msg.afterMessageId);
|
|
480
|
+
} else {
|
|
481
|
+
messages = messageDb.getRecent(msg.conversationId, limit);
|
|
482
|
+
}
|
|
483
|
+
const oldestId = messages.length > 0 ? messages[0].id : null;
|
|
484
|
+
hasMore = oldestId ? messageDb.getBeforeId(msg.conversationId, oldestId, 1).length > 0 : false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const total = messageDb.getCount(msg.conversationId);
|
|
488
|
+
console.log(`[sync_messages] Found ${messages.length} messages (total=${total}, hasMore=${hasMore})`);
|
|
489
|
+
await sendToWebClient(client, {
|
|
490
|
+
type: 'sync_messages_result',
|
|
491
|
+
conversationId: msg.conversationId,
|
|
492
|
+
messages,
|
|
493
|
+
hasMore,
|
|
494
|
+
total
|
|
495
|
+
});
|
|
496
|
+
} catch (e) {
|
|
497
|
+
console.error('Failed to sync messages:', e.message);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
|
|
502
|
+
case 'chat': {
|
|
503
|
+
// Support explicit conversationId for multi-column mode
|
|
504
|
+
const convId = msg.conversationId || client.currentConversation;
|
|
505
|
+
if (!convId) {
|
|
506
|
+
await sendToWebClient(client, { type: 'error', message: 'No conversation selected' });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Ownership check when explicit conversationId is provided
|
|
511
|
+
if (msg.conversationId && !CONFIG.skipAuth) {
|
|
512
|
+
if (!verifyConversationOwnership(msg.conversationId, client.userId)) {
|
|
513
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Find the agent that owns this conversation
|
|
519
|
+
let chatAgentId = client.currentAgent;
|
|
520
|
+
let chatAgent = agents.get(chatAgentId);
|
|
521
|
+
let convInfo = chatAgent?.conversations.get(convId);
|
|
522
|
+
|
|
523
|
+
// If conversation not found on current agent, search all agents
|
|
524
|
+
if (!convInfo && msg.conversationId) {
|
|
525
|
+
for (const [agentId, agent] of agents) {
|
|
526
|
+
if (agent.conversations.has(convId)) {
|
|
527
|
+
chatAgentId = agentId;
|
|
528
|
+
chatAgent = agent;
|
|
529
|
+
convInfo = agent.conversations.get(convId);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (!chatAgentId || !chatAgent) {
|
|
536
|
+
await sendToWebClient(client, { type: 'error', message: 'No agent available' });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (!await checkAgentAccess(chatAgentId)) return;
|
|
541
|
+
|
|
542
|
+
if (chatAgent.status === 'syncing') {
|
|
543
|
+
await sendToWebClient(client, { type: 'error', message: 'Agent is still syncing, please wait...' });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// fix-copilot-provider-persist: tell the agent which provider this
|
|
548
|
+
// conversation uses. After an agent process restart the agent's
|
|
549
|
+
// in-memory ctx.conversations is empty, so handleUserInput can't know
|
|
550
|
+
// the conv was copilot — without this it falls back to the default
|
|
551
|
+
// (claude-code), skips the ACP self-heal branch, and the send dies.
|
|
552
|
+
// Prefer the live in-memory value; fall back to the persisted column.
|
|
553
|
+
const resolvedProvider = convInfo?.provider || sessionDb.get(convId)?.provider || undefined;
|
|
554
|
+
|
|
555
|
+
// 处理附件
|
|
556
|
+
const fileIds = msg.fileIds || [];
|
|
557
|
+
let resolvedFiles = [];
|
|
558
|
+
if (fileIds.length > 0) {
|
|
559
|
+
for (const fileId of fileIds) {
|
|
560
|
+
const file = pendingFiles.get(fileId);
|
|
561
|
+
if (file && (!file.userId || CONFIG.skipAuth || file.userId === client.userId)) {
|
|
562
|
+
resolvedFiles.push({
|
|
563
|
+
name: file.name,
|
|
564
|
+
mimeType: file.mimeType,
|
|
565
|
+
data: file.buffer.toString('base64')
|
|
566
|
+
});
|
|
567
|
+
pendingFiles.delete(fileId);
|
|
568
|
+
} else if (file && file.userId !== client.userId) {
|
|
569
|
+
console.warn(`[Security] User ${client.userId} attempted to use file ${fileId} owned by ${file.userId}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (convInfo) convInfo.processing = true;
|
|
575
|
+
trackUserTurn(client.userId, Buffer.byteLength(JSON.stringify(msg)));
|
|
576
|
+
|
|
577
|
+
// 暂存 expertSelections 供 agent-output 保存 user 消息时使用
|
|
578
|
+
if (msg.expertSelections?.length > 0 && convInfo) {
|
|
579
|
+
convInfo._pendingExperts = msg.expertSelections;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// fix-usermsg-dup: stash the client-stamped id so agent-output can
|
|
583
|
+
// round-trip it on the `claude_output` user echo (it's persisted in
|
|
584
|
+
// the DB row's metadata too so the post-refresh `sync_messages_result`
|
|
585
|
+
// payload carries the same dedup key). Without this, the echo path
|
|
586
|
+
// falls back to content-equality dedup, which loses races after page
|
|
587
|
+
// refresh (the optimistic add competes with the DB-sourced row, both
|
|
588
|
+
// sharing the same text but neither sharing a stable id).
|
|
589
|
+
//
|
|
590
|
+
// Review C1 (Fowler): validate at the trust boundary. The id is
|
|
591
|
+
// opaque to the server but it lands in the DB, the WS broadcast,
|
|
592
|
+
// and the agent's history rebuild — a hostile client must not be
|
|
593
|
+
// able to inject 10 MB strings or SQL-looking payloads. The
|
|
594
|
+
// dedup gate falls back to content-equality cleanly if we drop
|
|
595
|
+
// an invalid id, so failed validation degrades gracefully.
|
|
596
|
+
if (msg.clientMessageId && convInfo && isValidClientMessageId(msg.clientMessageId)) {
|
|
597
|
+
convInfo._pendingClientMessageId = msg.clientMessageId;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 用用户输入的 prompt 更新会话标题(跳过用户自定义标题的会话)
|
|
601
|
+
if (msg.prompt && msg.prompt.trim() && !(convInfo?.customTitle)) {
|
|
602
|
+
const title = msg.prompt.trim().substring(0, 100);
|
|
603
|
+
sessionDb.update(convId, { title });
|
|
604
|
+
if (convInfo) convInfo.title = title;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (resolvedFiles.length > 0) {
|
|
608
|
+
await forwardToAgent(chatAgentId, {
|
|
609
|
+
type: 'transfer_files',
|
|
610
|
+
conversationId: convId,
|
|
611
|
+
files: resolvedFiles,
|
|
612
|
+
prompt: msg.prompt,
|
|
613
|
+
workDir: msg.workDir || convInfo?.workDir,
|
|
614
|
+
claudeSessionId: convInfo?.claudeSessionId,
|
|
615
|
+
...(resolvedProvider ? { provider: resolvedProvider } : {}),
|
|
616
|
+
targetRole: msg.targetRole || null,
|
|
617
|
+
expertSelections: msg.expertSelections || null,
|
|
618
|
+
expertMessage: msg.expertMessage || null
|
|
619
|
+
});
|
|
620
|
+
} else {
|
|
621
|
+
await forwardToAgent(chatAgentId, {
|
|
622
|
+
type: 'execute',
|
|
623
|
+
conversationId: convId,
|
|
624
|
+
prompt: msg.prompt,
|
|
625
|
+
workDir: msg.workDir || convInfo?.workDir,
|
|
626
|
+
claudeSessionId: convInfo?.claudeSessionId,
|
|
627
|
+
...(resolvedProvider ? { provider: resolvedProvider } : {}),
|
|
628
|
+
targetRole: msg.targetRole || null,
|
|
629
|
+
expertSelections: msg.expertSelections || null,
|
|
630
|
+
expertMessage: msg.expertMessage || null
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
case 'get_conversations':
|
|
637
|
+
if (!client.currentAgent) return;
|
|
638
|
+
if (!await checkAgentAccess(client.currentAgent)) return;
|
|
639
|
+
await forwardToAgent(client.currentAgent, { type: 'get_conversations' });
|
|
640
|
+
break;
|
|
641
|
+
|
|
642
|
+
case 'list_history_sessions': {
|
|
643
|
+
const historyAgentId = msg.agentId || client.currentAgent;
|
|
644
|
+
if (!historyAgentId) return;
|
|
645
|
+
if (!await checkAgentAccess(historyAgentId)) return;
|
|
646
|
+
await forwardToAgent(historyAgentId, {
|
|
647
|
+
type: 'list_history_sessions',
|
|
648
|
+
workDir: msg.workDir,
|
|
649
|
+
provider: msg.provider,
|
|
650
|
+
requestId: msg.requestId,
|
|
651
|
+
_requestClientId: clientId
|
|
652
|
+
});
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
case 'list_folders': {
|
|
657
|
+
const foldersAgentId = msg.agentId || client.currentAgent;
|
|
658
|
+
if (!foldersAgentId) return;
|
|
659
|
+
if (!await checkAgentAccess(foldersAgentId)) return;
|
|
660
|
+
await forwardToAgent(foldersAgentId, {
|
|
661
|
+
type: 'list_folders',
|
|
662
|
+
provider: msg.provider,
|
|
663
|
+
requestId: msg.requestId,
|
|
664
|
+
_requestClientId: clientId
|
|
665
|
+
});
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
case 'list_models': {
|
|
670
|
+
const modelsAgentId = msg.agentId || client.currentAgent;
|
|
671
|
+
if (!modelsAgentId) return;
|
|
672
|
+
if (!await checkAgentAccess(modelsAgentId)) return;
|
|
673
|
+
await forwardToAgent(modelsAgentId, {
|
|
674
|
+
type: 'list_models',
|
|
675
|
+
provider: msg.provider,
|
|
676
|
+
requestId: msg.requestId,
|
|
677
|
+
_requestClientId: clientId
|
|
678
|
+
});
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
case 'cancel_execution': {
|
|
684
|
+
if (!client.currentAgent) return;
|
|
685
|
+
if (!await checkAgentAccess(client.currentAgent)) return;
|
|
686
|
+
const cancelConvId = msg.conversationId || client.currentConversation;
|
|
687
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(cancelConvId, client.userId)) {
|
|
688
|
+
console.warn(`[Security] User ${client.userId} cancel denied for ${cancelConvId}`);
|
|
689
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
await forwardToAgent(client.currentAgent, {
|
|
693
|
+
type: 'cancel_execution',
|
|
694
|
+
conversationId: cancelConvId
|
|
695
|
+
});
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
case 'refresh_conversation': {
|
|
700
|
+
const refreshAgent = msg.agentId || client.currentAgent;
|
|
701
|
+
if (!refreshAgent) return;
|
|
702
|
+
if (!await checkAgentAccess(refreshAgent)) return;
|
|
703
|
+
const refreshConvId = msg.conversationId || client.currentConversation;
|
|
704
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(refreshConvId, client.userId)) {
|
|
705
|
+
console.warn(`[Security] User ${client.userId} refresh denied for ${refreshConvId}`);
|
|
706
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
await forwardToAgent(refreshAgent, {
|
|
710
|
+
type: 'refresh_conversation',
|
|
711
|
+
conversationId: refreshConvId,
|
|
712
|
+
clientId
|
|
713
|
+
});
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
case 'ping_session': {
|
|
718
|
+
const pingAgent = msg.agentId || client.currentAgent;
|
|
719
|
+
const pingConvId = msg.conversationId;
|
|
720
|
+
if (!pingAgent || !pingConvId) return;
|
|
721
|
+
// Check agent is online first — if not, reply directly
|
|
722
|
+
const pingAgentObj = agents.get(pingAgent);
|
|
723
|
+
if (!pingAgentObj || !pingAgentObj.ws || pingAgentObj.ws.readyState !== 1) {
|
|
724
|
+
await sendToWebClient(client, {
|
|
725
|
+
type: 'pong_session',
|
|
726
|
+
conversationId: pingConvId,
|
|
727
|
+
status: 'agent-offline'
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// Old agent doesn't support ping_session — reply unsupported
|
|
732
|
+
if (!pingAgentObj.capabilities?.includes('ping_session')) {
|
|
733
|
+
await sendToWebClient(client, {
|
|
734
|
+
type: 'pong_session',
|
|
735
|
+
conversationId: pingConvId,
|
|
736
|
+
status: 'unsupported'
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await forwardToAgent(pingAgent, {
|
|
741
|
+
type: 'ping_session',
|
|
742
|
+
conversationId: pingConvId,
|
|
743
|
+
clientId
|
|
744
|
+
});
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
case 'update_conversation_settings': {
|
|
749
|
+
if (!client.currentAgent) return;
|
|
750
|
+
if (!await checkAgentAccess(client.currentAgent)) return;
|
|
751
|
+
const settingsConvId = msg.conversationId || client.currentConversation;
|
|
752
|
+
if (!settingsConvId) return;
|
|
753
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(settingsConvId, client.userId)) {
|
|
754
|
+
console.warn(`[Security] User ${client.userId} settings update denied for ${settingsConvId}`);
|
|
755
|
+
await sendToWebClient(client, { type: 'error', message: 'Permission denied' });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
// Handle custom title (server-local, no agent forwarding needed).
|
|
759
|
+
// fix-chat-title-sticky: persist `is_custom_title` to the DB so the
|
|
760
|
+
// sticky bit survives agent reconnect / server restart / convInfo
|
|
761
|
+
// rebuild. Without this, the per-message auto-title write at
|
|
762
|
+
// line 351 silently clobbers the user's renamed title the next
|
|
763
|
+
// time `convInfo.customTitle` is reset to undefined.
|
|
764
|
+
if (msg.title !== undefined) {
|
|
765
|
+
const titleAgent = agents.get(client.currentAgent);
|
|
766
|
+
const titleConvInfo = titleAgent?.conversations.get(settingsConvId);
|
|
767
|
+
if (msg.title) {
|
|
768
|
+
sessionDb.update(settingsConvId, { title: msg.title, isCustomTitle: 1 });
|
|
769
|
+
if (titleConvInfo) { titleConvInfo.title = msg.title; titleConvInfo.customTitle = true; }
|
|
770
|
+
} else {
|
|
771
|
+
// Clearing the custom title returns the session to auto-naming
|
|
772
|
+
// mode — the next user prompt repopulates the title.
|
|
773
|
+
sessionDb.update(settingsConvId, { isCustomTitle: 0 });
|
|
774
|
+
if (titleConvInfo) { titleConvInfo.customTitle = false; }
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Only forward to agent if disallowedTools present
|
|
778
|
+
if (msg.disallowedTools) {
|
|
779
|
+
await forwardToAgent(client.currentAgent, {
|
|
780
|
+
type: 'update_conversation_settings',
|
|
781
|
+
conversationId: settingsConvId,
|
|
782
|
+
disallowedTools: msg.disallowedTools
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
case 'ask_user_answer': {
|
|
789
|
+
if (!client.currentAgent) return;
|
|
790
|
+
if (!await checkAgentAccess(client.currentAgent)) return;
|
|
791
|
+
const answerConvId = msg.conversationId || client.currentConversation;
|
|
792
|
+
if (!answerConvId) return;
|
|
793
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(answerConvId, client.userId)) {
|
|
794
|
+
console.warn(`[Security] User ${client.userId} ask_user_answer denied for ${answerConvId}`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
await forwardToAgent(client.currentAgent, {
|
|
798
|
+
type: 'ask_user_answer',
|
|
799
|
+
conversationId: answerConvId,
|
|
800
|
+
requestId: msg.requestId,
|
|
801
|
+
answers: msg.answers
|
|
802
|
+
});
|
|
803
|
+
// Persist answered state into the AskUserQuestion tool_use DB record
|
|
804
|
+
try {
|
|
805
|
+
if (answerConvId && msg.requestId) {
|
|
806
|
+
const recent = messageDb.getRecent(answerConvId, 100);
|
|
807
|
+
// Search from newest to oldest for the matching requestId
|
|
808
|
+
let askMsg = null;
|
|
809
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
810
|
+
const m = recent[i];
|
|
811
|
+
if (m.message_type !== 'tool_use' || m.tool_name !== 'AskUserQuestion') continue;
|
|
812
|
+
if (!m.metadata) continue;
|
|
813
|
+
try {
|
|
814
|
+
const meta = JSON.parse(m.metadata);
|
|
815
|
+
if (meta.askRequestId === msg.requestId) { askMsg = m; break; }
|
|
816
|
+
} catch { /* skip */ }
|
|
817
|
+
}
|
|
818
|
+
if (askMsg) {
|
|
819
|
+
const meta = JSON.parse(askMsg.metadata);
|
|
820
|
+
messageDb.updateMetadata(askMsg.id, JSON.stringify({
|
|
821
|
+
...meta,
|
|
822
|
+
askAnswered: true,
|
|
823
|
+
selectedAnswers: msg.answers
|
|
824
|
+
}));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
// Silent — don't block the main flow
|
|
829
|
+
}
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
case 'btw_question': {
|
|
834
|
+
if (!client.currentAgent) {
|
|
835
|
+
await sendToWebClient(client, { type: 'btw_error', error: 'No agent selected' });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (!await checkAgentAccess(client.currentAgent)) return;
|
|
839
|
+
const btwConvId = msg.conversationId || client.currentConversation;
|
|
840
|
+
if (!btwConvId) {
|
|
841
|
+
await sendToWebClient(client, { type: 'btw_error', error: 'No conversation selected' });
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (!CONFIG.skipAuth && !verifyConversationOwnership(btwConvId, client.userId)) {
|
|
845
|
+
console.warn(`[Security] User ${client.userId} btw_question denied for ${btwConvId}`);
|
|
846
|
+
await sendToWebClient(client, { type: 'btw_error', conversationId: btwConvId, error: 'Permission denied' });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
await forwardToAgent(client.currentAgent, {
|
|
850
|
+
type: 'btw_question',
|
|
851
|
+
conversationId: btwConvId,
|
|
852
|
+
question: msg.question,
|
|
853
|
+
btwSessionId: msg.btwSessionId || null
|
|
854
|
+
});
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
case 'perf_trace_events': {
|
|
859
|
+
if (Array.isArray(msg.events)) {
|
|
860
|
+
for (const event of msg.events.slice(0, 100)) {
|
|
861
|
+
recordPerfTraceEvent({
|
|
862
|
+
...event,
|
|
863
|
+
source: 'web',
|
|
864
|
+
userId: client.userId || null,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case 'yeaft_load_history':
|
|
872
|
+
case 'unify_load_history': {
|
|
873
|
+
const histAgentId = msg.agentId || client.currentAgent;
|
|
874
|
+
if (msg.perfTraceId) {
|
|
875
|
+
recordPerfTraceEvent({
|
|
876
|
+
traceId: msg.perfTraceId,
|
|
877
|
+
source: 'server',
|
|
878
|
+
phase: 'relay.resolve_agent',
|
|
879
|
+
at: Date.now(),
|
|
880
|
+
userId: client.userId || null,
|
|
881
|
+
agentId: histAgentId || null,
|
|
882
|
+
sessionId: msg.sessionId || null,
|
|
883
|
+
messageType: 'yeaft_load_history',
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
if (!histAgentId) return;
|
|
887
|
+
if (!await checkAgentAccess(histAgentId)) return;
|
|
888
|
+
// Forward the catch-up cursor (afterSeq / afterMessageId) verbatim when
|
|
889
|
+
// present. Without it the agent never takes the cheap delta path and
|
|
890
|
+
// falls back to a full recent-history replay on every reconnect
|
|
891
|
+
// catch-up — re-sending the whole pane instead of an empty delta.
|
|
892
|
+
const forwarded = {
|
|
893
|
+
type: 'yeaft_load_history',
|
|
894
|
+
limit: msg.limit,
|
|
895
|
+
sessionId: msg.sessionId || null,
|
|
896
|
+
...(typeof msg.perfTraceId === 'string' ? { perfTraceId: msg.perfTraceId } : {}),
|
|
897
|
+
...(Number.isFinite(msg.afterSeq) ? { afterSeq: msg.afterSeq } : {}),
|
|
898
|
+
...(typeof msg.afterMessageId === 'string' ? { afterMessageId: msg.afterMessageId } : {}),
|
|
899
|
+
};
|
|
900
|
+
if (forwarded.perfTraceId) {
|
|
901
|
+
recordPerfTraceEvent({
|
|
902
|
+
traceId: forwarded.perfTraceId,
|
|
903
|
+
source: 'server',
|
|
904
|
+
phase: 'relay.forward_to_agent',
|
|
905
|
+
at: Date.now(),
|
|
906
|
+
userId: client.userId || null,
|
|
907
|
+
agentId: histAgentId,
|
|
908
|
+
sessionId: forwarded.sessionId,
|
|
909
|
+
messageType: forwarded.type,
|
|
910
|
+
bytes: Buffer.byteLength(JSON.stringify(forwarded)),
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
await forwardToAgent(histAgentId, forwarded);
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
case 'yeaft_search_history': {
|
|
918
|
+
const searchAgentId = msg.agentId;
|
|
919
|
+
const searchSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : '';
|
|
920
|
+
if (!searchAgentId || !searchSessionId) return;
|
|
921
|
+
if (!await checkAgentAccess(searchAgentId)) return;
|
|
922
|
+
if (!CONFIG.skipAuth && !yeaftSessionDb.getForAgent(client.userId, searchAgentId, searchSessionId)) return;
|
|
923
|
+
await forwardToAgent(searchAgentId, {
|
|
924
|
+
type: 'yeaft_search_history',
|
|
925
|
+
sessionId: searchSessionId,
|
|
926
|
+
requestId: typeof msg.requestId === 'string' ? msg.requestId : null,
|
|
927
|
+
query: typeof msg.query === 'string' ? msg.query.slice(0, 500) : '',
|
|
928
|
+
limit: typeof msg.limit === 'number' ? msg.limit : 20,
|
|
929
|
+
beforeSeq: typeof msg.beforeSeq === 'number' ? msg.beforeSeq : null,
|
|
930
|
+
_requestClientId: clientId,
|
|
931
|
+
});
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
case 'yeaft_load_history_window': {
|
|
936
|
+
const windowAgentId = msg.agentId;
|
|
937
|
+
const windowSessionId = typeof msg.sessionId === 'string' ? msg.sessionId : '';
|
|
938
|
+
if (!windowAgentId || !windowSessionId) return;
|
|
939
|
+
if (!await checkAgentAccess(windowAgentId)) return;
|
|
940
|
+
if (!CONFIG.skipAuth && !yeaftSessionDb.getForAgent(client.userId, windowAgentId, windowSessionId)) return;
|
|
941
|
+
await forwardToAgent(windowAgentId, {
|
|
942
|
+
type: 'yeaft_load_history_window',
|
|
943
|
+
sessionId: windowSessionId,
|
|
944
|
+
requestId: typeof msg.requestId === 'string' ? msg.requestId : null,
|
|
945
|
+
anchorMessageId: typeof msg.anchorMessageId === 'string' ? msg.anchorMessageId : null,
|
|
946
|
+
anchorSeq: typeof msg.anchorSeq === 'number' ? msg.anchorSeq : null,
|
|
947
|
+
beforeTurns: typeof msg.beforeTurns === 'number' ? msg.beforeTurns : 3,
|
|
948
|
+
afterTurns: typeof msg.afterTurns === 'number' ? msg.afterTurns : 3,
|
|
949
|
+
_requestClientId: clientId,
|
|
950
|
+
});
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
case 'yeaft_load_more_history':
|
|
955
|
+
case 'unify_load_more_history': {
|
|
956
|
+
const moreAgentId = msg.agentId || client.currentAgent;
|
|
957
|
+
if (!moreAgentId) return;
|
|
958
|
+
if (!await checkAgentAccess(moreAgentId)) return;
|
|
959
|
+
await forwardToAgent(moreAgentId, {
|
|
960
|
+
type: 'yeaft_load_more_history',
|
|
961
|
+
sessionId: msg.sessionId || null,
|
|
962
|
+
beforeSeq: typeof msg.beforeSeq === 'number' ? msg.beforeSeq : null,
|
|
963
|
+
turns: typeof msg.turns === 'number' ? msg.turns : 20,
|
|
964
|
+
...(typeof msg.perfTraceId === 'string' ? { perfTraceId: msg.perfTraceId } : {}),
|
|
965
|
+
});
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
case 'yeaft_mode_switch':
|
|
970
|
+
case 'unify_mode_switch': {
|
|
971
|
+
const modeAgentId = msg.agentId || client.currentAgent;
|
|
972
|
+
if (!modeAgentId) return;
|
|
973
|
+
if (!await checkAgentAccess(modeAgentId)) return;
|
|
974
|
+
await forwardToAgent(modeAgentId, {
|
|
975
|
+
type: 'yeaft_mode_switch',
|
|
976
|
+
mode: msg.mode,
|
|
977
|
+
});
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
case 'yeaft_model_switch':
|
|
982
|
+
case 'unify_model_switch': {
|
|
983
|
+
const modelAgentId = msg.agentId || client.currentAgent;
|
|
984
|
+
if (!modelAgentId) return;
|
|
985
|
+
if (!await checkAgentAccess(modelAgentId)) return;
|
|
986
|
+
await forwardToAgent(modelAgentId, {
|
|
987
|
+
type: 'yeaft_model_switch',
|
|
988
|
+
model: msg.model,
|
|
989
|
+
});
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
case 'yeaft_reset':
|
|
994
|
+
case 'unify_reset': {
|
|
995
|
+
const resetAgentId = msg.agentId || client.currentAgent;
|
|
996
|
+
if (!resetAgentId) return;
|
|
997
|
+
if (!await checkAgentAccess(resetAgentId)) return;
|
|
998
|
+
await forwardToAgent(resetAgentId, {
|
|
999
|
+
type: 'yeaft_reset',
|
|
1000
|
+
});
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
case 'yeaft_merge_thread':
|
|
1005
|
+
case 'unify_merge_thread': {
|
|
1006
|
+
const mergeAgentId = msg.agentId || client.currentAgent;
|
|
1007
|
+
if (!mergeAgentId) return;
|
|
1008
|
+
if (!await checkAgentAccess(mergeAgentId)) return;
|
|
1009
|
+
await forwardToAgent(mergeAgentId, {
|
|
1010
|
+
type: 'yeaft_merge_thread',
|
|
1011
|
+
sourceId: msg.sourceId,
|
|
1012
|
+
targetId: msg.targetId,
|
|
1013
|
+
});
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
case 'yeaft_fork_thread':
|
|
1018
|
+
case 'unify_fork_thread': {
|
|
1019
|
+
const forkAgentId = msg.agentId || client.currentAgent;
|
|
1020
|
+
if (!forkAgentId) return;
|
|
1021
|
+
if (!await checkAgentAccess(forkAgentId)) return;
|
|
1022
|
+
await forwardToAgent(forkAgentId, {
|
|
1023
|
+
type: 'yeaft_fork_thread',
|
|
1024
|
+
sourceThreadId: msg.sourceThreadId,
|
|
1025
|
+
atMessageId: msg.atMessageId,
|
|
1026
|
+
name: msg.name,
|
|
1027
|
+
});
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
case 'yeaft_abort_thread':
|
|
1032
|
+
case 'unify_abort_thread': {
|
|
1033
|
+
// task-325c: relay targeted abort command to the agent. Payload
|
|
1034
|
+
// carries `threadId` only — no extra fields. Unknown thread is a
|
|
1035
|
+
// silent no-op on the agent side, so we forward unconditionally.
|
|
1036
|
+
const abortAgentId = msg.agentId || client.currentAgent;
|
|
1037
|
+
if (!abortAgentId) return;
|
|
1038
|
+
if (!await checkAgentAccess(abortAgentId)) return;
|
|
1039
|
+
await forwardToAgent(abortAgentId, {
|
|
1040
|
+
type: 'yeaft_abort_thread',
|
|
1041
|
+
threadId: msg.threadId,
|
|
1042
|
+
});
|
|
1043
|
+
break;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
case 'yeaft_abort_all':
|
|
1047
|
+
case 'unify_abort_all': {
|
|
1048
|
+
// task-325c: relay abort command. With sessionId present this is scoped
|
|
1049
|
+
// to that Yeaft Session; without it, older clients keep the legacy
|
|
1050
|
+
// "abort everything" behavior.
|
|
1051
|
+
const abortAllAgentId = msg.agentId || client.currentAgent;
|
|
1052
|
+
if (!abortAllAgentId) return;
|
|
1053
|
+
if (!await checkAgentAccess(abortAllAgentId)) return;
|
|
1054
|
+
await forwardToAgent(abortAllAgentId, { type: 'yeaft_abort_all', sessionId: msg.sessionId || null });
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
default: {
|
|
1059
|
+
// task-fix: generic relay for all `yeaft_*` messages so any new
|
|
1060
|
+
// agent-router case (yeaft_vp_*, yeaft_user_memory_*, yeaft_*_group,
|
|
1061
|
+
// yeaft_dream_*, etc.) works without a dedicated server case.
|
|
1062
|
+
// Without this, messages like `yeaft_vp_subscribe` arrive at the
|
|
1063
|
+
// server, fall through to default, return false, and are silently
|
|
1064
|
+
// dropped — which is why the GroupCreateWizard's "VP 加载中..."
|
|
1065
|
+
// hung forever.
|
|
1066
|
+
if (typeof msg.type === 'string' && (msg.type.startsWith('yeaft_') || msg.type.startsWith('unify_'))) {
|
|
1067
|
+
const relayType = msg.type.startsWith('unify_') ? `yeaft_${msg.type.slice('unify_'.length)}` : msg.type;
|
|
1068
|
+
const relayAgentId = msg.agentId || client.currentAgent;
|
|
1069
|
+
if (msg.perfTraceId) {
|
|
1070
|
+
recordPerfTraceEvent({
|
|
1071
|
+
traceId: msg.perfTraceId,
|
|
1072
|
+
source: 'server',
|
|
1073
|
+
phase: 'relay.resolve_agent',
|
|
1074
|
+
at: Date.now(),
|
|
1075
|
+
userId: client.userId || null,
|
|
1076
|
+
agentId: relayAgentId || null,
|
|
1077
|
+
sessionId: msg.sessionId || null,
|
|
1078
|
+
messageType: relayType,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
if (!relayAgentId) {
|
|
1082
|
+
if (relayType === 'yeaft_fetch_tool_stats') {
|
|
1083
|
+
await sendToWebClient(client, emptyYeaftToolStats('No agent selected.'));
|
|
1084
|
+
} else if (relayType === 'yeaft_dream_trigger') {
|
|
1085
|
+
await sendToWebClient(client, skippedYeaftDreamResult(msg, 'no-agent-selected'));
|
|
1086
|
+
} else {
|
|
1087
|
+
// fix-session-restore-modal-unify: every prior swallow was
|
|
1088
|
+
// invisible — the user-facing symptom (e.g. VP roster stuck
|
|
1089
|
+
// on "加载中...") doesn't point back to "the server has no
|
|
1090
|
+
// agent to route this to." A WARN log is one grep away
|
|
1091
|
+
// from the root cause next time this happens.
|
|
1092
|
+
console.warn(
|
|
1093
|
+
`[Server] swallowed yeaft message ${relayType} (no agent resolved)`
|
|
1094
|
+
+ ` userId=${client.userId || '?'}`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
return true; // swallow silently for legacy fire-and-forget messages
|
|
1098
|
+
}
|
|
1099
|
+
if (!await checkAgentAccess(relayAgentId)) {
|
|
1100
|
+
if (relayType === 'yeaft_fetch_tool_stats') {
|
|
1101
|
+
await sendToWebClient(client, emptyYeaftToolStats('Agent is not available.'));
|
|
1102
|
+
} else if (relayType === 'yeaft_dream_trigger') {
|
|
1103
|
+
await sendToWebClient(client, skippedYeaftDreamResult(msg, 'agent-not-available'));
|
|
1104
|
+
}
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
const relayAgent = agents.get(relayAgentId);
|
|
1108
|
+
if (!relayAgent || relayAgent.ws?.readyState !== 1) {
|
|
1109
|
+
if (relayType === 'yeaft_fetch_tool_stats') {
|
|
1110
|
+
await sendToWebClient(client, emptyYeaftToolStats('Agent is offline.'));
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
if (relayType === 'yeaft_dream_trigger') {
|
|
1114
|
+
await sendToWebClient(client, skippedYeaftDreamResult(msg, 'agent-offline'));
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (relayType === 'yeaft_fetch_agent_metrics') {
|
|
1119
|
+
await forwardToAgent(relayAgentId, { type: 'get_agent_metrics' });
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
// Forward the entire message minus the agentId field; the agent
|
|
1123
|
+
// router is the authoritative consumer of the payload shape.
|
|
1124
|
+
const { agentId: _discard, ...rest } = msg;
|
|
1125
|
+
rest.type = relayType;
|
|
1126
|
+
if (rest.type === 'yeaft_session_send' || rest.type === 'yeaft_session_chat') {
|
|
1127
|
+
trackUserTurn(client.userId, Buffer.byteLength(JSON.stringify(msg)));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (rest.type === 'yeaft_session_chat' && !rest.id) {
|
|
1131
|
+
rest.id = `u_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// Resolve attachment fileIds → base64 BEFORE forwarding, mirroring
|
|
1135
|
+
// The agent never sees fileIds — it
|
|
1136
|
+
// only handles `files: [{ name, mimeType, data, isImage }]`.
|
|
1137
|
+
if (Array.isArray(rest.attachments) && rest.attachments.length > 0) {
|
|
1138
|
+
const resolvedFiles = [];
|
|
1139
|
+
for (const att of rest.attachments) {
|
|
1140
|
+
if (!att || !att.fileId) continue;
|
|
1141
|
+
const file = pendingFiles.get(att.fileId);
|
|
1142
|
+
if (file && (!file.userId || CONFIG.skipAuth || file.userId === client.userId)) {
|
|
1143
|
+
resolvedFiles.push({
|
|
1144
|
+
name: file.name,
|
|
1145
|
+
mimeType: file.mimeType,
|
|
1146
|
+
data: file.buffer.toString('base64'),
|
|
1147
|
+
isImage: !!att.isImage || (file.mimeType || '').startsWith('image/'),
|
|
1148
|
+
});
|
|
1149
|
+
pendingFiles.delete(att.fileId);
|
|
1150
|
+
} else if (file && file.userId !== client.userId) {
|
|
1151
|
+
console.warn(`[Security] User ${client.userId} attempted to use yeaft file ${att.fileId} owned by ${file.userId}`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (resolvedFiles.length > 0) {
|
|
1155
|
+
rest.files = resolvedFiles;
|
|
1156
|
+
}
|
|
1157
|
+
// Drop the fileId-bearing array so the agent only sees the
|
|
1158
|
+
// resolved form on `files`.
|
|
1159
|
+
delete rest.attachments;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (rest.perfTraceId) {
|
|
1163
|
+
recordPerfTraceEvent({
|
|
1164
|
+
traceId: rest.perfTraceId,
|
|
1165
|
+
source: 'server',
|
|
1166
|
+
phase: 'relay.forward_to_agent',
|
|
1167
|
+
at: Date.now(),
|
|
1168
|
+
userId: client.userId || null,
|
|
1169
|
+
agentId: relayAgentId,
|
|
1170
|
+
sessionId: rest.sessionId || null,
|
|
1171
|
+
messageType: rest.type,
|
|
1172
|
+
bytes: Buffer.byteLength(JSON.stringify(rest)),
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
await forwardToAgent(relayAgentId, rest);
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
return false; // Not handled
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return true; // Handled
|
|
1182
|
+
}
|