mouaif 0.3.0
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/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Chat + message REST handlers, including the SSE streaming loop
|
|
4
|
+
// (handleChatStream). Extracted from the original single-file
|
|
5
|
+
// http-server.js so no file stays above ~2 000 lines. Shared state
|
|
6
|
+
// (runningChats, runningChatCancels) and helpers live in
|
|
7
|
+
// src/server-shared.js.
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
sendJSON,
|
|
11
|
+
qs,
|
|
12
|
+
readJsonOr400,
|
|
13
|
+
runningKey,
|
|
14
|
+
runningChats,
|
|
15
|
+
runningChatCancels,
|
|
16
|
+
resolveModel,
|
|
17
|
+
settings,
|
|
18
|
+
chats,
|
|
19
|
+
messages,
|
|
20
|
+
trace,
|
|
21
|
+
push,
|
|
22
|
+
usage,
|
|
23
|
+
promptProfiles,
|
|
24
|
+
prompts,
|
|
25
|
+
agentFiles,
|
|
26
|
+
agentSkills,
|
|
27
|
+
agentFeatures,
|
|
28
|
+
tags,
|
|
29
|
+
mcp,
|
|
30
|
+
shellTool,
|
|
31
|
+
ai,
|
|
32
|
+
liveChat,
|
|
33
|
+
safeDecode
|
|
34
|
+
} = require('./server-shared.js');
|
|
35
|
+
const { resolveNotificationPrefs } = require('./notifications.js');
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// resolveNotificationPrefs(saved) now lives in src/notifications.js so the
|
|
39
|
+
// access sign-in push and the chat streaming push resolve the same
|
|
40
|
+
// preferences (status, authorization, quickActions, and the login alert).
|
|
41
|
+
// See that module for the legacy-key fallbacks.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async function handleChats(req, res, parsed, sessionToken, lifecycle = {}) {
|
|
45
|
+
const urlPath = parsed.pathname;
|
|
46
|
+
|
|
47
|
+
const method = req.method;
|
|
48
|
+
const q = parsed.query || {};
|
|
49
|
+
|
|
50
|
+
function chatError(e) {
|
|
51
|
+
if (e && e.code === 'MOUAIF_PROJECT_PARSE_ERROR') return 422;
|
|
52
|
+
if (e && e.code === 'EBADINPUT') return 400;
|
|
53
|
+
return 500;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readProjectDir(body) {
|
|
57
|
+
const fromQuery = qs(q, 'projectDir');
|
|
58
|
+
const fromBody = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
59
|
+
const dir = fromQuery || fromBody;
|
|
60
|
+
if (!dir) return null;
|
|
61
|
+
return dir;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// GET /api/chats?projectDir=<abs>[&offset=0&limit=20]
|
|
65
|
+
if (urlPath === '/api/chats' && method === 'GET') {
|
|
66
|
+
const dir = qs(q, 'projectDir');
|
|
67
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
68
|
+
const offset = Math.max(0, parseInt(typeof q.offset === 'string' ? q.offset : '0', 10) || 0);
|
|
69
|
+
const limitRaw = parseInt(typeof q.limit === 'string' ? q.limit : '0', 10) || 0;
|
|
70
|
+
const limit = limitRaw > 0 ? Math.min(limitRaw, 100) : 0;
|
|
71
|
+
try {
|
|
72
|
+
const page = chats.listChats(dir, { offset, limit });
|
|
73
|
+
const total = limit > 0 ? chats.countChats(dir) : page.length;
|
|
74
|
+
// Chat cost totals are persisted on chat metadata when cost-bearing
|
|
75
|
+
// messages are written. Listing chats never scans message_store.
|
|
76
|
+
// `messageCount` is a per-page bulk COUNT (one indexed GROUP BY), so the
|
|
77
|
+
// project card can flag a draft-only chat — persisted messages === 0 —
|
|
78
|
+
// without any N+1 query and without sending transcript text down.
|
|
79
|
+
//
|
|
80
|
+
// List rows are summaries (src/chatdb.js#LIST_COLUMNS): a chat's
|
|
81
|
+
// `draftSnippet` + `hasDraftImage` stand in for the `draft` and
|
|
82
|
+
// `draftAttachments` bodies, which can run to megabytes when a picture sits
|
|
83
|
+
// in the composer. The card only previews the draft, so the bodies stay in
|
|
84
|
+
// SQLite until a single-chat read (GET /api/chats/:id) asks for them.
|
|
85
|
+
const pageCounts = messages.projectMessageCounts(dir, page.map((c) => c.id));
|
|
86
|
+
for (const c of page) {
|
|
87
|
+
if (runningChats.has(runningKey(dir, c.id))) c.running = true;
|
|
88
|
+
c.messageCount = pageCounts[c.id] || 0;
|
|
89
|
+
}
|
|
90
|
+
return sendJSON(res, 200, {
|
|
91
|
+
chats: page,
|
|
92
|
+
total,
|
|
93
|
+
offset,
|
|
94
|
+
limit: limit || page.length
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /api/chats/:id?projectDir=<abs>
|
|
102
|
+
let m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
|
|
103
|
+
if (m && method === 'GET') {
|
|
104
|
+
const id = safeDecode(m[1]);
|
|
105
|
+
const dir = qs(q, 'projectDir');
|
|
106
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
107
|
+
try {
|
|
108
|
+
const chat = chats.getChat(dir, id);
|
|
109
|
+
if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
110
|
+
// Response-only liveness marker (never persisted on the record).
|
|
111
|
+
if (runningChats.has(runningKey(dir, id))) chat.running = true;
|
|
112
|
+
return sendJSON(res, 200, { chat });
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// POST /api/chats body: { projectDir, title?, trace?, promptSize?, promptId? }
|
|
119
|
+
if (urlPath === '/api/chats' && method === 'POST') {
|
|
120
|
+
const body = await readJsonOr400(req, res);
|
|
121
|
+
if (!body) return;
|
|
122
|
+
const dir = readProjectDir(body);
|
|
123
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
124
|
+
try {
|
|
125
|
+
if (body.promptId != null && body.promptId !== '') {
|
|
126
|
+
if (typeof body.promptId !== 'string' || !prompts.getPrompt(dir, body.promptId)) {
|
|
127
|
+
return sendJSON(res, 400, { error: 'Unknown promptId' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const chat = chats.createChat(dir, body || {});
|
|
131
|
+
return sendJSON(res, 201, { chat });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// PATCH /api/chats/:id body: { projectDir, title?, trace?, promptSize?, draft?, draftAttachments? }
|
|
138
|
+
m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
|
|
139
|
+
if (m && method === 'PATCH') {
|
|
140
|
+
const id = safeDecode(m[1]);
|
|
141
|
+
const body = await readJsonOr400(req, res);
|
|
142
|
+
if (!body) return;
|
|
143
|
+
const dir = readProjectDir(body);
|
|
144
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
145
|
+
// Strip server-owned fields from the client patch. The generic
|
|
146
|
+
// merge in updateChat absorbs every key, so without this a PATCH
|
|
147
|
+
// could rewrite the chat's id, createdAt, or lastOpenedAt.
|
|
148
|
+
// Internal callers (touchChat, titleChatFromPrompt) set those
|
|
149
|
+
// fields intentionally and don't come through here.
|
|
150
|
+
const safeBody = Object.assign({}, body || {});
|
|
151
|
+
delete safeBody.id;
|
|
152
|
+
delete safeBody.createdAt;
|
|
153
|
+
delete safeBody.lastOpenedAt;
|
|
154
|
+
try {
|
|
155
|
+
if (safeBody.promptId != null && safeBody.promptId !== '') {
|
|
156
|
+
if (typeof safeBody.promptId !== 'string' || !prompts.getPrompt(dir, safeBody.promptId)) {
|
|
157
|
+
return sendJSON(res, 400, { error: 'Unknown promptId' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const chat = chats.updateChat(dir, id, safeBody);
|
|
161
|
+
if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
162
|
+
return sendJSON(res, 200, { chat });
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// POST /api/chats/:id/touch body: { projectDir }
|
|
169
|
+
m = urlPath.match(/^\/api\/chats\/([^/]+)\/touch$/);
|
|
170
|
+
if (m && method === 'POST') {
|
|
171
|
+
const id = safeDecode(m[1]);
|
|
172
|
+
const body = await readJsonOr400(req, res);
|
|
173
|
+
if (!body) return;
|
|
174
|
+
const dir = readProjectDir(body);
|
|
175
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
176
|
+
try {
|
|
177
|
+
require('./tools/authorization.js').clearGrants(dir, id);
|
|
178
|
+
const chat = chats.touchChat(dir, id);
|
|
179
|
+
if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
180
|
+
return sendJSON(res, 200, { chat });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// DELETE /api/chats/:id?projectDir=<abs>
|
|
187
|
+
m = urlPath.match(/^\/api\/chats\/([^/]+)$/);
|
|
188
|
+
if (m && method === 'DELETE') {
|
|
189
|
+
const id = safeDecode(m[1]);
|
|
190
|
+
const dir = qs(q, 'projectDir');
|
|
191
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
192
|
+
try {
|
|
193
|
+
const removed = chats.deleteChat(dir, id);
|
|
194
|
+
if (!removed) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
195
|
+
// Chat storage and trace export are independent. Deleting a chat
|
|
196
|
+
// removes its transcript, but deliberately keeps the user-owned trace
|
|
197
|
+
// file so it can remain committed with the project (decision §5).
|
|
198
|
+
try { require('fs').rmSync(messages.messagesFilePath(dir, id), { force: true }); }
|
|
199
|
+
catch { /* best-effort cleanup after the chat record is gone */ }
|
|
200
|
+
// Clean up in-memory task state.
|
|
201
|
+
try { require('./tools/task.js').clearChat(id); } catch { /* non-fatal */ }
|
|
202
|
+
return sendJSON(res, 200, { ok: true, removed: id });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return sendJSON(res, chatError(e), { error: e.message, code: e.code || 'INTERNAL' });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- Per-chat messages -------------------------------------------
|
|
209
|
+
// GET /api/chats/:id/messages?projectDir=[&fromSeq=<seq>] -> { messages, nextSeq, base }
|
|
210
|
+
// GET /api/chats/:id/messages?projectDir=[&limit=<n>[&beforeSeq=<seq>]] -> { messages, total, hasMore, nextSeq, base, beforeSeq }
|
|
211
|
+
//
|
|
212
|
+
// Two modes share one URL:
|
|
213
|
+
//
|
|
214
|
+
// - Tail mode (the stream/recovery hot path): `fromSeq` is the next
|
|
215
|
+
// persisted row the caller has not merged. Append-only — fetch rows
|
|
216
|
+
// with seq >= fromSeq and avoid a full transcript transfer unless the
|
|
217
|
+
// server cursor is behind the local cursor. `since` is a compatibility
|
|
218
|
+
// alias.
|
|
219
|
+
//
|
|
220
|
+
// - Window mode (chat backward pagination): `limit` returns only the
|
|
221
|
+
// newest `limit` rows (the first page), and `beforeSeq` returns the
|
|
222
|
+
// `limit` rows strictly below that seq (the previous page). This lets
|
|
223
|
+
// a long transcript open fast with just the tail; older pages load on
|
|
224
|
+
// demand as the user scrolls up. `total` and `hasMore` let the client
|
|
225
|
+
// know when every older row has been reached.
|
|
226
|
+
const getMsgsMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/messages$/);
|
|
227
|
+
if (getMsgsMatch && method === 'GET') {
|
|
228
|
+
const id = safeDecode(getMsgsMatch[1]);
|
|
229
|
+
const dir = qs(q, 'projectDir');
|
|
230
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
231
|
+
try {
|
|
232
|
+
const chat = chats.getChat(dir, id);
|
|
233
|
+
if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
234
|
+
// Snapshot the aggregate alongside this response's cursor. All storage
|
|
235
|
+
// reads below are synchronous, so appends cannot interleave the snapshot.
|
|
236
|
+
const totalCost = chat.totalCost;
|
|
237
|
+
const rawFrom = typeof q.fromSeq === 'string' ? q.fromSeq : q.since;
|
|
238
|
+
const fromSeq = typeof rawFrom === 'string' ? parseInt(rawFrom, 10) : NaN;
|
|
239
|
+
const rawLimit = typeof q.limit === 'string' ? parseInt(q.limit, 10) : 0;
|
|
240
|
+
// Window mode is selected by an explicit `limit` (used by the chat
|
|
241
|
+
// pagination loader). It takes precedence over fromSeq so the two
|
|
242
|
+
// paths never conflict.
|
|
243
|
+
if (rawLimit > 0) {
|
|
244
|
+
const limit = Math.min(rawLimit, 200);
|
|
245
|
+
const beforeSeqRaw = typeof q.beforeSeq === 'string' ? parseInt(q.beforeSeq, 10) : NaN;
|
|
246
|
+
const beforeSeq = (isFinite(beforeSeqRaw) && beforeSeqRaw >= 0) ? beforeSeqRaw : Infinity;
|
|
247
|
+
const window = messages.listMessagesWindow(dir, id, { limit, beforeSeq });
|
|
248
|
+
const total = messages.getMessageCount(dir, id);
|
|
249
|
+
// hasMore: a full page AND the oldest returned row is not the first
|
|
250
|
+
// message. Because seq is contiguous from 0, an oldest seq of 0 means
|
|
251
|
+
// we already reached the very top.
|
|
252
|
+
const hasMore = window.length >= limit && window.length > 0 && window[0].seq > 0;
|
|
253
|
+
const nextBeforeSeq = hasMore && window.length ? window[0].seq : null;
|
|
254
|
+
return sendJSON(res, 200, {
|
|
255
|
+
messages: window,
|
|
256
|
+
total,
|
|
257
|
+
totalCost,
|
|
258
|
+
hasMore,
|
|
259
|
+
beforeSeq: nextBeforeSeq,
|
|
260
|
+
nextSeq: total,
|
|
261
|
+
base: total
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const all = messages.listMessages(dir, id);
|
|
265
|
+
if (isFinite(fromSeq) && fromSeq >= 0) {
|
|
266
|
+
const tail = fromSeq <= all.length ? all.slice(fromSeq) : [];
|
|
267
|
+
return sendJSON(res, 200, { messages: tail, nextSeq: all.length, base: all.length, totalCost });
|
|
268
|
+
}
|
|
269
|
+
return sendJSON(res, 200, { messages: all, nextSeq: all.length, base: all.length, totalCost });
|
|
270
|
+
} catch (e) {
|
|
271
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
272
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// GET /api/chats/:id/revision?projectDir= -> { nextSeq, running }
|
|
277
|
+
// Lightweight run state for the reconcile/recovery poll. `nextSeq` is
|
|
278
|
+
// the append-only transcript cursor; if it is ahead of the client cursor,
|
|
279
|
+
// the client fetches just `/messages?fromSeq=<localNextSeq>`.
|
|
280
|
+
const revMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/revision$/);
|
|
281
|
+
if (revMatch && method === 'GET') {
|
|
282
|
+
const id = safeDecode(revMatch[1]);
|
|
283
|
+
const dir = qs(q, 'projectDir');
|
|
284
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
285
|
+
try {
|
|
286
|
+
if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
287
|
+
const rev = messages.messageCursor(dir, id);
|
|
288
|
+
rev.running = runningChats.has(runningKey(dir, id));
|
|
289
|
+
return sendJSON(res, 200, rev);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
292
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// GET /api/chats/:id/system-prompt?projectDir= -> { profile, agentFiles, skills, prompt, text }
|
|
297
|
+
// Returns the effective system context for a chat as it will be sent
|
|
298
|
+
// upstream: the resolved prompt-size profile system message, the
|
|
299
|
+
// agent files (when enabled), and, if the chat references a custom
|
|
300
|
+
// prompt, that prompt's content. The chat UI renders this as the
|
|
301
|
+
// first (collapsible) message in the transcript so the user can see
|
|
302
|
+
// what the model is being told, without the prompt-size picker having
|
|
303
|
+
// to be a permanent fixture.
|
|
304
|
+
const sysPromptMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/system-prompt$/);
|
|
305
|
+
if (sysPromptMatch && method === 'GET') {
|
|
306
|
+
const id = safeDecode(sysPromptMatch[1]);
|
|
307
|
+
const dir = qs(q, 'projectDir');
|
|
308
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
309
|
+
try {
|
|
310
|
+
const chat = chats.getChat(dir, id);
|
|
311
|
+
if (!chat) return sendJSON(res, 404, { error: 'chat not found' });
|
|
312
|
+
let profile = null;
|
|
313
|
+
try {
|
|
314
|
+
const p = promptProfiles.resolveProfile({ chat, projectDir: dir });
|
|
315
|
+
if (p) profile = { id: p.id, label: p.label, description: p.description, systemMessage: p.systemMessage };
|
|
316
|
+
} catch { /* profile stays null; the stream would fall through too */ }
|
|
317
|
+
let prompt = null;
|
|
318
|
+
// The prompt's optional `preset` (tools + agent-files) is part of
|
|
319
|
+
// the chat's effective config for this request — the same shape the
|
|
320
|
+
// stream uses (see resolveChatEffective). It only ADDS to the
|
|
321
|
+
// per-chat toggle (see prompts.effectivePresetConfig), never
|
|
322
|
+
// overrides the project's authorization gate.
|
|
323
|
+
let effectiveChat = chat;
|
|
324
|
+
if (chat.promptId) {
|
|
325
|
+
try {
|
|
326
|
+
const cp = prompts.getPrompt(dir, chat.promptId);
|
|
327
|
+
if (cp) {
|
|
328
|
+
prompt = { id: cp.id, title: cp.title, role: cp.role, content: cp.content, preset: cp.preset || null };
|
|
329
|
+
if (cp.preset) effectiveChat = Object.assign({}, chat, prompts.effectivePresetConfig(chat, cp.preset));
|
|
330
|
+
}
|
|
331
|
+
} catch { /* custom prompt stays null */ }
|
|
332
|
+
}
|
|
333
|
+
// The combined text mirrors the order handleChatStream uses:
|
|
334
|
+
// profile system message first, then agent files, selected agent,
|
|
335
|
+
// skills, then the custom prompt.
|
|
336
|
+
const parts = [];
|
|
337
|
+
if (profile && profile.systemMessage) parts.push(profile.systemMessage);
|
|
338
|
+
let agentFilesList = null;
|
|
339
|
+
let agentFilesEnabled = false;
|
|
340
|
+
let agentFilesAvailable = [];
|
|
341
|
+
let agentFileNames = [];
|
|
342
|
+
try {
|
|
343
|
+
agentFilesEnabled = agentFiles.resolveEnabled({ chat: effectiveChat, projectDir: dir });
|
|
344
|
+
agentFileNames = agentFiles.resolveFileNames({ chat: effectiveChat, projectDir: dir });
|
|
345
|
+
agentFilesAvailable = agentFiles.discover(dir, agentFileNames).map((f) => ({ name: f.name, size: f.size }));
|
|
346
|
+
if (agentFilesEnabled) {
|
|
347
|
+
agentFilesList = agentFiles.load(dir, agentFileNames);
|
|
348
|
+
for (const af of agentFilesList) parts.push(af.content);
|
|
349
|
+
}
|
|
350
|
+
} catch { /* agent files stay null */ }
|
|
351
|
+
// Agents are subagent delegation targets only — never part of
|
|
352
|
+
// the chat's system prompt. Skills are project instruction files.
|
|
353
|
+
// A prompt preset with `skills: true` rides on the chat for this
|
|
354
|
+
// turn (see prompts.effectivePresetConfig), so the catalog
|
|
355
|
+
// resolution reads `effectiveChat` — not the persisted record —
|
|
356
|
+
// to mirror the live stream's behavior.
|
|
357
|
+
const skillState = agentSkills.resolve({ chat: effectiveChat, projectDir: dir });
|
|
358
|
+
const skillCatalog = agentSkills.catalogMessage(dir, effectiveChat);
|
|
359
|
+
if (skillCatalog) parts.push(skillCatalog);
|
|
360
|
+
if (prompt && prompt.content) parts.push(prompt.content);
|
|
361
|
+
// Also expose the project-level gate so the UI can render the
|
|
362
|
+
// per-chat toggle as locked off when the project has it disabled.
|
|
363
|
+
let projectAgentFiles = null;
|
|
364
|
+
try {
|
|
365
|
+
const project = require('./settings.js').getProject(dir);
|
|
366
|
+
if (project && typeof project.agentFiles === 'boolean') projectAgentFiles = project.agentFiles;
|
|
367
|
+
} catch { /* null */ }
|
|
368
|
+
return sendJSON(res, 200, {
|
|
369
|
+
profile,
|
|
370
|
+
agentFiles: agentFilesList ? agentFilesList.map(m => ({ name: m.name })) : null,
|
|
371
|
+
agentFilesEnabled,
|
|
372
|
+
agentFilesAvailable,
|
|
373
|
+
agentFileNames,
|
|
374
|
+
projectAgentFiles,
|
|
375
|
+
// `disabled` is the project lock (rendered locked with a reason);
|
|
376
|
+
// `chatDisabled` is this chat's own per-skill opt-out, which the
|
|
377
|
+
// transcript row can turn back on without touching the project.
|
|
378
|
+
skills: skillState.skills.map((s) => ({
|
|
379
|
+
id: s.id,
|
|
380
|
+
name: s.name,
|
|
381
|
+
description: s.description,
|
|
382
|
+
enabled: skillState.enabled && !skillState.disabled.has(s.id),
|
|
383
|
+
disabled: skillState.projectDisabled.has(s.id),
|
|
384
|
+
chatDisabled: skillState.chatDisabled.has(s.id)
|
|
385
|
+
})),
|
|
386
|
+
projectSkills: skillState.projectEnabled,
|
|
387
|
+
// The family flag as the STREAM resolves it, which is not always the
|
|
388
|
+
// persisted `chat.skills`: a prompt preset with `skills: true` rides
|
|
389
|
+
// on the chat for the turn (see prompts.effectivePresetConfig). The
|
|
390
|
+
// transcript card must show what the model will actually get.
|
|
391
|
+
skillsEnabled: skillState.enabled,
|
|
392
|
+
prompt,
|
|
393
|
+
text: parts.join('\n\n')
|
|
394
|
+
});
|
|
395
|
+
} catch (e) {
|
|
396
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
397
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// GET /api/chats/:id/tool-preview?projectDir= -> { profile, tools }
|
|
402
|
+
// Returns the tool-declaration state for the chat's resolved
|
|
403
|
+
// prompt-size profile: which tools are advertised to the model and
|
|
404
|
+
// in what shape (full spec vs. the very-small discover_tool flow).
|
|
405
|
+
// The chat UI shows this as a temporary preview while
|
|
406
|
+
// the chat is still empty, so the user sees the concrete effect of
|
|
407
|
+
// the S/M/L switch on the tool budget before the first message.
|
|
408
|
+
// The collection logic mirrors ai.streamChat (native shell + MCP),
|
|
409
|
+
// then promptProfiles.reduceToolSpecs applies the same reduction the
|
|
410
|
+
// stream will apply — so the preview is always what the model gets.
|
|
411
|
+
const toolPreviewMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/tool-preview$/);
|
|
412
|
+
if (toolPreviewMatch && method === 'GET') {
|
|
413
|
+
const id = safeDecode(toolPreviewMatch[1]);
|
|
414
|
+
const dir = qs(q, 'projectDir');
|
|
415
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
416
|
+
try {
|
|
417
|
+
const chat = chats.getChat(dir, id);
|
|
418
|
+
if (!chat) return sendJSON(res, 404, { error: 'chat not found' });
|
|
419
|
+
// Resolve the profile id (chat -> project -> app -> 'average').
|
|
420
|
+
let profileId = promptProfiles.DEFAULT_PROFILE;
|
|
421
|
+
try {
|
|
422
|
+
const p = promptProfiles.resolveProfile({ chat, projectDir: dir });
|
|
423
|
+
if (p && p.id) profileId = p.id;
|
|
424
|
+
} catch { /* fall through to default */ }
|
|
425
|
+
// Collect the tool specs exactly as streamChat does: base shell,
|
|
426
|
+
// progress, subagent, ask_user, and file tools are always
|
|
427
|
+
// advertised, plus ready MCP servers.
|
|
428
|
+
const shellEnabled = true;
|
|
429
|
+
const fileToolsEnabled = true;
|
|
430
|
+
const toolSpecs = [];
|
|
431
|
+
if (shellEnabled) {
|
|
432
|
+
try { toolSpecs.push(shellTool.SPEC); } catch { /* skip */ }
|
|
433
|
+
}
|
|
434
|
+
try { toolSpecs.push(require('./tools/progress.js').SPEC); } catch { /* skip */ }
|
|
435
|
+
try { toolSpecs.push(require('./tools/subagent.js').SPEC); } catch { /* skip */ }
|
|
436
|
+
try { toolSpecs.push(require('./tools/ask.js').SPEC); } catch { /* skip */ }
|
|
437
|
+
try { toolSpecs.push(require('./agentFeatures.js').LIST_FEATURES_SPEC); } catch { /* skip */ }
|
|
438
|
+
try { toolSpecs.push(require('./tools/webpreview.js').SPEC); } catch { /* skip */ }
|
|
439
|
+
try { toolSpecs.push(require('./tools/restart.js').SPEC); } catch { /* skip */ }
|
|
440
|
+
if (fileToolsEnabled) {
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
const fileTools = require('./tools/files.js');
|
|
444
|
+
for (const n of fileTools.FILE_TOOL_NAMES) toolSpecs.push(fileTools.SPECS[n]);
|
|
445
|
+
} catch { /* skip */ }
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
const specs = mcp.listComposedToolSpecs(dir);
|
|
449
|
+
if (specs && specs.length) {
|
|
450
|
+
for (const s of specs) {
|
|
451
|
+
toolSpecs.push({
|
|
452
|
+
type: 'function',
|
|
453
|
+
function: { name: s.name, description: s.description, parameters: s.parameters }
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch { /* no MCP tools */ }
|
|
458
|
+
try {
|
|
459
|
+
const authz = require('./tools/authorization.js');
|
|
460
|
+
const authState = authz.getAuthorization(dir, id);
|
|
461
|
+
for (const family of ['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']) {
|
|
462
|
+
const cfg = authState.tools[family];
|
|
463
|
+
if (cfg && cfg.mode === 'off') {
|
|
464
|
+
const hidden = family === 'file' ? authz.FILE_FAMILY_TOOLS : new Set([family]);
|
|
465
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
466
|
+
const spec = toolSpecs[i];
|
|
467
|
+
if (spec && spec.function && hidden.has(spec.function.name)) toolSpecs.splice(i, 1);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// Per-leaf file overrides (e.g. tools.read_file.mode = "off"
|
|
472
|
+
// with the `file` family enabled) must drop just that operation,
|
|
473
|
+
// matching what streamChat advertises to the model.
|
|
474
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
475
|
+
const spec = toolSpecs[i];
|
|
476
|
+
if (!spec || !spec.function || !authz.FILE_FAMILY_TOOLS.has(spec.function.name)) continue;
|
|
477
|
+
const cfg = authz.effectiveConfig(dir, spec.function.name, id);
|
|
478
|
+
if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
|
|
479
|
+
}
|
|
480
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
481
|
+
const spec = toolSpecs[i];
|
|
482
|
+
if (!spec || !spec.function || !String(spec.function.name).startsWith('mcp__')) continue;
|
|
483
|
+
const cfg = authz.effectiveConfig(dir, spec.function.name, id);
|
|
484
|
+
if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
|
|
485
|
+
}
|
|
486
|
+
} catch { /* authorization state unreadable; keep every tool advertised */ }
|
|
487
|
+
// Apply the same per-profile reduction the stream applies. For
|
|
488
|
+
// very-small this is discover_tool plus one compact (name + description,
|
|
489
|
+
// schema-less) entry per tool — a FIXED list, identical on every
|
|
490
|
+
// tool-loop request, so the Anthropic cached prefix stays byte-stable.
|
|
491
|
+
let effective = toolSpecs;
|
|
492
|
+
try { effective = promptProfiles.reduceToolSpecs(toolSpecs, profileId); } catch { /* full specs */ }
|
|
493
|
+
const reduced = profileId === 'very-small';
|
|
494
|
+
const tools = (effective || []).map((s) => {
|
|
495
|
+
const fn = (s && s.function) || {};
|
|
496
|
+
const params = fn.parameters && fn.parameters.properties ? Object.keys(fn.parameters.properties) : [];
|
|
497
|
+
return {
|
|
498
|
+
name: fn.name || '',
|
|
499
|
+
description: typeof fn.description === 'string' ? fn.description : '',
|
|
500
|
+
// hasSchema reflects whether this advertised tool exposes
|
|
501
|
+
// parameter names. For very-small only discover_tool's own
|
|
502
|
+
// schema is present; the other entries are schema-less.
|
|
503
|
+
hasSchema: params.length > 0,
|
|
504
|
+
params
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
return sendJSON(res, 200, {
|
|
508
|
+
profile: profileId,
|
|
509
|
+
reduced,
|
|
510
|
+
shellEnabled,
|
|
511
|
+
fileToolsEnabled,
|
|
512
|
+
count: tools.length,
|
|
513
|
+
tools
|
|
514
|
+
});
|
|
515
|
+
} catch (e) {
|
|
516
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
517
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// POST /api/chats/:id/messages body: { projectDir, role, content }
|
|
522
|
+
// Append a message directly. The /messages/stream endpoint below
|
|
523
|
+
// does the same internally for user / assistant messages; this
|
|
524
|
+
// route is for manual edits and tests.
|
|
525
|
+
if (getMsgsMatch && method === 'POST') {
|
|
526
|
+
const id = safeDecode(getMsgsMatch[1]);
|
|
527
|
+
const body = await readJsonOr400(req, res);
|
|
528
|
+
if (!body) return;
|
|
529
|
+
const dir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
530
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
531
|
+
try {
|
|
532
|
+
if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
533
|
+
const msg = messages.appendMessage(dir, id, { role: body.role, content: body.content, ts: body.ts });
|
|
534
|
+
return sendJSON(res, 201, { message: msg });
|
|
535
|
+
} catch (e) {
|
|
536
|
+
return sendJSON(res, 400, { error: e.message });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// DELETE /api/chats/:id/messages?projectDir= -> { ok, removed }
|
|
541
|
+
if (getMsgsMatch && method === 'DELETE') {
|
|
542
|
+
const id = safeDecode(getMsgsMatch[1]);
|
|
543
|
+
const dir = qs(q, 'projectDir');
|
|
544
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
545
|
+
try {
|
|
546
|
+
if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
547
|
+
const removed = messages.clearMessages(dir, id);
|
|
548
|
+
return sendJSON(res, 200, { ok: true, removed });
|
|
549
|
+
} catch (e) {
|
|
550
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
551
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const exportTraceMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/trace\/export$/);
|
|
556
|
+
if (exportTraceMatch && method === 'POST') {
|
|
557
|
+
const id = safeDecode(exportTraceMatch[1]);
|
|
558
|
+
const body = await readJsonOr400(req, res);
|
|
559
|
+
if (!body) return;
|
|
560
|
+
const dir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
561
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
562
|
+
try {
|
|
563
|
+
if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
564
|
+
const file = trace.exportMessages(dir, id, messages.listMessages(dir, id));
|
|
565
|
+
return sendJSON(res, 200, { ok: true, path: file });
|
|
566
|
+
} catch (e) {
|
|
567
|
+
return sendJSON(res, e.code === 'EBADINPUT' ? 400 : 500, { error: e.message, code: e.code || 'INTERNAL' });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// POST /api/chats/import body: { projectDir, skipExisting?: bool }
|
|
572
|
+
// Re-import chat metadata and messages from JSON files into the DB.
|
|
573
|
+
if (urlPath === '/api/chats/import' && method === 'POST') {
|
|
574
|
+
const body = await readJsonOr400(req, res);
|
|
575
|
+
if (!body) return;
|
|
576
|
+
const dir = readProjectDir(body);
|
|
577
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
578
|
+
try {
|
|
579
|
+
const chatdb = require('./chatdb.js');
|
|
580
|
+
const result = chatdb.importFromJson(dir, { skipExisting: !!body.skipExisting });
|
|
581
|
+
chats.recomputeProjectTotalCost(dir);
|
|
582
|
+
return sendJSON(res, 200, { ok: true, imported: result });
|
|
583
|
+
} catch (e) {
|
|
584
|
+
return sendJSON(res, 500, { error: e.message, code: e.code || 'INTERNAL' });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// GET /api/chats/:id/live?projectDir= (SSE)
|
|
589
|
+
// Per-chat live-replay stream. While a chat is running, a follower
|
|
590
|
+
// client (another tab/device, or this UI returning to a running chat)
|
|
591
|
+
// subscribes here and immediately receives the buffered transient tool
|
|
592
|
+
// events (shell_output / subagent_event / progress_update) for the
|
|
593
|
+
// in-flight run, then continues to receive them as they occur. The
|
|
594
|
+
// stream closes with a `run_end` event when the run finishes. Requires
|
|
595
|
+
// the chat to actually be running — a 404 prevents a client from
|
|
596
|
+
// holding a dead connection waiting for content that will never come.
|
|
597
|
+
const liveMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/live$/);
|
|
598
|
+
if (liveMatch && method === 'GET') {
|
|
599
|
+
const id = safeDecode(liveMatch[1]);
|
|
600
|
+
const dir = qs(q, 'projectDir');
|
|
601
|
+
if (!dir) return sendJSON(res, 400, { error: 'projectDir query param is required' });
|
|
602
|
+
try {
|
|
603
|
+
if (!chats.getChat(dir, id)) return sendJSON(res, 404, { error: 'Chat not found', id });
|
|
604
|
+
const rk = runningKey(dir, id);
|
|
605
|
+
if (!runningChats.has(rk)) return sendJSON(res, 404, { error: 'No live run for this chat', id });
|
|
606
|
+
const fromLiveSeq = typeof q.fromLiveSeq === 'string' ? parseInt(q.fromLiveSeq, 10) : 0;
|
|
607
|
+
return liveChat.addSubscriber(rk, req, res, { fromLiveSeq: isFinite(fromLiveSeq) && fromLiveSeq >= 0 ? fromLiveSeq : 0 });
|
|
608
|
+
} catch (e) {
|
|
609
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
610
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// POST /api/chats/:id/messages/stream body: { projectDir, modelId, content }
|
|
615
|
+
// Appends the user message, calls ai.streamChat, streams the
|
|
616
|
+
// response back as SSE, appends the assistant message on done, and
|
|
617
|
+
// writes both events to the trace file (if the chat's trace flag
|
|
618
|
+
// is on). One round-trip per user turn.
|
|
619
|
+
const streamMatch = urlPath.match(/^\/api\/chats\/([^/]+)\/messages\/stream$/);
|
|
620
|
+
if (streamMatch && method === 'POST') {
|
|
621
|
+
return handleChatStream(req, res, streamMatch[1], sessionToken, lifecycle);
|
|
622
|
+
}
|
|
623
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'chats' });
|
|
624
|
+
}
|
|
625
|
+
// Handles POST /api/chats/:id/messages/stream. Splits out for clarity;
|
|
626
|
+
// the route table above stays compact.
|
|
627
|
+
async function handleChatStream(req, res, chatId, sessionToken, lifecycle = {}) {
|
|
628
|
+
const _pushSessionId = push.sessionIdFromToken(sessionToken);
|
|
629
|
+
const body = await readJsonOr400(req, res);
|
|
630
|
+
if (!body) return;
|
|
631
|
+
const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
632
|
+
const modelId = body && typeof body.modelId === 'string' ? body.modelId : '';
|
|
633
|
+
const providerId = body && typeof body.providerId === 'string' ? body.providerId : '';
|
|
634
|
+
const content = body && typeof body.content === 'string' ? body.content : '';
|
|
635
|
+
const attachments = messages.normalizeAttachments(body && body.attachments);
|
|
636
|
+
const thinkingLevel = body && typeof body.thinkingLevel === 'string' ? body.thinkingLevel : '';
|
|
637
|
+
const maxOutputTokens = body && typeof body.maxOutputTokens === 'string' ? body.maxOutputTokens : '';
|
|
638
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
639
|
+
if (!modelId) return sendJSON(res, 400, { error: 'modelId is required' });
|
|
640
|
+
if (!content && !attachments.length) return sendJSON(res, 400, { error: 'content or image is required' });
|
|
641
|
+
|
|
642
|
+
let chat;
|
|
643
|
+
try { chat = chats.getChat(projectDir, chatId); }
|
|
644
|
+
catch (e) {
|
|
645
|
+
const status = e.code === 'MOUAIF_PROJECT_PARSE_ERROR' ? 422 : 500;
|
|
646
|
+
return sendJSON(res, status, { error: e.message, code: e.code || 'INTERNAL' });
|
|
647
|
+
}
|
|
648
|
+
if (!chat) return sendJSON(res, 404, { error: 'Chat not found', id: chatId });
|
|
649
|
+
|
|
650
|
+
// The chat's effective per-chat config. A custom prompt with a `preset`
|
|
651
|
+
// (tools + agent-files) rides on the chat for THIS turn: it is merged
|
|
652
|
+
// into the per-chat tool filter and agent-files toggle (see
|
|
653
|
+
// prompts.effectivePresetConfig) without touching the persisted chat
|
|
654
|
+
// record or the project's authorization gate. Falls back to `chat`
|
|
655
|
+
// when the prompt has no preset.
|
|
656
|
+
let effectiveChat = chat;
|
|
657
|
+
try {
|
|
658
|
+
if (chat.promptId) {
|
|
659
|
+
const preset = prompts.getPromptPreset(projectDir, chat.promptId);
|
|
660
|
+
if (preset) effectiveChat = Object.assign({}, chat, prompts.effectivePresetConfig(chat, preset));
|
|
661
|
+
}
|
|
662
|
+
} catch { /* preset best-effort; fall back to plain chat */ }
|
|
663
|
+
|
|
664
|
+
// Reject a second concurrent stream on the same chat. Two in-flight
|
|
665
|
+
// runs interleave appendMessage read-modify-writes and both append
|
|
666
|
+
// assistant messages, corrupting transcript order.
|
|
667
|
+
const runKey = runningKey(projectDir, chatId);
|
|
668
|
+
if (runningChats.has(runKey)) {
|
|
669
|
+
return sendJSON(res, 409, { error: 'A response is already streaming for this chat', code: 'EALREADY_RUNNING', id: chatId });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Resolve the project model and hydrate it with its app-level provider
|
|
673
|
+
// connection (credentials, base URL, and auth account).
|
|
674
|
+
let model;
|
|
675
|
+
try { model = resolveModel(modelId, projectDir, providerId); }
|
|
676
|
+
catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code, modelId, providerId: providerId || undefined }); }
|
|
677
|
+
|
|
678
|
+
// Append the user message and bump lastOpenedAt BEFORE streaming.
|
|
679
|
+
// If this is the first prompt in a new/default-named chat, also
|
|
680
|
+
// derive a human title from that prompt and persist it immediately.
|
|
681
|
+
let userMsg;
|
|
682
|
+
try { userMsg = messages.appendMessage(projectDir, chatId, { role: 'user', content, attachments }); }
|
|
683
|
+
catch (e) { return sendJSON(res, 400, { error: e.message }); }
|
|
684
|
+
// Read the full transcript once and reuse across the title-derivation
|
|
685
|
+
// check and the upstream message assembly below. Two separate
|
|
686
|
+
// listMessages() calls would read SQLite or the JSON file twice
|
|
687
|
+
// for identical data (the user message was already appended above).
|
|
688
|
+
const history = messages.listMessages(projectDir, chatId);
|
|
689
|
+
try {
|
|
690
|
+
if (history.filter(m => m && m.role === 'user').length === 1 && content) {
|
|
691
|
+
const renamed = chats.titleChatFromPrompt(projectDir, chatId, content);
|
|
692
|
+
if (renamed) chat = renamed;
|
|
693
|
+
}
|
|
694
|
+
} catch { /* non-fatal */ }
|
|
695
|
+
try { chats.touchChat(projectDir, chatId); } catch { /* non-fatal */ }
|
|
696
|
+
|
|
697
|
+
// Mark the chat as running for the lifetime of the SSE response so a
|
|
698
|
+
// reloaded client re-enters its busy state and a second stream is
|
|
699
|
+
// rejected (above). Registered only after every failable setup step
|
|
700
|
+
// (model resolution, message append) so an early 4xx cannot leak the
|
|
701
|
+
// marker; cleared at every exit below (normal, error, and throw).
|
|
702
|
+
const runController = new AbortController();
|
|
703
|
+
runningChats.add(runKey);
|
|
704
|
+
runningChatCancels.set(runKey, runController);
|
|
705
|
+
liveChat.ensureLiveChat(runKey);
|
|
706
|
+
|
|
707
|
+
// Open SSE.
|
|
708
|
+
res.writeHead(200, {
|
|
709
|
+
'Content-Type': 'text/event-stream',
|
|
710
|
+
'Cache-Control': 'no-cache',
|
|
711
|
+
Connection: 'keep-alive'
|
|
712
|
+
});
|
|
713
|
+
res.write(': connected\n\n');
|
|
714
|
+
|
|
715
|
+
// Open the trace file. No-op writer if trace is off or the file
|
|
716
|
+
// system is read-only.
|
|
717
|
+
const traceStream = chat.trace ? trace.open(projectDir, chatId) : null;
|
|
718
|
+
function emit(name, data) {
|
|
719
|
+
try {
|
|
720
|
+
res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n');
|
|
721
|
+
} catch { /* socket closed */ }
|
|
722
|
+
if (traceStream) trace.write(traceStream, name, data);
|
|
723
|
+
// Fan transient tool streams out to followers (live-chat.js) so a
|
|
724
|
+
// returning page or a second tab renders them in real time. These
|
|
725
|
+
// are exactly the events that never reach the persisted transcript
|
|
726
|
+
// on their own: buffered until the matching tool_result lands (or
|
|
727
|
+
// the run ends), replayed to late subscribers, and pushed live to
|
|
728
|
+
// connected ones. Conveniently, the run entry also gives us the
|
|
729
|
+
// toolResultId for a later prune on `tool_result`.
|
|
730
|
+
if (name === 'shell_output' || name === 'subagent_event' || name === 'progress_update'
|
|
731
|
+
|| name === 'authorization_required' || name === 'ask_user_required') {
|
|
732
|
+
liveChat.pushLive(runKey, name, data);
|
|
733
|
+
} else if (name === 'tool_result') {
|
|
734
|
+
liveChat.pruneLive(runKey, data && data.id);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (traceStream) {
|
|
738
|
+
const event = trace.eventForMessage(userMsg);
|
|
739
|
+
trace.write(traceStream, event.type, event.payload);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Build the message list to send upstream: existing transcript + the
|
|
743
|
+
// user message we just appended. The list is composed in this order
|
|
744
|
+
// (each block is optional, but the profile block is always present):
|
|
745
|
+
// 1. Prompt-size profile system message (decisions §4 prompt-size
|
|
746
|
+
// profiles). Resolved from chat.promptSize -> resolved project
|
|
747
|
+
// settings.promptSize -> 'average'. The profile carries the
|
|
748
|
+
// model identity + the default guidance. A missing or unknown
|
|
749
|
+
// value falls through to the default; this code never throws.
|
|
750
|
+
// 2. Agent files (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md),
|
|
751
|
+
// when enabled for this chat. Each file rides as its own system
|
|
752
|
+
// message so the model sees the file boundary.
|
|
753
|
+
// 3. Custom prompt (chat.promptId), if the chat references a
|
|
754
|
+
// project prompt. The custom prompt refines the profile — the
|
|
755
|
+
// instructions on each prompt say "where they do not conflict
|
|
756
|
+
// with the active profile".
|
|
757
|
+
// 4. The transcript (user + assistant turns), with the brand-new
|
|
758
|
+
// user turn already appended by the appendMessage call above.
|
|
759
|
+
const upstreamMessages = [];
|
|
760
|
+
// Resolve the prompt-size profile once. Its id drives BOTH the system
|
|
761
|
+
// message (below) and the tool-declaration reduction passed to
|
|
762
|
+
// streamChat (decisions §4: very-small trims tool schemas).
|
|
763
|
+
let resolvedProfileId = promptProfiles.DEFAULT_PROFILE;
|
|
764
|
+
try {
|
|
765
|
+
const profile = promptProfiles.resolveProfile({ chat, projectDir });
|
|
766
|
+
if (profile) {
|
|
767
|
+
if (profile.id) resolvedProfileId = profile.id;
|
|
768
|
+
if (profile.systemMessage) {
|
|
769
|
+
upstreamMessages.push({ role: 'system', content: profile.systemMessage });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
} catch { /* non-fatal; stream proceeds without a profile system message */ }
|
|
773
|
+
// Resolve the per-project tool output profile (size + structure). The
|
|
774
|
+
// resolved settings are defaults → app → project, so an unset project
|
|
775
|
+
// gets the built-in `{ size: 'average', structure: 'full' }`. This
|
|
776
|
+
// drives how much of each tool result the model sees on BOTH the live
|
|
777
|
+
// tool loop (streamChat) and the reconstructed history (below).
|
|
778
|
+
let resolvedToolOutput = null;
|
|
779
|
+
try {
|
|
780
|
+
resolvedToolOutput = settings.getResolved(projectDir).toolOutput;
|
|
781
|
+
} catch { /* non-fatal; fall back to the defaults in toolFeedback */ }
|
|
782
|
+
// Agent files (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md).
|
|
783
|
+
// Injected after the profile but before tagged files and the custom
|
|
784
|
+
// prompt, so they sit close to the identity block. Each file rides
|
|
785
|
+
// as its own system message. A trace line records what was injected.
|
|
786
|
+
try {
|
|
787
|
+
if (agentFiles.resolveEnabled({ chat: effectiveChat, projectDir })) {
|
|
788
|
+
const names = agentFiles.resolveFileNames({ chat: effectiveChat, projectDir });
|
|
789
|
+
const injected = agentFiles.load(projectDir, names);
|
|
790
|
+
for (const m of injected) upstreamMessages.push({ role: m.role, content: m.content });
|
|
791
|
+
if (traceStream && injected.length) {
|
|
792
|
+
trace.write(traceStream, 'agent-files', {
|
|
793
|
+
files: injected.map(m => m.name)
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
} catch { /* non-fatal; stream proceeds without agent files */ }
|
|
798
|
+
// Agents are delegation targets for the `subagent` tool only — they
|
|
799
|
+
// are never injected into the main chat stream (docs/features/agents.md).
|
|
800
|
+
// A prompt preset with `skills: true` rides on the chat for this
|
|
801
|
+
// turn (see prompts.effectivePresetConfig), so the skills catalog
|
|
802
|
+
// resolves against `effectiveChat` — not the persisted record —
|
|
803
|
+
// to match the agent-files / agentFeatures paths above.
|
|
804
|
+
try {
|
|
805
|
+
const catalog = agentSkills.catalogMessage(projectDir, effectiveChat);
|
|
806
|
+
if (catalog) upstreamMessages.push({ role: 'system', content: catalog });
|
|
807
|
+
} catch { /* non-fatal; stream proceeds without skills */ }
|
|
808
|
+
|
|
809
|
+
// Agent features summary — a terse list of enabled features and their
|
|
810
|
+
// authorization state in the current project. Tells the model what it
|
|
811
|
+
// can do without the user having to guess or ask. The `list_features`
|
|
812
|
+
// tool gives the full structured state.
|
|
813
|
+
try {
|
|
814
|
+
// Collect what we need for the feature summary.
|
|
815
|
+
const project = require('./settings.js').getProject(projectDir);
|
|
816
|
+
let authz = null;
|
|
817
|
+
try { authz = require('./tools/authorization.js').getAuthorization(projectDir, effectiveChat && effectiveChat.id); } catch { /* safe default */ }
|
|
818
|
+
let mcpServers = null;
|
|
819
|
+
try { mcpServers = require('./mcp.js').listServers(projectDir); } catch { /* safe default */ }
|
|
820
|
+
const featureMsg = agentFeatures.buildFeatureSummary({ chat: effectiveChat, projectDir, project, authz, mcpServers });
|
|
821
|
+
if (featureMsg) {
|
|
822
|
+
upstreamMessages.push({ role: 'system', content: featureMsg });
|
|
823
|
+
}
|
|
824
|
+
} catch { /* non-fatal; stream proceeds without feature summary */ }
|
|
825
|
+
// Tagged files (decisions §15). Injected after the profile but before
|
|
826
|
+
// the custom prompt and the transcript, so they are the deepest
|
|
827
|
+
// context. includeInChat entries ride as `system`; any file the user
|
|
828
|
+
// @-referenced in this turn's message is promoted to `user`. A trace
|
|
829
|
+
// line records what was injected without re-reading disk on replay.
|
|
830
|
+
try {
|
|
831
|
+
const referencedPaths = tags.parseReferences(projectDir, content);
|
|
832
|
+
const injected = tags.resolveForInjection(projectDir, { referencedPaths });
|
|
833
|
+
if (injected.length) {
|
|
834
|
+
for (const m of injected) upstreamMessages.push({ role: m.role, content: m.content });
|
|
835
|
+
if (traceStream) {
|
|
836
|
+
trace.write(traceStream, 'tags', {
|
|
837
|
+
files: injected.map(m => ({ path: m.relPath, role: m.role }))
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
} catch { /* non-fatal; stream proceeds without tagged files */ }
|
|
842
|
+
const effectivePromptId = chat.promptId || null;
|
|
843
|
+
if (effectivePromptId) {
|
|
844
|
+
try {
|
|
845
|
+
const prompt = prompts.getPrompt(projectDir, effectivePromptId);
|
|
846
|
+
if (prompt && prompt.content) {
|
|
847
|
+
upstreamMessages.push({ role: prompt.role, content: prompt.content });
|
|
848
|
+
}
|
|
849
|
+
} catch { /* non-fatal; stream proceeds without the prompt */ }
|
|
850
|
+
}
|
|
851
|
+
function upstreamContentForMessage(m) {
|
|
852
|
+
if (!m || m.role !== 'user' || !Array.isArray(m.attachments) || !m.attachments.length) return m && m.content;
|
|
853
|
+
const parts = [];
|
|
854
|
+
if (m.content) parts.push({ type: 'text', text: m.content });
|
|
855
|
+
for (const a of m.attachments) parts.push({ type: 'image_url', image_url: { url: a.dataUrl } });
|
|
856
|
+
return parts;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Reconstruct only complete historical tool call/result pairs. An aborted
|
|
860
|
+
// run can leave a persisted call with no result; strict OpenAI-compatible
|
|
861
|
+
// providers reject that orphan on the next send with HTTP 400. The helper
|
|
862
|
+
// also canonicalizes provider-specific call ids for cross-model resumes.
|
|
863
|
+
const supportsOpenAIToolHistory = model.provider === 'openai-compatible'
|
|
864
|
+
|| model.provider === 'openrouter'
|
|
865
|
+
|| model.provider === 'github-copilot'
|
|
866
|
+
|| model.provider === 'anthropic'; // converted to tool_use/tool_result by buildAnthropicRequest
|
|
867
|
+
let toolFeedbackMaxBytes;
|
|
868
|
+
try { toolFeedbackMaxBytes = settings.getApp().toolFeedbackMaxBytes; } catch { /* default applies */ }
|
|
869
|
+
upstreamMessages.push(...messages.reconstructUpstreamHistory(history, upstreamContentForMessage, {
|
|
870
|
+
includeTools: supportsOpenAIToolHistory,
|
|
871
|
+
toolFeedbackMaxBytes,
|
|
872
|
+
toolOutput: resolvedToolOutput
|
|
873
|
+
}));
|
|
874
|
+
|
|
875
|
+
let assistantContent = '';
|
|
876
|
+
let assistantReasoning = '';
|
|
877
|
+
let assistantMsg = null;
|
|
878
|
+
// Track the streaming window so the cost line (which is computed
|
|
879
|
+
// server-side from the upstream's authoritative usage block) also
|
|
880
|
+
// carries the streamingMs the chat UI needs for its tok/s counter.
|
|
881
|
+
// (The chat UI independently tracks its own counter for live
|
|
882
|
+
// updates; the server-side number is the fallback when the client
|
|
883
|
+
// missed frames — e.g. when the tab was backgrounded.)
|
|
884
|
+
//
|
|
885
|
+
// streamingMs accumulates ONLY the assistant-streaming windows, not
|
|
886
|
+
// the tool-execution gaps between them. The multi-round tool loop
|
|
887
|
+
// would otherwise stretch the window and under-report tok/s.
|
|
888
|
+
let streamStartedAt = 0; // set on first message/reasoning delta
|
|
889
|
+
let streamingMs = 0; // accumulated across streaming windows
|
|
890
|
+
// Per-round usage snapshots from ai.js. Each tool round's upstream
|
|
891
|
+
// call reports its own prompt/completion tokens. When a round ends
|
|
892
|
+
// with tool calls, the pending snapshot is attached to the segment
|
|
893
|
+
// persisted at `assistant_turn_end`, giving it a cost. When the
|
|
894
|
+
// turn ends without tool calls, the snapshot is redundant — the
|
|
895
|
+
// `done` handler computes the final cost from aggregated usage.
|
|
896
|
+
let pendingRoundUsage = null;
|
|
897
|
+
// Running token/cost totals across all upstream rounds in this turn,
|
|
898
|
+
// used by the task progress push notification title ("12.4K tok · $0.0312").
|
|
899
|
+
let turnTokens = 0;
|
|
900
|
+
let turnCost = 0;
|
|
901
|
+
let turnCostKnown = false;
|
|
902
|
+
// Wall-clock start of this turn, for the status block's elapsed-time row.
|
|
903
|
+
// Armed on the first streamed delta and cleared on turn end, so it measures
|
|
904
|
+
// the model's work rather than the user's think time.
|
|
905
|
+
let turnStartedAt = 0;
|
|
906
|
+
// The most recent tool this turn ran, for the status block's activity row.
|
|
907
|
+
// Reset each turn so it never leaks a tool name from the previous turn.
|
|
908
|
+
let lastToolName = '';
|
|
909
|
+
// Cost already persisted on intermediate segments (assistant_turn_end).
|
|
910
|
+
// The final message must carry only the REMAINING cost so the chat
|
|
911
|
+
// total (segment costs + final cost) equals the true per-round sum —
|
|
912
|
+
// otherwise segment completion tokens are billed twice (once on the
|
|
913
|
+
// segment, once inside the final aggregate).
|
|
914
|
+
let persistedSegmentCost = 0;
|
|
915
|
+
// Nested subagents are separate billed model calls. ai-stream reports their
|
|
916
|
+
// fully resolved total independently so it can be added without pretending
|
|
917
|
+
// their tokens used the parent model's price.
|
|
918
|
+
let delegatedCost = null;
|
|
919
|
+
function accumulateRoundUsage(roundUsage) {
|
|
920
|
+
if (!roundUsage) return;
|
|
921
|
+
turnTokens += (Number(roundUsage.promptTokens) || 0) + (Number(roundUsage.completionTokens) || 0);
|
|
922
|
+
const segCost = computeSegmentCost(roundUsage);
|
|
923
|
+
if (segCost && segCost.known) { turnCost += segCost.total; turnCostKnown = true; }
|
|
924
|
+
}
|
|
925
|
+
// The turn usage as the status block's usage row: token count plus price
|
|
926
|
+
// when pricing is known ('12.4K tok · $0.0312'), or '' before any usage has
|
|
927
|
+
// been reported. It rides the notification BODY — never the title, which the
|
|
928
|
+
// OS shows in a fixed slot and clips first.
|
|
929
|
+
function pushUsageLabel() {
|
|
930
|
+
if (!turnTokens) return '';
|
|
931
|
+
return usage.formatTokens(turnTokens) + ' tok' + (turnCostKnown ? ' · ' + usage.formatCost(turnCost) : '');
|
|
932
|
+
}
|
|
933
|
+
// Per-turn enrichment (cost + usage) is computed once on `done`
|
|
934
|
+
// and reused for both the SSE emit and the persisted assistant
|
|
935
|
+
// message. The chat UI's own live counter and the cost line
|
|
936
|
+
// diverge slightly while the stream is in flight (the live counter
|
|
937
|
+
// is per-delta; the cost line is final); that's intentional.
|
|
938
|
+
let lastEnrichment = null;
|
|
939
|
+
|
|
940
|
+
// Compute the cost for an intermediate segment from its round's
|
|
941
|
+
// usage snapshot. Returns null when pricing is unavailable.
|
|
942
|
+
function computeSegmentCost(roundUsage) {
|
|
943
|
+
if (!roundUsage) return null;
|
|
944
|
+
try {
|
|
945
|
+
const app = settings.getApp();
|
|
946
|
+
if (typeof roundUsage.providerCost === 'number' && isFinite(roundUsage.providerCost) && roundUsage.providerCost >= 0) {
|
|
947
|
+
// OpenRouter reports a real input/output split under
|
|
948
|
+
// cost_details; fall back to 0 when the round didn't carry it.
|
|
949
|
+
const split = (v) => (typeof v === 'number' && isFinite(v) && v > 0) ? v : 0;
|
|
950
|
+
return {
|
|
951
|
+
known: true,
|
|
952
|
+
input: split(roundUsage.providerCostInput),
|
|
953
|
+
output: split(roundUsage.providerCostOutput),
|
|
954
|
+
total: roundUsage.providerCost,
|
|
955
|
+
currency: 'USD'
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const result = usage.computeCost({ model, usage: roundUsage, app });
|
|
959
|
+
return { known: result.known, input: result.input, output: result.output, total: result.total, currency: result.currency };
|
|
960
|
+
} catch { return null; }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Built-in shell and file tools are always advertised. Their authorization
|
|
964
|
+
// modes decide whether calls prompt, run automatically, or are disabled.
|
|
965
|
+
const shellEnabled = true;
|
|
966
|
+
const fileToolsEnabled = true;
|
|
967
|
+
|
|
968
|
+
// App-level knobs (size caps etc.) are read once and passed through
|
|
969
|
+
// to the file tool dispatcher. The dispatcher itself uses the
|
|
970
|
+
// DEFAULT_* constants when these are missing, so passing the whole
|
|
971
|
+
// app object is fine — only the file-tool keys are consulted.
|
|
972
|
+
let appSettings = {};
|
|
973
|
+
try { appSettings = settings.getApp() || {}; } catch { /* defaults apply */ }
|
|
974
|
+
|
|
975
|
+
// Resolve the two-slot notification preferences (status, authorization)
|
|
976
|
+
// plus quickActions. Current settings store the two-key shape directly;
|
|
977
|
+
// older stores only had five booleans (progress/completion/errors and
|
|
978
|
+
// askUser/toolAuthorization). Prefer the new keys when present, else
|
|
979
|
+
// derive from the legacy keys. Mirrors normalizePreferences() in
|
|
980
|
+
// frontend/src/components/SettingsNotifications.jsx.
|
|
981
|
+
const notificationPrefs = resolveNotificationPrefs(appSettings.notifications);
|
|
982
|
+
const chatUrl = `/#/chat/${chatId}?projectDir=${encodeURIComponent(projectDir)}`;
|
|
983
|
+
// Exactly two notification channels exist per chat: one replaceable status
|
|
984
|
+
// slot rendered with an ASCII bar, and one authorization/attention slot.
|
|
985
|
+
const statusPushTag = 'chat-' + chatId + '-status';
|
|
986
|
+
|
|
987
|
+
// statusBody(sub, percent, info) -> notification body for one device
|
|
988
|
+
//
|
|
989
|
+
// The whole status lives in the body, under the bar: the running message,
|
|
990
|
+
// the position in the work, the turn usage, the elapsed time, the tool, and
|
|
991
|
+
// the model. The notification TITLE stays the chat name — the OS shows it
|
|
992
|
+
// in a fixed, narrow slot and clips it first, so the facts that used to ride
|
|
993
|
+
// there are far more useful in the body, where a wider device simply shows
|
|
994
|
+
// more of them (src/statusBar.js detailLines()).
|
|
995
|
+
//
|
|
996
|
+
// `info` is the fact object from report_progress / task, enriched with the
|
|
997
|
+
// per-turn usage, start time, tool, and model; see statusInfo().
|
|
998
|
+
function statusBody(sub, percent, info) {
|
|
999
|
+
const plan = push.statusBar.planForSubscription(sub);
|
|
1000
|
+
return push.statusBar.composeStatusBody(plan, percent, info);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// statusInfo(data, extra) -> fact object for statusBody()
|
|
1004
|
+
//
|
|
1005
|
+
// Merges the stream event with this turn's context. Every field is
|
|
1006
|
+
// optional: a fact that is not known yet simply does not appear, so the
|
|
1007
|
+
// first update of a turn is just the bar and the message.
|
|
1008
|
+
function statusInfo(data, extra) {
|
|
1009
|
+
const o = extra || {};
|
|
1010
|
+
const modelName = model.label || model.id
|
|
1011
|
+
? ((model.provider ? model.provider + '/' : '') + (model.id || model.label || ''))
|
|
1012
|
+
: '';
|
|
1013
|
+
return {
|
|
1014
|
+
title: data && data.title ? String(data.title) : '',
|
|
1015
|
+
message: data && data.message ? String(data.message) : '',
|
|
1016
|
+
kind: o.kind || (data && data.kind) || '',
|
|
1017
|
+
current: data && data.current != null ? data.current : undefined,
|
|
1018
|
+
total: data && data.total != null ? data.total : undefined,
|
|
1019
|
+
// The activity row's parts. A tool the stream named wins over the last
|
|
1020
|
+
// tool this turn ran. The elapsed time stands on its own (it is useful
|
|
1021
|
+
// without a tool), so it does not depend on one.
|
|
1022
|
+
tool: o.tool || lastToolName || '',
|
|
1023
|
+
model: modelName,
|
|
1024
|
+
time: turnStartedAt ? Math.max(1, Math.round((Date.now() - turnStartedAt) / 1000)) + 's' : '',
|
|
1025
|
+
// The title is the chat's own name for a task, which reads as the
|
|
1026
|
+
// notification title it already is; the running usage is the fact
|
|
1027
|
+
// worth a row here.
|
|
1028
|
+
usage: pushUsageLabel()
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function sendChatPush(kind, options = {}) {
|
|
1033
|
+
if (!_pushSessionId) return;
|
|
1034
|
+
const preferenceKey = kind === 'ask_user' || kind === 'tool_authorization'
|
|
1035
|
+
? 'authorization'
|
|
1036
|
+
: kind === 'completion' || kind === 'error' || kind === 'progress'
|
|
1037
|
+
? 'status'
|
|
1038
|
+
: '';
|
|
1039
|
+
if (preferenceKey && notificationPrefs[preferenceKey] === false) return;
|
|
1040
|
+
const data = Object.assign({ kind, chatId, projectDir, url: chatUrl }, options.data || {});
|
|
1041
|
+
push.sendPushToSession(_pushSessionId, {
|
|
1042
|
+
title: options.title || ((chat && chat.title) || 'mouaif'),
|
|
1043
|
+
body: options.body || '',
|
|
1044
|
+
bodyFor: options.bodyFor,
|
|
1045
|
+
chatId,
|
|
1046
|
+
projectDir,
|
|
1047
|
+
tag: options.tag || `chat-${chatId}-${kind}`,
|
|
1048
|
+
data,
|
|
1049
|
+
actions: options.actions,
|
|
1050
|
+
requireInteraction: options.requireInteraction === true
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function attentionActions(kind, data) {
|
|
1055
|
+
const actions = [];
|
|
1056
|
+
if (notificationPrefs.quickActions !== false) {
|
|
1057
|
+
if (kind === 'tool_authorization') {
|
|
1058
|
+
actions.push({ action: 'allow-once', title: 'Allow once' });
|
|
1059
|
+
actions.push({ action: 'deny', title: 'Deny' });
|
|
1060
|
+
} else if (kind === 'ask_user' && data && data.multiSelect !== true && Array.isArray(data.options) && data.options.length === 2) {
|
|
1061
|
+
for (let i = 0; i < data.options.length; i++) {
|
|
1062
|
+
const option = data.options[i] || {};
|
|
1063
|
+
if (option.label && option.value) actions.push({ action: 'answer-' + i, title: String(option.label).slice(0, 40) });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (!actions.length) actions.push({ action: 'open', title: 'Open chat' });
|
|
1068
|
+
return actions;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// formatStreamError(err) — one-line, user-facing summary of a
|
|
1072
|
+
// failed turn. Persisted as a system message and shown as the
|
|
1073
|
+
// chat's error bubble, so keep it short: code + message + the
|
|
1074
|
+
// first line of any upstream detail (provider error bodies can
|
|
1075
|
+
// run to a full HTML page — useless in a chat bubble).
|
|
1076
|
+
function formatStreamError(err) {
|
|
1077
|
+
if (!err || typeof err !== 'object') return 'Request failed';
|
|
1078
|
+
const code = err.code ? err.code + ': ' : '';
|
|
1079
|
+
const msg = err.message || 'Request failed';
|
|
1080
|
+
let detail = '';
|
|
1081
|
+
if (typeof err.detail === 'string' && err.detail) {
|
|
1082
|
+
detail = ' — ' + err.detail.split('\n').map(l => l.trim()).filter(Boolean).slice(0, 1).join(' ').slice(0, 300);
|
|
1083
|
+
}
|
|
1084
|
+
return '⚠ ' + code + msg + detail;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// persistStreamError(err) — write the failure into the transcript
|
|
1088
|
+
// as a system message so it survives a reload and lands in the
|
|
1089
|
+
// chat history (errors belong in the chat, not just in a transient
|
|
1090
|
+
// status line). Kept best-effort: a read-only transcript must not
|
|
1091
|
+
// mask the original error.
|
|
1092
|
+
function persistStreamError(err) {
|
|
1093
|
+
try {
|
|
1094
|
+
messages.appendMessage(projectDir, chatId, { role: 'system', content: formatStreamError(err) });
|
|
1095
|
+
} catch { /* non-fatal */ }
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Keep the server-side chat run alive even if the browser tab or SSE
|
|
1099
|
+
// connection disappears. All stream writes are best-effort and the
|
|
1100
|
+
// transcript remains authoritative, so a reloaded client can catch up by
|
|
1101
|
+
// polling persisted messages instead of causing an upstream abort with
|
|
1102
|
+
// "client disconnected".
|
|
1103
|
+
|
|
1104
|
+
let result;
|
|
1105
|
+
try {
|
|
1106
|
+
result = await ai.streamChat({
|
|
1107
|
+
model,
|
|
1108
|
+
messages: upstreamMessages,
|
|
1109
|
+
projectDir,
|
|
1110
|
+
chatId, // Pass chatId for authorization gate
|
|
1111
|
+
shellEnabled,
|
|
1112
|
+
fileToolsEnabled,
|
|
1113
|
+
appSettings,
|
|
1114
|
+
lifecycle,
|
|
1115
|
+
promptSize: resolvedProfileId,
|
|
1116
|
+
|
|
1117
|
+
toolOutput: resolvedToolOutput,
|
|
1118
|
+
thinkingLevel: thinkingLevel || chat.thinkingLevel || '',
|
|
1119
|
+
maxOutputTokens: maxOutputTokens || chat.maxOutputTokens || '',
|
|
1120
|
+
signal: runController.signal,
|
|
1121
|
+
// Per-chat tool filter (decisions: chat.tools). null/undefined
|
|
1122
|
+
// means "all tools available to the project"; an array (even an
|
|
1123
|
+
// empty one) means "restrict to exactly these tool names". The
|
|
1124
|
+
// legacy fields above stay so existing API clients keep working.
|
|
1125
|
+
// Chat tool filter wins; otherwise all project tools are offered.
|
|
1126
|
+
// `effectiveChat` folds in the prompt's preset tools (if any) the
|
|
1127
|
+
// same way it feeds agent-files above.
|
|
1128
|
+
enabledTools: Array.isArray(effectiveChat.tools) ? effectiveChat.tools : null,
|
|
1129
|
+
chat,
|
|
1130
|
+
// Per-round usage snapshot (one per upstream API call, including
|
|
1131
|
+
// tool rounds). Stashed so `assistant_turn_end` can attach cost
|
|
1132
|
+
// to the intermediate segment it persists.
|
|
1133
|
+
onRoundUsage: (roundUsage) => { pendingRoundUsage = roundUsage; accumulateRoundUsage(roundUsage); },
|
|
1134
|
+
onEvent: (name, data) => {
|
|
1135
|
+
if (name === 'message' && typeof data.delta === 'string') {
|
|
1136
|
+
if (!streamStartedAt) streamStartedAt = Date.now();
|
|
1137
|
+
if (!turnStartedAt) turnStartedAt = streamStartedAt;
|
|
1138
|
+
assistantContent += data.delta;
|
|
1139
|
+
try { res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n'); } catch { /* socket closed */ }
|
|
1140
|
+
return;
|
|
1141
|
+
} else if (name === 'reasoning' && typeof data.delta === 'string') {
|
|
1142
|
+
if (!streamStartedAt) streamStartedAt = Date.now();
|
|
1143
|
+
if (!turnStartedAt) turnStartedAt = streamStartedAt;
|
|
1144
|
+
assistantReasoning += data.delta;
|
|
1145
|
+
try { res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n'); } catch { /* socket closed */ }
|
|
1146
|
+
return;
|
|
1147
|
+
} else if (name === 'assistant_turn_end') {
|
|
1148
|
+
// A tool round is starting: fold the window that just ended into
|
|
1149
|
+
// the accumulator and clear the start marker. The next assistant
|
|
1150
|
+
// delta re-arms streamStartedAt.
|
|
1151
|
+
if (streamStartedAt) { streamingMs += Date.now() - streamStartedAt; streamStartedAt = 0; }
|
|
1152
|
+
// Persist text produced before a tool call at its real transcript
|
|
1153
|
+
// position, then start a fresh segment for the post-tool response.
|
|
1154
|
+
// Attach the round's usage/cost so this segment shows its own
|
|
1155
|
+
// cost line in the chat UI.
|
|
1156
|
+
// The same numbers ride the SSE event so the live bubble can
|
|
1157
|
+
// render the round's real cost without waiting for reconciliation.
|
|
1158
|
+
let segmentCost = null;
|
|
1159
|
+
let segmentUsage;
|
|
1160
|
+
if (assistantContent.trim() || assistantReasoning.trim()) {
|
|
1161
|
+
try {
|
|
1162
|
+
segmentCost = computeSegmentCost(pendingRoundUsage);
|
|
1163
|
+
if (segmentCost && segmentCost.known && typeof segmentCost.total === 'number') {
|
|
1164
|
+
persistedSegmentCost += segmentCost.total;
|
|
1165
|
+
}
|
|
1166
|
+
segmentUsage = pendingRoundUsage
|
|
1167
|
+
? {
|
|
1168
|
+
promptTokens: pendingRoundUsage.promptTokens,
|
|
1169
|
+
completionTokens: pendingRoundUsage.completionTokens,
|
|
1170
|
+
cacheReadTokens: pendingRoundUsage.cacheReadTokens || 0,
|
|
1171
|
+
cacheCreationTokens: pendingRoundUsage.cacheCreationTokens || 0
|
|
1172
|
+
}
|
|
1173
|
+
: undefined;
|
|
1174
|
+
assistantMsg = messages.appendMessage(projectDir, chatId, {
|
|
1175
|
+
role: 'assistant', content: assistantContent, reasoning: assistantReasoning, modelId: model.id,
|
|
1176
|
+
usage: segmentUsage,
|
|
1177
|
+
cost: segmentCost || undefined
|
|
1178
|
+
});
|
|
1179
|
+
if (traceStream && assistantMsg) {
|
|
1180
|
+
const event = trace.eventForMessage(assistantMsg);
|
|
1181
|
+
trace.write(traceStream, event.type, event.payload);
|
|
1182
|
+
}
|
|
1183
|
+
} catch { /* non-fatal */ }
|
|
1184
|
+
}
|
|
1185
|
+
// The snapshot is consumed whether or not this segment had text.
|
|
1186
|
+
// Leaving it set on a no-text round would leak round N's tokens
|
|
1187
|
+
// into round N+1's segment (double-counted cost in the totals).
|
|
1188
|
+
pendingRoundUsage = null;
|
|
1189
|
+
assistantContent = '';
|
|
1190
|
+
assistantReasoning = '';
|
|
1191
|
+
// Emit the enriched frame (cost + usage attached) and skip the
|
|
1192
|
+
// generic emit below so the client never sees a cost-less copy.
|
|
1193
|
+
emit(name, Object.assign({}, data, {
|
|
1194
|
+
usage: segmentUsage,
|
|
1195
|
+
cost: segmentCost || undefined,
|
|
1196
|
+
modelId: model.id
|
|
1197
|
+
}));
|
|
1198
|
+
return;
|
|
1199
|
+
} else if (name === 'tool_call') {
|
|
1200
|
+
// Remember the tool for the status block's activity row: it names
|
|
1201
|
+
// what the model is doing, which is more useful in a progress
|
|
1202
|
+
// notification than a bare percentage.
|
|
1203
|
+
if (data && data.name) lastToolName = String(data.name);
|
|
1204
|
+
if (!turnStartedAt) turnStartedAt = Date.now();
|
|
1205
|
+
try {
|
|
1206
|
+
messages.appendMessage(projectDir, chatId, {
|
|
1207
|
+
role: 'tool', phase: 'call', toolCallId: data.id || '', name: data.name || '',
|
|
1208
|
+
args: data.args || {}, content: JSON.stringify(data.args || {})
|
|
1209
|
+
});
|
|
1210
|
+
} catch { /* non-fatal */ }
|
|
1211
|
+
} else if (name === 'tool_result') {
|
|
1212
|
+
try {
|
|
1213
|
+
messages.appendMessage(projectDir, chatId, {
|
|
1214
|
+
role: 'tool', phase: 'result', toolCallId: data.id || '', name: data.name || '',
|
|
1215
|
+
ok: !!data.ok, content: JSON.stringify(data.result || {})
|
|
1216
|
+
});
|
|
1217
|
+
} catch { /* non-fatal */ }
|
|
1218
|
+
} else if (name === 'authorization_required') {
|
|
1219
|
+
const notificationData = {
|
|
1220
|
+
callId: data && data.callId,
|
|
1221
|
+
tool: data && data.tool
|
|
1222
|
+
};
|
|
1223
|
+
sendChatPush('tool_authorization', {
|
|
1224
|
+
title: 'Authorization needed',
|
|
1225
|
+
body: (data && data.tool ? data.tool : 'A tool') + ' is waiting for approval.',
|
|
1226
|
+
tag: 'chat-' + chatId + '-attention',
|
|
1227
|
+
data: notificationData,
|
|
1228
|
+
actions: attentionActions('tool_authorization', data),
|
|
1229
|
+
requireInteraction: true
|
|
1230
|
+
});
|
|
1231
|
+
} else if (name === 'ask_user_required') {
|
|
1232
|
+
const quickOptions = data && data.multiSelect !== true && Array.isArray(data.options) && data.options.length === 2
|
|
1233
|
+
? data.options.slice(0, 2).map((option) => ({ label: String(option.label || '').slice(0, 40), value: String(option.value || '').slice(0, 120) }))
|
|
1234
|
+
: [];
|
|
1235
|
+
const notificationData = {
|
|
1236
|
+
callId: data && data.callId,
|
|
1237
|
+
tool: 'ask_user',
|
|
1238
|
+
options: quickOptions
|
|
1239
|
+
};
|
|
1240
|
+
sendChatPush('ask_user', {
|
|
1241
|
+
title: 'The chat needs your answer',
|
|
1242
|
+
body: data && data.question ? String(data.question).slice(0, 240) : 'Open the chat to answer.',
|
|
1243
|
+
tag: 'chat-' + chatId + '-attention',
|
|
1244
|
+
data: notificationData,
|
|
1245
|
+
actions: attentionActions('ask_user', data),
|
|
1246
|
+
requireInteraction: true
|
|
1247
|
+
});
|
|
1248
|
+
} else if (name === 'progress_update') {
|
|
1249
|
+
// Updatable per-chat push notification for real-time progress.
|
|
1250
|
+
// Uses a stable tag so each new progress_update replaces the
|
|
1251
|
+
// previous OS notification for this chat (no notification spam).
|
|
1252
|
+
const pctNum = data.current != null && data.total != null
|
|
1253
|
+
? Math.round((Number(data.current) / Math.max(1, Number(data.total))) * 100)
|
|
1254
|
+
: null;
|
|
1255
|
+
// Everything the status shows rides in the BODY, under the bar, as a
|
|
1256
|
+
// set of optional facts (src/statusBar.js detailLines()): the message,
|
|
1257
|
+
// the position in the work, the turn usage, the elapsed time, the
|
|
1258
|
+
// tool, and the model. The title stays just the chat name — it is the
|
|
1259
|
+
// slot the OS clips first, and a wider device shows more detail rows
|
|
1260
|
+
// without the title changing. A task update is one row of facts, not a
|
|
1261
|
+
// title row plus a separate task row.
|
|
1262
|
+
sendChatPush('progress', {
|
|
1263
|
+
title: (chat && chat.title) || 'mouaif',
|
|
1264
|
+
bodyFor: (sub) => statusBody(sub, pctNum, statusInfo(data)),
|
|
1265
|
+
tag: statusPushTag
|
|
1266
|
+
});
|
|
1267
|
+
} else if (name === 'done') {
|
|
1268
|
+
sendChatPush('completion', {
|
|
1269
|
+
title: (chat && chat.title) || 'mouaif',
|
|
1270
|
+
bodyFor: (sub) => statusBody(sub, 100, statusInfo({
|
|
1271
|
+
kind: 'complete',
|
|
1272
|
+
message: 'Response complete'
|
|
1273
|
+
})),
|
|
1274
|
+
tag: statusPushTag
|
|
1275
|
+
});
|
|
1276
|
+
// Compute the enrichment once. `cost.known` is true when at
|
|
1277
|
+
// least one of the four pricing layers (model, app, builtin)
|
|
1278
|
+
// had a non-empty entry for this model id. We always emit
|
|
1279
|
+
// the enriched event so the UI can render `--` cleanly; the
|
|
1280
|
+
// `known: false` flag tells it not to show a dollar sign.
|
|
1281
|
+
let enriched = data;
|
|
1282
|
+
delegatedCost = data && typeof data.delegatedCost === 'number' && isFinite(data.delegatedCost) && data.delegatedCost >= 0
|
|
1283
|
+
? data.delegatedCost
|
|
1284
|
+
: null;
|
|
1285
|
+
try {
|
|
1286
|
+
const app = settings.getApp();
|
|
1287
|
+
const providerCost = data && typeof data.providerCost === 'number' && isFinite(data.providerCost) && data.providerCost >= 0
|
|
1288
|
+
? data.providerCost
|
|
1289
|
+
: null;
|
|
1290
|
+
const cost = providerCost == null
|
|
1291
|
+
? usage.computeCost({ model, usage: data && data.usage, app })
|
|
1292
|
+
: { known: true, input: 0, output: 0, total: providerCost, currency: 'USD' };
|
|
1293
|
+
// Fold the still-open window (first delta → done) into the
|
|
1294
|
+
// accumulated tool-round windows. Falls back to the full
|
|
1295
|
+
// elapsed time when no message delta ever armed the start.
|
|
1296
|
+
const finalStreamingMs = streamingMs + (streamStartedAt ? Date.now() - streamStartedAt : 0);
|
|
1297
|
+
enriched = Object.assign({}, data, {
|
|
1298
|
+
cost: {
|
|
1299
|
+
known: cost.known,
|
|
1300
|
+
input: cost.input,
|
|
1301
|
+
output: cost.output,
|
|
1302
|
+
total: cost.total,
|
|
1303
|
+
currency: cost.currency
|
|
1304
|
+
},
|
|
1305
|
+
streamingMs: finalStreamingMs,
|
|
1306
|
+
modelId: model.id
|
|
1307
|
+
});
|
|
1308
|
+
} catch { /* keep data as-is on any pricing resolution error */ }
|
|
1309
|
+
// Cost and usage on the final row are REMAINDER values, not the
|
|
1310
|
+
// turn aggregate:
|
|
1311
|
+
// - Intermediate segments (assistant_turn_end) already carry
|
|
1312
|
+
// their own round's usage + cost.
|
|
1313
|
+
// - `turnCost` accumulated EVERY round's real cost — including
|
|
1314
|
+
// tool rounds that produced no text and would otherwise
|
|
1315
|
+
// vanish from the chat total.
|
|
1316
|
+
// - Charging the full aggregate here would double-bill the
|
|
1317
|
+
// segment completion tokens; charging only this round's
|
|
1318
|
+
// snapshot would drop the no-text rounds entirely.
|
|
1319
|
+
// So: final cost = parent turnCost + delegatedCost − persistedSegmentCost,
|
|
1320
|
+
// and the usage block shows this round's own footprint (the aggregate
|
|
1321
|
+
// stays on the SSE event's usage block for the live "Context"
|
|
1322
|
+
// display). The SAME remainder rides the SSE `done` cost so the
|
|
1323
|
+
// in-flight chat total (segments + live final) matches the
|
|
1324
|
+
// persisted total exactly — no jump on reload.
|
|
1325
|
+
const finalRoundUsage = pendingRoundUsage;
|
|
1326
|
+
pendingRoundUsage = null;
|
|
1327
|
+
let remainderCost = enriched.cost;
|
|
1328
|
+
if (turnCostKnown && delegatedCost != null && enriched.cost && enriched.cost.known) {
|
|
1329
|
+
const remaining = Math.max(0, turnCost + delegatedCost - persistedSegmentCost);
|
|
1330
|
+
remainderCost = {
|
|
1331
|
+
known: true,
|
|
1332
|
+
input: 0,
|
|
1333
|
+
output: 0,
|
|
1334
|
+
total: remaining,
|
|
1335
|
+
currency: (enriched.cost && enriched.cost.currency) || 'USD'
|
|
1336
|
+
};
|
|
1337
|
+
enriched = Object.assign({}, enriched, { cost: remainderCost });
|
|
1338
|
+
}
|
|
1339
|
+
lastEnrichment = enriched;
|
|
1340
|
+
// Persist the assistant message so a chat that is later
|
|
1341
|
+
// reopened renders the same numbers (decision §14 — the usage
|
|
1342
|
+
// block rides the message).
|
|
1343
|
+
if (assistantContent.trim() || assistantReasoning.trim()) {
|
|
1344
|
+
try {
|
|
1345
|
+
const persistUsage = finalRoundUsage
|
|
1346
|
+
? {
|
|
1347
|
+
promptTokens: finalRoundUsage.promptTokens,
|
|
1348
|
+
completionTokens: finalRoundUsage.completionTokens,
|
|
1349
|
+
cacheReadTokens: finalRoundUsage.cacheReadTokens || 0,
|
|
1350
|
+
cacheCreationTokens: finalRoundUsage.cacheCreationTokens || 0
|
|
1351
|
+
}
|
|
1352
|
+
: (data && data.usage);
|
|
1353
|
+
assistantMsg = messages.appendMessage(projectDir, chatId, {
|
|
1354
|
+
role: 'assistant',
|
|
1355
|
+
content: assistantContent,
|
|
1356
|
+
reasoning: assistantReasoning,
|
|
1357
|
+
usage: persistUsage,
|
|
1358
|
+
cost: remainderCost,
|
|
1359
|
+
streamingMs: enriched.streamingMs,
|
|
1360
|
+
modelId: enriched.modelId
|
|
1361
|
+
});
|
|
1362
|
+
} catch { /* non-fatal */ }
|
|
1363
|
+
}
|
|
1364
|
+
if (traceStream && assistantMsg) {
|
|
1365
|
+
const event = trace.eventForMessage(assistantMsg);
|
|
1366
|
+
trace.write(traceStream, event.type, event.payload);
|
|
1367
|
+
}
|
|
1368
|
+
emit('done', enriched);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
emit(name, data);
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
} catch (streamErr) {
|
|
1375
|
+
// A throw out of the streaming layer must still clear the running
|
|
1376
|
+
// marker or the chat would look busy forever after a reload.
|
|
1377
|
+
runningChats.delete(runKey);
|
|
1378
|
+
runningChatCancels.delete(runKey);
|
|
1379
|
+
// The turn is over (either way), so its status facts stop applying.
|
|
1380
|
+
lastToolName = '';
|
|
1381
|
+
turnStartedAt = 0;
|
|
1382
|
+
if (traceStream) trace.close(traceStream);
|
|
1383
|
+
const errPayload = { code: 'EINTERNAL', message: streamErr && streamErr.message ? streamErr.message : 'stream failed' };
|
|
1384
|
+
persistStreamError(errPayload);
|
|
1385
|
+
try { emit('error', errPayload); } catch { /* socket closed */ }
|
|
1386
|
+
liveChat.finishLiveChat(runKey);
|
|
1387
|
+
sendChatPush('error', {
|
|
1388
|
+
title: (chat && chat.title) || 'mouaif',
|
|
1389
|
+
// An error is a fact set too: the message, then whatever usage and
|
|
1390
|
+
// context the turn had reached before it failed.
|
|
1391
|
+
bodyFor: (sub) => statusBody(sub, null, statusInfo({ kind: 'error', message: 'Error: ' + (errPayload.message || 'stream failed') })),
|
|
1392
|
+
tag: statusPushTag
|
|
1393
|
+
});
|
|
1394
|
+
res.end();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (!result.ok) {
|
|
1399
|
+
// Always surface the failure — even when the stream produced
|
|
1400
|
+
// partial content before dying. The old guard
|
|
1401
|
+
// (`!assistantContent && !assistantReasoning`) silently dropped
|
|
1402
|
+
// mid-turn failures: the client saw the socket close with no
|
|
1403
|
+
// `done` and no `error`, leaving the chat stuck on "streaming…"
|
|
1404
|
+
// with zero explanation. Persist any partial output first, then
|
|
1405
|
+
// the error itself, so the transcript shows exactly what the
|
|
1406
|
+
// model produced before the failure.
|
|
1407
|
+
const errPayload = Object.assign({ code: result.error.code || 'EUPSTREAM' }, result.error);
|
|
1408
|
+
if (assistantContent.trim() || assistantReasoning.trim()) {
|
|
1409
|
+
try {
|
|
1410
|
+
messages.appendMessage(projectDir, chatId, {
|
|
1411
|
+
role: 'assistant',
|
|
1412
|
+
content: assistantContent,
|
|
1413
|
+
reasoning: assistantReasoning,
|
|
1414
|
+
modelId: model.id
|
|
1415
|
+
});
|
|
1416
|
+
} catch { /* non-fatal */ }
|
|
1417
|
+
}
|
|
1418
|
+
persistStreamError(errPayload);
|
|
1419
|
+
emit('error', errPayload);
|
|
1420
|
+
sendChatPush('error', {
|
|
1421
|
+
title: (chat && chat.title) || 'mouaif',
|
|
1422
|
+
bodyFor: (sub) => statusBody(sub, null, statusInfo({ kind: 'error', message: 'Error: ' + (errPayload.message || 'upstream error') })),
|
|
1423
|
+
tag: statusPushTag
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
if (traceStream) trace.close(traceStream);
|
|
1427
|
+
runningChats.delete(runKey);
|
|
1428
|
+
runningChatCancels.delete(runKey);
|
|
1429
|
+
// The turn is over, so its status facts stop applying to the next one.
|
|
1430
|
+
lastToolName = '';
|
|
1431
|
+
turnStartedAt = 0;
|
|
1432
|
+
liveChat.finishLiveChat(runKey);
|
|
1433
|
+
res.end();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
module.exports = { handleChats, handleChatStream };
|