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,306 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// App access authentication REST handlers. Extracted from the original
|
|
4
|
+
// single-file http-server.js. Shared helpers live in src/server-shared.js.
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
sendJSON,
|
|
8
|
+
readJsonOr400,
|
|
9
|
+
parseCookies,
|
|
10
|
+
accessCookie,
|
|
11
|
+
accessRequestOrigin,
|
|
12
|
+
checkAccessAttempts,
|
|
13
|
+
recordAccessFailure,
|
|
14
|
+
clearAccessFailures,
|
|
15
|
+
publicAccessStatus,
|
|
16
|
+
ACCESS_COOKIE,
|
|
17
|
+
accessAuth,
|
|
18
|
+
qr,
|
|
19
|
+
push,
|
|
20
|
+
settings,
|
|
21
|
+
safeDecode
|
|
22
|
+
} = require('./server-shared.js');
|
|
23
|
+
const { resolveNotificationPrefs } = require('./notifications.js');
|
|
24
|
+
|
|
25
|
+
// notifyLogin(username, req) — best-effort push that a new browser signed
|
|
26
|
+
// in. Gated by the `notifications.login` preference (on by default) so it
|
|
27
|
+
// can be silenced from Settings. Broadcast to every subscribed endpoint
|
|
28
|
+
// (not the just-issued session, whose subscription list is empty until the
|
|
29
|
+
// page rebinds) so the alert reaches the user's other already-signed-in
|
|
30
|
+
// devices — the whole point of a sign-in notice.
|
|
31
|
+
function notifyLogin(username, req) {
|
|
32
|
+
try {
|
|
33
|
+
let prefs = {};
|
|
34
|
+
try { prefs = resolveNotificationPrefs((settings.getApp() || {}).notifications); } catch { /* defaults apply */ }
|
|
35
|
+
if (prefs.login !== true) return;
|
|
36
|
+
const agent = String((req && req.headers && req.headers['user-agent']) || '');
|
|
37
|
+
const label = /iphone|ipad|ipod/i.test(agent) ? 'iPhone or iPad'
|
|
38
|
+
: /android/i.test(agent) ? 'Android device'
|
|
39
|
+
: /mobile/i.test(agent) ? 'mobile browser'
|
|
40
|
+
: /macintosh|mac os/i.test(agent) ? 'Mac'
|
|
41
|
+
: /windows/i.test(agent) ? 'Windows'
|
|
42
|
+
: /linux/i.test(agent) ? 'Linux'
|
|
43
|
+
: 'this browser';
|
|
44
|
+
push.sendPushToAll({
|
|
45
|
+
title: 'mouaif sign-in',
|
|
46
|
+
body: `New sign-in as ${username || 'user'} from ${label}.`,
|
|
47
|
+
tag: 'mouaif-login',
|
|
48
|
+
data: { kind: 'login', url: '/#/projects' },
|
|
49
|
+
actions: [{ action: 'open', title: 'Open mouaif' }]
|
|
50
|
+
});
|
|
51
|
+
} catch { /* a notification failure must never block sign-in */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// notifyAccessChange(enabled) — best-effort push that app access protection
|
|
55
|
+
// changed. A security-relevant event, so it reuses the `login` channel
|
|
56
|
+
// ("sign in to / access of this server") rather than adding a preference;
|
|
57
|
+
// it broadcasts to every subscribed device, including the one that acted,
|
|
58
|
+
// so the state change is visible even if that phone is put down.
|
|
59
|
+
function notifyAccessChange(enabled) {
|
|
60
|
+
try {
|
|
61
|
+
let prefs = {};
|
|
62
|
+
try { prefs = resolveNotificationPrefs((settings.getApp() || {}).notifications); } catch { /* defaults apply */ }
|
|
63
|
+
if (prefs.login !== true) return;
|
|
64
|
+
push.sendPushToAll({
|
|
65
|
+
title: enabled ? 'mouaif access enabled' : 'mouaif access disabled',
|
|
66
|
+
body: enabled
|
|
67
|
+
? 'Password and passkey protection is on again.'
|
|
68
|
+
: 'App access is off. Anyone who can reach this server can open it.',
|
|
69
|
+
tag: 'mouaif-access',
|
|
70
|
+
data: { kind: 'login', url: '/#/settings/access' },
|
|
71
|
+
actions: [{ action: 'open', title: 'Open settings' }]
|
|
72
|
+
});
|
|
73
|
+
} catch { /* a notification failure must never change the access state */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async function handleAccess(req, res, parsed, serverConfig) {
|
|
78
|
+
const urlPath = parsed.pathname;
|
|
79
|
+
const method = req.method;
|
|
80
|
+
let servedOrigin;
|
|
81
|
+
try { servedOrigin = accessRequestOrigin(req, serverConfig.publicOrigin); }
|
|
82
|
+
catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code || 'EORIGIN' }); }
|
|
83
|
+
const secure = servedOrigin.startsWith('https://');
|
|
84
|
+
const accessToken = parseCookies(req.headers.cookie)[ACCESS_COOKIE] || '';
|
|
85
|
+
const activeSession = accessAuth.session(accessToken);
|
|
86
|
+
|
|
87
|
+
if (urlPath === '/api/access/status' && method === 'GET') {
|
|
88
|
+
const status = publicAccessStatus(serverConfig.authEnabled);
|
|
89
|
+
return sendJSON(res, 200, { ...status, session: !!activeSession, authenticated: !status.enabled || !!activeSession });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (urlPath === '/api/access/login' && method === 'POST') {
|
|
93
|
+
const body = await readJsonOr400(req, res);
|
|
94
|
+
if (!body) return;
|
|
95
|
+
if (!accessAuth.configured()) return sendJSON(res, 409, { error: 'Access is not configured yet', code: 'ENOTCONFIGURED' });
|
|
96
|
+
if (!checkAccessAttempts(req)) return sendJSON(res, 429, { error: 'Too many sign-in attempts; wait a minute', code: 'ERATE_LIMIT' });
|
|
97
|
+
if (!accessAuth.verifyPassword(body.username, body.password)) {
|
|
98
|
+
recordAccessFailure(req);
|
|
99
|
+
return sendJSON(res, 401, { error: 'User or password is incorrect', code: 'EBADCREDENTIALS' });
|
|
100
|
+
}
|
|
101
|
+
clearAccessFailures(req);
|
|
102
|
+
const issued = accessAuth.issueSession();
|
|
103
|
+
res.setHeader('Set-Cookie', accessCookie(issued.token, secure, accessAuth.SESSION_TTL_MS / 1000));
|
|
104
|
+
notifyLogin(accessAuth.user().username, req);
|
|
105
|
+
return sendJSON(res, 200, { ok: true, user: accessAuth.user().username });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if (urlPath === '/api/access/logout' && method === 'POST') {
|
|
110
|
+
accessAuth.revokeSession(accessToken);
|
|
111
|
+
res.setHeader('Set-Cookie', accessCookie('', secure, 0));
|
|
112
|
+
return sendJSON(res, 200, { ok: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- Turn app access off / back on ------------------------------------
|
|
116
|
+
// Disabling is signed-in-only and requires a fresh one-time code, so a
|
|
117
|
+
// request that only holds a cookie (a script, a stale tab) cannot remove
|
|
118
|
+
// the login wall by itself. The code is minted here — unlike setup codes
|
|
119
|
+
// it is never printed to stdout or served to an unauthenticated caller.
|
|
120
|
+
if (urlPath === '/api/access/disable/code' && method === 'POST') {
|
|
121
|
+
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
122
|
+
const issued = accessAuth.createDisableCode();
|
|
123
|
+
return sendJSON(res, 200, { code: issued.code, expiresAt: issued.expiresAt, ttlMs: accessAuth.DISABLE_TTL_MS });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The QR is an image so a phone can open it with the system camera. It
|
|
127
|
+
// encodes the confirmation page, which is what makes the code itself
|
|
128
|
+
// unnecessary to type on the scanning device.
|
|
129
|
+
if (urlPath === '/api/access/disable/qr' && method === 'GET') {
|
|
130
|
+
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
131
|
+
const code = typeof parsed.query.code === 'string' ? parsed.query.code : '';
|
|
132
|
+
if (!accessAuth.disableCodeValid(code)) return sendJSON(res, 404, { error: 'Disable code is missing or expired', code: 'EDISABLE_CODE' });
|
|
133
|
+
const confirmUrl = servedOrigin + '/#/disable-access?code=' + encodeURIComponent(accessAuth.normalizeCode(code));
|
|
134
|
+
const svg = qr.svg(confirmUrl);
|
|
135
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
136
|
+
res.end(svg);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (urlPath === '/api/access/disable' && method === 'POST') {
|
|
141
|
+
const body = await readJsonOr400(req, res);
|
|
142
|
+
if (!body) return;
|
|
143
|
+
// An invalid code is a failed guess, so it counts against the same
|
|
144
|
+
// per-address limit as a wrong password. The confirmation page is
|
|
145
|
+
// deliberately reachable without a session, so nothing else slows down
|
|
146
|
+
// a caller trying codes in a loop.
|
|
147
|
+
if (!checkAccessAttempts(req)) return sendJSON(res, 429, { error: 'Too many attempts; wait a minute', code: 'ERATE_LIMIT' });
|
|
148
|
+
if (!accessAuth.disableCodeValid(body.code)) {
|
|
149
|
+
recordAccessFailure(req);
|
|
150
|
+
return sendJSON(res, 401, { error: 'That code is invalid or expired', code: 'EDISABLE_CODE' });
|
|
151
|
+
}
|
|
152
|
+
if (!accessAuth.consumeDisableCode(body.code)) {
|
|
153
|
+
recordAccessFailure(req);
|
|
154
|
+
return sendJSON(res, 409, { error: 'That code was already used or expired', code: 'EDISABLE_CODE' });
|
|
155
|
+
}
|
|
156
|
+
clearAccessFailures(req);
|
|
157
|
+
// Consume every other outstanding invitation and turn the gate off.
|
|
158
|
+
// Sessions are intentionally kept: with the gate off they authorize
|
|
159
|
+
// nothing, and holding on to them lets the device that disabled access
|
|
160
|
+
// re-enable it without having to prove the password again.
|
|
161
|
+
settings.getDb().prepare('DELETE FROM access_disable_codes').run();
|
|
162
|
+
settings.getDb().prepare('DELETE FROM access_setup_codes').run();
|
|
163
|
+
accessAuth.setEnabled(false);
|
|
164
|
+
notifyAccessChange(false);
|
|
165
|
+
return sendJSON(res, 200, { ok: true, enabled: false, configured: accessAuth.configured() });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Turning protection back on is safe to do from an unauthenticated screen,
|
|
169
|
+
// so it accepts any of the store's proofs: a live session, the account
|
|
170
|
+
// password, or a CLI setup code. That keeps recovery possible even after
|
|
171
|
+
// session cookies have expired while access was off.
|
|
172
|
+
if (urlPath === '/api/access/enable' && method === 'POST') {
|
|
173
|
+
const body = await readJsonOr400(req, res);
|
|
174
|
+
if (!body) return;
|
|
175
|
+
const account = accessAuth.user();
|
|
176
|
+
if (!account) return sendJSON(res, 409, { error: 'Create the access user before enabling protection', code: 'ENOTCONFIGURED' });
|
|
177
|
+
const bySession = !!activeSession;
|
|
178
|
+
const byPassword = !bySession && account
|
|
179
|
+
&& accessAuth.verifyPassword(body.username || account.username, body.password);
|
|
180
|
+
const bySetupCode = !bySession && !byPassword && accessAuth.setupCodeValid(body.code);
|
|
181
|
+
if (!bySession && !byPassword && !bySetupCode) {
|
|
182
|
+
return sendJSON(res, 401, { error: 'Sign in, or enter the account password, to enable access', code: 'EAUTH_REQUIRED' });
|
|
183
|
+
}
|
|
184
|
+
if (bySetupCode && !accessAuth.consumeSetupCode(body.code)) {
|
|
185
|
+
return sendJSON(res, 409, { error: 'Setup code was already used or expired', code: 'ESETUP_CODE' });
|
|
186
|
+
}
|
|
187
|
+
accessAuth.setEnabled(true);
|
|
188
|
+
notifyAccessChange(true);
|
|
189
|
+
return sendJSON(res, 200, { ok: true, enabled: true, configured: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Password change from an authenticated browser session. Requires a real
|
|
193
|
+
// cookie session (a Basic-auth API client cannot ask to rotate the
|
|
194
|
+
// password through this endpoint). The current session survives the
|
|
195
|
+
// rotation; every other browser is signed out.
|
|
196
|
+
if (urlPath === '/api/access/password' && method === 'POST') {
|
|
197
|
+
const body = await readJsonOr400(req, res);
|
|
198
|
+
if (!body) return;
|
|
199
|
+
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
200
|
+
try {
|
|
201
|
+
const changed = accessAuth.changePassword(activeSession.username, body.currentPassword, body.newPassword, accessToken);
|
|
202
|
+
res.setHeader('Set-Cookie', accessCookie(accessToken, secure, accessAuth.SESSION_TTL_MS / 1000));
|
|
203
|
+
return sendJSON(res, 200, { ok: true, user: changed.user && changed.user.username, expiresAt: changed.expiresAt });
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return sendJSON(res, e.code === 'EBADCREDENTIALS' ? 401 : 400, { error: e.message, code: e.code || 'EBADINPUT' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (urlPath === '/api/access/setup/verify' && method === 'POST') {
|
|
210
|
+
const body = await readJsonOr400(req, res);
|
|
211
|
+
if (!body) return;
|
|
212
|
+
const valid = accessAuth.setupCodeValid(body.code);
|
|
213
|
+
return sendJSON(res, valid ? 200 : 401, { valid });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (urlPath === '/api/access/setup' && method === 'POST') {
|
|
217
|
+
const body = await readJsonOr400(req, res);
|
|
218
|
+
if (!body) return;
|
|
219
|
+
const setupAuthorized = accessAuth.setupCodeValid(body.code);
|
|
220
|
+
if (!activeSession && !setupAuthorized) return sendJSON(res, 401, { error: 'A valid one-time setup code or signed-in session is required', code: 'ESETUP_CODE' });
|
|
221
|
+
try {
|
|
222
|
+
accessAuth.setPassword(body.username, body.password);
|
|
223
|
+
if (setupAuthorized && !accessAuth.consumeSetupCode(body.code)) return sendJSON(res, 409, { error: 'Setup code was already used or expired', code: 'ESETUP_CODE' });
|
|
224
|
+
const issued = accessAuth.issueSession();
|
|
225
|
+
res.setHeader('Set-Cookie', accessCookie(issued.token, secure, accessAuth.SESSION_TTL_MS / 1000));
|
|
226
|
+
notifyLogin(accessAuth.user() && accessAuth.user().username, req);
|
|
227
|
+
return sendJSON(res, 200, { ok: true, ...publicAccessStatus(serverConfig.authEnabled), authenticated: true });
|
|
228
|
+
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return sendJSON(res, 400, { error: e.message, code: e.code || 'EBADINPUT' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (urlPath === '/api/access/passkeys/register/options' && method === 'POST') {
|
|
235
|
+
const body = await readJsonOr400(req, res);
|
|
236
|
+
if (!body) return;
|
|
237
|
+
if (!accessAuth.configured()) return sendJSON(res, 409, { error: 'Create the access user before adding a passkey', code: 'ENOTCONFIGURED' });
|
|
238
|
+
const setupAuthorized = accessAuth.setupCodeValid(body.code);
|
|
239
|
+
if (!activeSession && !setupAuthorized) return sendJSON(res, 401, { error: 'Sign in or provide a valid setup code first', code: 'EAUTH_REQUIRED' });
|
|
240
|
+
const username = activeSession ? activeSession.username : String(body.username || accessAuth.user()?.username || '').trim();
|
|
241
|
+
if (!username) return sendJSON(res, 400, { error: 'username is required' });
|
|
242
|
+
try {
|
|
243
|
+
return sendJSON(res, 200, accessAuth.beginRegistration({
|
|
244
|
+
origin: servedOrigin, username,
|
|
245
|
+
authorizedBy: setupAuthorized ? accessAuth.normalizeCode(body.code) : 'session'
|
|
246
|
+
}));
|
|
247
|
+
} catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code || 'EWEBAUTHN' }); }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (urlPath === '/api/access/passkeys/register/verify' && method === 'POST') {
|
|
251
|
+
const body = await readJsonOr400(req, res);
|
|
252
|
+
if (!body) return;
|
|
253
|
+
try {
|
|
254
|
+
const result = accessAuth.finishRegistration(body);
|
|
255
|
+
return sendJSON(res, 200, { ok: true, passkeys: result.passkeys });
|
|
256
|
+
} catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code || 'EWEBAUTHN' }); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (urlPath === '/api/access/passkeys/login/options' && method === 'POST') {
|
|
260
|
+
if (!checkAccessAttempts(req)) return sendJSON(res, 429, { error: 'Too many sign-in attempts; wait a minute', code: 'ERATE_LIMIT' });
|
|
261
|
+
try { return sendJSON(res, 200, accessAuth.beginAuthentication({ origin: servedOrigin })); }
|
|
262
|
+
catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code || 'EWEBAUTHN' }); }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (urlPath === '/api/access/passkeys/login/verify' && method === 'POST') {
|
|
266
|
+
const body = await readJsonOr400(req, res);
|
|
267
|
+
if (!body) return;
|
|
268
|
+
try {
|
|
269
|
+
const account = accessAuth.finishAuthentication(body);
|
|
270
|
+
clearAccessFailures(req);
|
|
271
|
+
const issued = accessAuth.issueSession();
|
|
272
|
+
res.setHeader('Set-Cookie', accessCookie(issued.token, secure, accessAuth.SESSION_TTL_MS / 1000));
|
|
273
|
+
notifyLogin(account.username, req);
|
|
274
|
+
return sendJSON(res, 200, { ok: true, user: account.username });
|
|
275
|
+
|
|
276
|
+
} catch (e) {
|
|
277
|
+
recordAccessFailure(req);
|
|
278
|
+
return sendJSON(res, 401, { error: e.message, code: e.code || 'EWEBAUTHN' });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (urlPath === '/api/access/passkeys' && method === 'GET') {
|
|
283
|
+
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
284
|
+
return sendJSON(res, 200, { passkeys: accessAuth.passkeys() });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (urlPath.startsWith('/api/access/passkeys/') && method === 'DELETE') {
|
|
288
|
+
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
289
|
+
const id = safeDecode(urlPath.slice('/api/access/passkeys/'.length));
|
|
290
|
+
return sendJSON(res, accessAuth.deletePasskey(id) ? 200 : 404, { ok: true });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (urlPath === '/api/access/setup/qr' && method === 'GET') {
|
|
294
|
+
const code = typeof parsed.query.code === 'string' ? parsed.query.code : '';
|
|
295
|
+
if (!accessAuth.setupCodeValid(code)) return sendJSON(res, 404, { error: 'Setup code is missing or expired', code: 'ESETUP_CODE' });
|
|
296
|
+
const setupUrl = servedOrigin + '/#/setup?code=' + encodeURIComponent(accessAuth.normalizeCode(code));
|
|
297
|
+
const svg = qr.svg(setupUrl);
|
|
298
|
+
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
299
|
+
res.end(svg);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'access' });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = { handleAccess };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Project custom-action CRUD + execution REST handlers.
|
|
3
|
+
const { sendJSON, qs, safeDecode, readJsonOr400, chats, mcp, shellTool } = require('./server-shared.js');
|
|
4
|
+
const customActions = require('./custom-actions.js');
|
|
5
|
+
function statusFor(error) {
|
|
6
|
+
if (error && ['EBADINPUT', 'MOUAIF_PROJECT_PARSE_ERROR'].includes(error.code)) return 400;
|
|
7
|
+
if (error && (error.code === 'ETOOL_DISABLED' || error.code === 'EDENIED')) return 403;
|
|
8
|
+
if (error && error.code === 'EMCP_NOTFOUND') return 404;
|
|
9
|
+
return 500;
|
|
10
|
+
}
|
|
11
|
+
function findAction(projectDir, id) {
|
|
12
|
+
return customActions.listActions(projectDir).find((action) => action.id === id) || null;
|
|
13
|
+
}
|
|
14
|
+
async function handleActions(req, res, parsed) {
|
|
15
|
+
const urlPath = parsed.pathname;
|
|
16
|
+
const method = req.method;
|
|
17
|
+
const q = parsed.query || {};
|
|
18
|
+
if (urlPath === '/api/actions' && method === 'GET') {
|
|
19
|
+
const projectDir = qs(q, 'projectDir');
|
|
20
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
21
|
+
try { return sendJSON(res, 200, { actions: customActions.listActions(projectDir) }); }
|
|
22
|
+
catch (error) { return sendJSON(res, statusFor(error), { error: error.message, code: error.code || 'INTERNAL' }); }
|
|
23
|
+
}
|
|
24
|
+
if (urlPath === '/api/actions' && method === 'POST') {
|
|
25
|
+
const body = await readJsonOr400(req, res);
|
|
26
|
+
if (!body) return;
|
|
27
|
+
const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
28
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
29
|
+
try {
|
|
30
|
+
const action = customActions.normalizeAction(body.action, 0);
|
|
31
|
+
const actions = customActions.listActions(projectDir);
|
|
32
|
+
const originalId = typeof body.originalId === 'string' ? body.originalId : '';
|
|
33
|
+
const index = originalId ? actions.findIndex((item) => item.id === originalId) : -1;
|
|
34
|
+
if (index >= 0) actions[index] = action; else actions.push(action);
|
|
35
|
+
const saved = customActions.replaceActions(projectDir, actions);
|
|
36
|
+
return sendJSON(res, 200, { action, actions: saved });
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return sendJSON(res, statusFor(error), { error: error.message, code: error.code || 'INTERNAL' });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let match = urlPath.match(/^\/api\/actions\/([^/]+)$/);
|
|
42
|
+
if (match && method === 'DELETE') {
|
|
43
|
+
const projectDir = qs(q, 'projectDir');
|
|
44
|
+
if (!projectDir) return sendJSON(res, 400, { error: 'projectDir is required' });
|
|
45
|
+
const id = safeDecode(match[1]);
|
|
46
|
+
try {
|
|
47
|
+
const actions = customActions.listActions(projectDir);
|
|
48
|
+
const next = actions.filter((action) => action.id !== id);
|
|
49
|
+
if (next.length === actions.length) return sendJSON(res, 404, { error: 'Action not found', id });
|
|
50
|
+
customActions.replaceActions(projectDir, next);
|
|
51
|
+
return sendJSON(res, 200, { ok: true, actions: next });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return sendJSON(res, statusFor(error), { error: error.message, code: error.code || 'INTERNAL' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
match = urlPath.match(/^\/api\/actions\/([^/]+)\/run$/);
|
|
57
|
+
if (match && method === 'POST') {
|
|
58
|
+
const body = await readJsonOr400(req, res);
|
|
59
|
+
if (!body) return;
|
|
60
|
+
const projectDir = body && typeof body.projectDir === 'string' ? body.projectDir : '';
|
|
61
|
+
const chatId = body && typeof body.chatId === 'string' ? body.chatId : '';
|
|
62
|
+
const callId = body && typeof body.callId === 'string' ? body.callId : '';
|
|
63
|
+
if (!projectDir || !chatId || !callId) return sendJSON(res, 400, { error: 'projectDir, chatId, and callId are required' });
|
|
64
|
+
if (!chats.getChat(projectDir, chatId)) return sendJSON(res, 404, { error: 'Chat not found', chatId });
|
|
65
|
+
try {
|
|
66
|
+
const id = safeDecode(match[1]);
|
|
67
|
+
const action = findAction(projectDir, id);
|
|
68
|
+
if (!action) return sendJSON(res, 404, { error: 'Action not found', id });
|
|
69
|
+
if (action.kind === 'cli') {
|
|
70
|
+
const authorization = await require('./tools/authorization.js').authorize({
|
|
71
|
+
projectDir, chatId, callId, tool: 'shell', cmd: action.command,
|
|
72
|
+
summary: action.label + ': ' + action.command, timeoutMs: action.timeoutMs, flow: 'retry'
|
|
73
|
+
});
|
|
74
|
+
if (authorization.decision === 'prompt') return sendJSON(res, 409, {
|
|
75
|
+
ok: false, code: 'EAUTH_REQUIRED', projectDir, chatId, callId,
|
|
76
|
+
tool: 'shell', cmd: action.command, timeoutMs: authorization.timeoutMs
|
|
77
|
+
});
|
|
78
|
+
const result = await shellTool.runShell({ projectDir, cmd: action.command, timeoutMs: authorization.timeoutMs });
|
|
79
|
+
return sendJSON(res, 200, { ok: !!result.ok, action, result });
|
|
80
|
+
}
|
|
81
|
+
const server = mcp.getServer(projectDir, action.serverId);
|
|
82
|
+
if (!server) return sendJSON(res, 404, { error: 'MCP server not found', serverId: action.serverId });
|
|
83
|
+
const tool = mcp.composedToolName(server.slug, action.toolName);
|
|
84
|
+
const authorization = await require('./tools/authorization.js').authorize({
|
|
85
|
+
projectDir, chatId, callId, tool, args: action.args,
|
|
86
|
+
summary: action.label + ': ' + JSON.stringify(action.args || {}), flow: 'retry'
|
|
87
|
+
});
|
|
88
|
+
if (authorization.decision === 'prompt') return sendJSON(res, 409, {
|
|
89
|
+
ok: false, code: 'EAUTH_REQUIRED', projectDir, chatId, callId, tool,
|
|
90
|
+
args: action.args, timeoutMs: authorization.timeoutMs
|
|
91
|
+
});
|
|
92
|
+
const result = await mcp.callTool(projectDir, server.slug, action.toolName, action.args || {});
|
|
93
|
+
return sendJSON(res, 200, { ok: result && result.ok !== false, action, result });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return sendJSON(res, statusFor(error), { ok: false, error: error.message, code: error.code || 'INTERNAL' });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'actions' });
|
|
99
|
+
}
|
|
100
|
+
module.exports = { handleActions };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// AI proxy REST handlers. Extracted from the original single-file
|
|
4
|
+
// http-server.js. Shared helpers + model resolution live in
|
|
5
|
+
// src/server-shared.js.
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
sendJSON,
|
|
9
|
+
readJsonOr400,
|
|
10
|
+
resolveModel,
|
|
11
|
+
credentialForProvider,
|
|
12
|
+
settings,
|
|
13
|
+
projects,
|
|
14
|
+
ai
|
|
15
|
+
} = require('./server-shared.js');
|
|
16
|
+
const modelList = require('./modelList.js');
|
|
17
|
+
|
|
18
|
+
async function handleAI(req, res, parsed) {
|
|
19
|
+
const urlPath = parsed.pathname;
|
|
20
|
+
const method = req.method;
|
|
21
|
+
|
|
22
|
+
// GET /api/ai/models?projectDir=<abs> -> list models visible to this project
|
|
23
|
+
if (urlPath === '/api/ai/models' && method === 'GET') {
|
|
24
|
+
const projectDir = typeof parsed.query.projectDir === 'string' ? parsed.query.projectDir : '';
|
|
25
|
+
const resolved = settings.getResolved(projectDir || null);
|
|
26
|
+
const models = (resolved.models || []).map(m => {
|
|
27
|
+
const rec = {
|
|
28
|
+
id: m.id, provider: m.provider, label: m.label, auth: m.auth || 'apikey'
|
|
29
|
+
};
|
|
30
|
+
// Thinking options come from the provider when it reports them
|
|
31
|
+
// (per-model on live lists, provider-level otherwise); a
|
|
32
|
+
// user-set model.thinking always wins.
|
|
33
|
+
const def = ai.ENDPOINTS[m.provider];
|
|
34
|
+
const thinking = m.thinking || (def && def.thinkingDescriptor) || null;
|
|
35
|
+
if (thinking) rec.thinking = thinking;
|
|
36
|
+
return rec;
|
|
37
|
+
});
|
|
38
|
+
return sendJSON(res, 200, { models, providers: Object.keys(ai.ENDPOINTS) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// GET /api/ai/models/live?provider=<id> -> { models, fetchedAt, cached }
|
|
42
|
+
// Live model list fetched from the upstream /models endpoint
|
|
43
|
+
// (OpenAI-shaped), Gemini's /v1beta/models, Ollama's /api/tags, or
|
|
44
|
+
// the curated Copilot catalog. Results are cached per-provider in
|
|
45
|
+
// memory for an hour so opening many chats does not re-hit the
|
|
46
|
+
// upstream. The chat UI calls this from the refresh button next to
|
|
47
|
+
// the model <select>.
|
|
48
|
+
//
|
|
49
|
+
// The fetch + cache live in src/modelList.js, because the dictation
|
|
50
|
+
// catalog needs the same answer filtered differently. `_bust=1` forces a
|
|
51
|
+
// reload (the picker's explicit refresh).
|
|
52
|
+
if (urlPath === '/api/ai/models/live' && method === 'GET') {
|
|
53
|
+
const provider = typeof parsed.query.provider === 'string' ? parsed.query.provider : '';
|
|
54
|
+
if (!provider || !ai.ENDPOINTS[provider]) {
|
|
55
|
+
return sendJSON(res, 400, { error: 'unknown provider', provider });
|
|
56
|
+
}
|
|
57
|
+
// `purpose` selects which slice of the provider's catalog to read:
|
|
58
|
+
// `chat` (default), `transcription`, or `image`. A provider without a
|
|
59
|
+
// separate slice returns its chat list, so a caller can safely ask for
|
|
60
|
+
// the image slice on every provider and union the results — which is
|
|
61
|
+
// what the agent editor's model picker does so an image model reaches
|
|
62
|
+
// it even though the chat list never carries one.
|
|
63
|
+
const purposeRaw = typeof parsed.query.purpose === 'string' ? parsed.query.purpose : '';
|
|
64
|
+
const purpose = (purposeRaw === 'image' || purposeRaw === 'transcription') ? purposeRaw : undefined;
|
|
65
|
+
try {
|
|
66
|
+
const result = await modelList.liveModelsFor(provider, { force: !!parsed.query._bust, purpose });
|
|
67
|
+
return sendJSON(res, 200, { models: result.models, fetchedAt: result.fetchedAt, cached: result.cached });
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Map typed error codes to HTTP statuses. 502 is reserved for
|
|
70
|
+
// "upstream answered with a non-2xx" (EUPSTREAM) — anything
|
|
71
|
+
// that isn't a recognized failure shape falls through to a
|
|
72
|
+
// generic 502 so a regression in the adapter still surfaces
|
|
73
|
+
// somewhere observable. Distinct failure modes get distinct
|
|
74
|
+
// statuses so the chat UI can show a useful next step.
|
|
75
|
+
const code = err && err.code;
|
|
76
|
+
let status;
|
|
77
|
+
if (code === 'ENO_LIST') status = 400;
|
|
78
|
+
else if (code === 'ENO_APIKEY') status = 400;
|
|
79
|
+
else if (code === 'EUNREACHABLE') status = 503;
|
|
80
|
+
else if (code === 'ETIMEOUT') status = 504;
|
|
81
|
+
else if (code === 'EUPSTREAM' && typeof err.status === 'number') status = err.status;
|
|
82
|
+
else status = 502;
|
|
83
|
+
const body = { error: String((err && err.message) || err), code: code || 'ELIVE', provider };
|
|
84
|
+
if (code === 'EUPSTREAM' && typeof err.status === 'number') body.upstreamStatus = err.status;
|
|
85
|
+
return sendJSON(res, status, body);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// GET /api/ai/provider-credit?provider=<id> -> { supported, remaining? }
|
|
90
|
+
// Provider-specific account balance lookup. Only OpenRouter exposes a
|
|
91
|
+
// simple key-scoped credits endpoint; unsupported providers return
|
|
92
|
+
// { supported: false } so the chat head can hide the pill.
|
|
93
|
+
if (urlPath === '/api/ai/provider-credit' && method === 'GET') {
|
|
94
|
+
const provider = typeof parsed.query.provider === 'string' ? parsed.query.provider : '';
|
|
95
|
+
if (provider !== 'openrouter') return sendJSON(res, 200, { provider, supported: false });
|
|
96
|
+
let cred;
|
|
97
|
+
try { cred = credentialForProvider(provider); }
|
|
98
|
+
catch (e) { return sendJSON(res, 400, { provider, supported: true, error: e.message, code: e.code || 'ENO_APIKEY' }); }
|
|
99
|
+
if (!cred) return sendJSON(res, 400, { provider, supported: true, error: 'OpenRouter API key required', code: 'ENO_APIKEY' });
|
|
100
|
+
let r;
|
|
101
|
+
try {
|
|
102
|
+
r = await fetch(ai.ENDPOINTS.openrouter.baseUrl + '/credits', {
|
|
103
|
+
headers: { ...ai.ENDPOINTS.openrouter.authHeader(cred), ...ai.ENDPOINTS.openrouter.staticHeaders }
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return sendJSON(res, 503, { provider, supported: true, error: 'OpenRouter unreachable', code: 'EUNREACHABLE' });
|
|
107
|
+
}
|
|
108
|
+
if (!r.ok) return sendJSON(res, r.status, { provider, supported: true, error: 'OpenRouter returned ' + r.status, code: 'EUPSTREAM' });
|
|
109
|
+
const body = await r.json().catch(() => ({}));
|
|
110
|
+
const data = body && body.data ? body.data : body;
|
|
111
|
+
const totalCredits = Number(data && (data.total_credits ?? data.totalCredits ?? data.credits));
|
|
112
|
+
const totalUsage = Number(data && (data.total_usage ?? data.totalUsage ?? data.usage));
|
|
113
|
+
const remaining = Number(data && (data.remaining_credits ?? data.remainingCredits ?? data.remaining));
|
|
114
|
+
const value = isFinite(remaining) ? remaining : (isFinite(totalCredits) && isFinite(totalUsage) ? totalCredits - totalUsage : NaN);
|
|
115
|
+
if (!isFinite(value)) return sendJSON(res, 502, { provider, supported: true, error: 'OpenRouter credit response missing totals', code: 'EBAD_CREDITS' });
|
|
116
|
+
return sendJSON(res, 200, { provider, supported: true, label: 'Balance', remaining: value, totalCredits: isFinite(totalCredits) ? totalCredits : undefined, totalUsage: isFinite(totalUsage) ? totalUsage : undefined });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// GET /api/ai/models/providers -> { providers: [{ id }] }
|
|
120
|
+
// The chat uses this when a project has no model records yet. Returning
|
|
121
|
+
// every configured connection avoids the old "first provider wins"
|
|
122
|
+
// fallback, which silently queried OpenAI when the user wanted OpenRouter.
|
|
123
|
+
if (urlPath === '/api/ai/models/providers' && method === 'GET') {
|
|
124
|
+
const app = settings.getApp();
|
|
125
|
+
const providers = (Array.isArray(app.providers) ? app.providers : [])
|
|
126
|
+
.filter((p) => p && typeof p.id === 'string' && ai.ENDPOINTS[p.id])
|
|
127
|
+
.map((p) => ({ id: p.id }));
|
|
128
|
+
return sendJSON(res, 200, { providers });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// GET /api/ai/models-all -> { ids: [..] }
|
|
132
|
+
// Union of every model id the user has configured across every
|
|
133
|
+
// registered project + the app-level models list. Used by the
|
|
134
|
+
// SettingsPricing view to surface "the ids you might want to
|
|
135
|
+
// price" without forcing the user to remember project paths.
|
|
136
|
+
if (urlPath === '/api/ai/models-all' && method === 'GET') {
|
|
137
|
+
const ids = new Set();
|
|
138
|
+
// App-level models (legacy / inline pricing tests)
|
|
139
|
+
const app = settings.getApp();
|
|
140
|
+
if (Array.isArray(app.models)) {
|
|
141
|
+
for (const m of app.models) if (m && m.id) ids.add(m.id);
|
|
142
|
+
}
|
|
143
|
+
// Project-level models. Each registered project has its own
|
|
144
|
+
// .mouaif.json; the registry is the source of truth for which
|
|
145
|
+
// projects still exist on disk.
|
|
146
|
+
const registered = projects.listProjects();
|
|
147
|
+
for (const p of registered) {
|
|
148
|
+
if (!p || !p.path) continue;
|
|
149
|
+
try {
|
|
150
|
+
const projSettings = settings.getProject(p.path);
|
|
151
|
+
if (Array.isArray(projSettings.models)) {
|
|
152
|
+
for (const m of projSettings.models) if (m && m.id) ids.add(m.id);
|
|
153
|
+
}
|
|
154
|
+
} catch { /* unreadable project file — skip */ }
|
|
155
|
+
}
|
|
156
|
+
return sendJSON(res, 200, { ids: Array.from(ids).sort() });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// POST /api/ai/test body: { modelId, projectDir? } -> { ok, error? }
|
|
160
|
+
// Lightweight connectivity test: sends a single "hi" message and
|
|
161
|
+
// checks whether the upstream returns a typed error or a 200 SSE
|
|
162
|
+
// stream. Aborts after 10 s so the user never waits long.
|
|
163
|
+
if (urlPath === '/api/ai/test' && method === 'POST') {
|
|
164
|
+
const body = await readJsonOr400(req, res);
|
|
165
|
+
if (!body) return;
|
|
166
|
+
|
|
167
|
+
let model;
|
|
168
|
+
try { model = resolveModel(body.modelId, body.projectDir); }
|
|
169
|
+
catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code }); }
|
|
170
|
+
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
let timedOut = false;
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
controller.abort();
|
|
176
|
+
}, 10000);
|
|
177
|
+
try {
|
|
178
|
+
const result = await ai.streamChat({
|
|
179
|
+
model,
|
|
180
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
181
|
+
signal: controller.signal,
|
|
182
|
+
onEvent: () => {} // discard events, we only care about ok/error
|
|
183
|
+
});
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
if (result.ok) return sendJSON(res, 200, { ok: true });
|
|
186
|
+
if (timedOut && result.error && result.error.code === 'EABORTED') {
|
|
187
|
+
return sendJSON(res, 200, { ok: false, error: 'timed out after 10 s', code: 'ETIMEDOUT' });
|
|
188
|
+
}
|
|
189
|
+
return sendJSON(res, 200, { ok: false, error: (result.error && result.error.message) || 'upstream error' });
|
|
190
|
+
} catch (e) {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
const code = e.code || 'ETEST';
|
|
193
|
+
if (timedOut || code === 'EABORTED') {
|
|
194
|
+
return sendJSON(res, 200, { ok: false, error: 'timed out after 10 s', code: 'ETIMEDOUT' });
|
|
195
|
+
}
|
|
196
|
+
return sendJSON(res, 200, { ok: false, error: e.message, code });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// POST /api/ai/chat body: { modelId, providerId?, messages, projectDir? } -> SSE stream
|
|
201
|
+
// `providerId` pins the model id to one provider connection: the same id can
|
|
202
|
+
// exist under several providers, and without it resolution picks the first
|
|
203
|
+
// project model with that id (surface-level callers such as the Inspector
|
|
204
|
+
// pass the provider they picked the model from).
|
|
205
|
+
if (urlPath === '/api/ai/chat' && method === 'POST') {
|
|
206
|
+
const body = await readJsonOr400(req, res);
|
|
207
|
+
if (!body) return;
|
|
208
|
+
|
|
209
|
+
let model;
|
|
210
|
+
try { model = resolveModel(body.modelId, body.projectDir, body.providerId); }
|
|
211
|
+
catch (e) { return sendJSON(res, 400, { error: e.message, code: e.code }); }
|
|
212
|
+
|
|
213
|
+
// Open SSE.
|
|
214
|
+
res.writeHead(200, {
|
|
215
|
+
'Content-Type': 'text/event-stream',
|
|
216
|
+
'Cache-Control': 'no-cache',
|
|
217
|
+
Connection: 'keep-alive'
|
|
218
|
+
});
|
|
219
|
+
// Initial comment so the client sees headers immediately.
|
|
220
|
+
res.write(': connected\n\n');
|
|
221
|
+
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
req.on('close', () => { try { controller.abort(); } catch { /* ignore */ } });
|
|
224
|
+
|
|
225
|
+
function emit(name, data) {
|
|
226
|
+
try {
|
|
227
|
+
res.write('event: ' + name + '\ndata: ' + JSON.stringify(data) + '\n\n');
|
|
228
|
+
} catch { /* socket already closed */ }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const result = await ai.streamChat({
|
|
232
|
+
model,
|
|
233
|
+
messages: body.messages || [],
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
onEvent: (name, data) => emit(name, data)
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!result.ok) {
|
|
239
|
+
emit('error', Object.assign({ code: result.error.code || 'EUPSTREAM' }, result.error));
|
|
240
|
+
}
|
|
241
|
+
res.end();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return sendJSON(res, 404, { error: 'Not found', scope: 'ai' });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { handleAI };
|