@jxsuite/server 0.32.0 → 0.33.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/package.json +7 -10
- package/src/ai-api.ts +465 -0
- package/src/server.ts +15 -1
- package/src/studio-api.ts +0 -74
- package/src/watch.ts +12 -0
- package/src/claude-session.ts +0 -338
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Jx development server with live reload, server-side proxy, and studio integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"type": "module",
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/server.ts",
|
|
17
|
-
"./
|
|
17
|
+
"./ai-api": "./src/ai-api.ts",
|
|
18
18
|
"./packages": "./src/packages.ts",
|
|
19
19
|
"./refactor": "./src/refactor/index.ts",
|
|
20
20
|
"./resolve": "./src/resolve.ts",
|
|
@@ -29,14 +29,11 @@
|
|
|
29
29
|
"test:coverage": "bun test --isolate --coverage"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
34
|
-
"@jxsuite/
|
|
35
|
-
"@jxsuite/
|
|
36
|
-
"@jxsuite/
|
|
37
|
-
"@jxsuite/runtime": "^0.32.0",
|
|
38
|
-
"@jxsuite/schema": "^0.32.0",
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
32
|
+
"@jxsuite/compiler": "^0.33.0",
|
|
33
|
+
"@jxsuite/create": "^0.33.0",
|
|
34
|
+
"@jxsuite/parser": "^0.33.0",
|
|
35
|
+
"@jxsuite/runtime": "^0.33.0",
|
|
36
|
+
"@jxsuite/schema": "^0.33.0",
|
|
40
37
|
"chokidar": "^5.0.0",
|
|
41
38
|
"zod": "^4.4.3"
|
|
42
39
|
}
|
package/src/ai-api.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-api.js — AI proxy endpoints for Jx Studio
|
|
3
|
+
*
|
|
4
|
+
* Handles /__studio/ai/chat (SSE streaming proxy to OpenAI) and /__studio/ai/models.
|
|
5
|
+
* The server acts as a thin proxy: validates the request shape, forwards to OpenAI,
|
|
6
|
+
* normalizes the SSE stream into StreamEvent-compatible format, and pipes back.
|
|
7
|
+
*
|
|
8
|
+
* API key flow:
|
|
9
|
+
* 1. Request header X-Api-Key or Authorization: Bearer <key>
|
|
10
|
+
* 2. Fallback: OPENAI_API_KEY env var
|
|
11
|
+
* 3. If neither → 401 with error message
|
|
12
|
+
*
|
|
13
|
+
* Base URL flow:
|
|
14
|
+
* 1. Request header X-Api-Base-URL
|
|
15
|
+
* 2. Fallback: OPENAI_BASE_URL env var
|
|
16
|
+
* 3. Default: https://api.openai.com/v1
|
|
17
|
+
*
|
|
18
|
+
* @license MIT
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
24
|
+
|
|
25
|
+
/** A tool-call fragment inside an OpenAI streaming `delta`. */
|
|
26
|
+
interface ToolCallDelta {
|
|
27
|
+
index: number;
|
|
28
|
+
id?: string;
|
|
29
|
+
type?: string;
|
|
30
|
+
function?: { name?: string; arguments?: string };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single OpenAI chat-completions streaming chunk (the JSON after `data: `). */
|
|
34
|
+
interface OpenAIStreamChunk {
|
|
35
|
+
choices?: {
|
|
36
|
+
index?: number;
|
|
37
|
+
delta?: { content?: string; tool_calls?: ToolCallDelta[] };
|
|
38
|
+
finish_reason?: string | null;
|
|
39
|
+
}[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A model entry from the upstream `/models` listing. */
|
|
43
|
+
interface ModelEntry {
|
|
44
|
+
id: string;
|
|
45
|
+
context_window?: number;
|
|
46
|
+
owned_by?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getConfig(req: Request): { apiKey: string | null; baseUrl: string; missingKey: boolean } {
|
|
50
|
+
const authHeader = req.headers.get("Authorization") || "";
|
|
51
|
+
const apiKeyHeader = req.headers.get("X-Api-Key") || "";
|
|
52
|
+
const baseUrlHeader = req.headers.get("X-Api-Base-URL") || "";
|
|
53
|
+
|
|
54
|
+
let apiKey = null;
|
|
55
|
+
|
|
56
|
+
// Check Authorization: Bearer <key>
|
|
57
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
58
|
+
apiKey = authHeader.slice(7).trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check X-Api-Key header (overrides Bearer)
|
|
62
|
+
if (apiKeyHeader) {
|
|
63
|
+
apiKey = apiKeyHeader.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check env var (fallback)
|
|
67
|
+
if (!apiKey && process.env.OPENAI_API_KEY) {
|
|
68
|
+
apiKey = process.env.OPENAI_API_KEY;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check base URL
|
|
72
|
+
const baseUrl = baseUrlHeader || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL;
|
|
73
|
+
|
|
74
|
+
return { apiKey, baseUrl, missingKey: !apiKey };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Write an SSE event to the response stream. */
|
|
80
|
+
function writeSSE(controller: ReadableStreamDefaultController, event: unknown): void {
|
|
81
|
+
const data = JSON.stringify(event);
|
|
82
|
+
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write a JSON error response (for non-streaming endpoints and error cases). */
|
|
86
|
+
function jsonError(status: number, message: string): Response {
|
|
87
|
+
return Response.json(
|
|
88
|
+
{ error: message },
|
|
89
|
+
{
|
|
90
|
+
status,
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── /__studio/ai/chat — SSE streaming proxy ───────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Handle POST /__studio/ai/chat — proxy chat completions to OpenAI via SSE. */
|
|
99
|
+
export async function handleChat(req: Request): Promise<Response> {
|
|
100
|
+
const { apiKey, baseUrl, missingKey } = getConfig(req);
|
|
101
|
+
if (missingKey) {
|
|
102
|
+
return jsonError(
|
|
103
|
+
401,
|
|
104
|
+
"No API key configured. Set OPENAI_API_KEY env var or send X-Api-Key header.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse request body
|
|
109
|
+
let body: {
|
|
110
|
+
messages?: unknown[];
|
|
111
|
+
tools?: unknown[];
|
|
112
|
+
systemPrompt?: string;
|
|
113
|
+
model?: string;
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
body = (await req.json()) as {
|
|
117
|
+
messages?: unknown[];
|
|
118
|
+
tools?: unknown[];
|
|
119
|
+
systemPrompt?: string;
|
|
120
|
+
model?: string;
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return jsonError(400, "Invalid JSON body");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { messages = [], tools = [], systemPrompt = "", model = "gpt-4o" } = body;
|
|
127
|
+
|
|
128
|
+
if (!Array.isArray(messages)) {
|
|
129
|
+
return jsonError(400, "messages must be an array");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build OpenAI request
|
|
133
|
+
const openaiBody: {
|
|
134
|
+
model: string;
|
|
135
|
+
messages: unknown[];
|
|
136
|
+
stream: boolean;
|
|
137
|
+
stream_options: { include_usage: boolean };
|
|
138
|
+
tools?: unknown[];
|
|
139
|
+
tool_choice?: string;
|
|
140
|
+
parallel_tool_calls?: boolean;
|
|
141
|
+
} = {
|
|
142
|
+
model,
|
|
143
|
+
messages: [{ role: "system", content: systemPrompt }, ...messages],
|
|
144
|
+
stream: true,
|
|
145
|
+
stream_options: { include_usage: true },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (tools && tools.length > 0) {
|
|
149
|
+
openaiBody.tools = tools;
|
|
150
|
+
openaiBody.tool_choice = "auto";
|
|
151
|
+
openaiBody.parallel_tool_calls = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const upstreamUrl = `${baseUrl}/chat/completions`;
|
|
155
|
+
|
|
156
|
+
// Create the SSE stream
|
|
157
|
+
const stream = new ReadableStream({
|
|
158
|
+
async start(controller) {
|
|
159
|
+
let response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(upstreamUrl, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
Authorization: `Bearer ${apiKey}`,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(openaiBody),
|
|
168
|
+
signal: req.signal,
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if ((error as Error).name === "AbortError") {
|
|
172
|
+
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
173
|
+
} else {
|
|
174
|
+
writeSSE(controller, {
|
|
175
|
+
type: "error",
|
|
176
|
+
message: `Network error: ${(error as Error).message}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
controller.close();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!response.ok) {
|
|
184
|
+
let errorBody = "";
|
|
185
|
+
try {
|
|
186
|
+
errorBody = await response.text();
|
|
187
|
+
} catch {
|
|
188
|
+
/* Ignore */
|
|
189
|
+
}
|
|
190
|
+
// Parse the upstream JSON error body (OpenAI returns { error: { message: "..." } },
|
|
191
|
+
// While some compatible providers return { error: "..." }). Extract a clean message
|
|
192
|
+
// Instead of embedding the raw JSON in the error text.
|
|
193
|
+
let cleanMessage = errorBody || response.statusText;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(errorBody) as { error?: string | { message?: string } };
|
|
196
|
+
if (typeof parsed.error === "string") {
|
|
197
|
+
cleanMessage = parsed.error;
|
|
198
|
+
} else if (parsed.error?.message) {
|
|
199
|
+
cleanMessage = parsed.error.message;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
/* Not JSON — use the raw body. */
|
|
203
|
+
}
|
|
204
|
+
writeSSE(controller, {
|
|
205
|
+
type: "error",
|
|
206
|
+
message: cleanMessage,
|
|
207
|
+
code: String(response.status),
|
|
208
|
+
});
|
|
209
|
+
controller.close();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const reader = response.body?.getReader();
|
|
214
|
+
if (!reader) {
|
|
215
|
+
writeSSE(controller, { type: "error", message: "No response body from upstream" });
|
|
216
|
+
controller.close();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const decoder = new TextDecoder();
|
|
221
|
+
let buffer = "";
|
|
222
|
+
|
|
223
|
+
const pendingToolCalls = new Map<number, { id: string; name: string; args: string }>();
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { done, value } = await reader.read();
|
|
228
|
+
if (done) {
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
buffer += decoder.decode(value, { stream: true });
|
|
233
|
+
const lines = buffer.split("\n");
|
|
234
|
+
buffer = lines.pop() || "";
|
|
235
|
+
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (!trimmed || !trimmed.startsWith("data: ")) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const dataStr = trimmed.slice(6);
|
|
243
|
+
if (dataStr === "[DONE]") {
|
|
244
|
+
// Emit pending tool call ends
|
|
245
|
+
for (const [, tc] of pendingToolCalls) {
|
|
246
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
247
|
+
}
|
|
248
|
+
pendingToolCalls.clear();
|
|
249
|
+
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
250
|
+
controller.close();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let parsed: OpenAIStreamChunk;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(dataStr) as OpenAIStreamChunk;
|
|
257
|
+
} catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const choice = parsed.choices?.[0];
|
|
262
|
+
if (!choice) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const { delta } = choice;
|
|
267
|
+
if (!delta) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Text content
|
|
272
|
+
if (delta.content) {
|
|
273
|
+
writeSSE(controller, { type: "delta", content: delta.content });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Tool calls
|
|
277
|
+
if (delta.tool_calls) {
|
|
278
|
+
for (const tc of delta.tool_calls) {
|
|
279
|
+
if (tc.id) {
|
|
280
|
+
// First appearance
|
|
281
|
+
const entry = {
|
|
282
|
+
id: tc.id,
|
|
283
|
+
name: tc.function?.name || "",
|
|
284
|
+
args: tc.function?.arguments || "",
|
|
285
|
+
};
|
|
286
|
+
pendingToolCalls.set(tc.index, entry);
|
|
287
|
+
|
|
288
|
+
writeSSE(controller, { type: "tool_call_start", id: tc.id, name: entry.name });
|
|
289
|
+
|
|
290
|
+
if (entry.args) {
|
|
291
|
+
writeSSE(controller, { type: "tool_call_delta", id: tc.id, args: entry.args });
|
|
292
|
+
}
|
|
293
|
+
} else if (tc.function?.arguments) {
|
|
294
|
+
// Subsequent fragment
|
|
295
|
+
const existing = pendingToolCalls.get(tc.index);
|
|
296
|
+
if (existing) {
|
|
297
|
+
existing.args += tc.function.arguments;
|
|
298
|
+
writeSSE(controller, {
|
|
299
|
+
type: "tool_call_delta",
|
|
300
|
+
id: existing.id,
|
|
301
|
+
args: tc.function.arguments,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Finish reason
|
|
309
|
+
if (choice.finish_reason === "tool_calls") {
|
|
310
|
+
for (const [, tc] of pendingToolCalls) {
|
|
311
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
312
|
+
}
|
|
313
|
+
pendingToolCalls.clear();
|
|
314
|
+
writeSSE(controller, { type: "done", stopReason: "tool_calls" });
|
|
315
|
+
controller.close();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (choice.finish_reason === "stop" || choice.finish_reason === "length") {
|
|
320
|
+
for (const [, tc] of pendingToolCalls) {
|
|
321
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
322
|
+
}
|
|
323
|
+
pendingToolCalls.clear();
|
|
324
|
+
writeSSE(controller, {
|
|
325
|
+
type: "done",
|
|
326
|
+
stopReason: choice.finish_reason === "length" ? "length" : "stop",
|
|
327
|
+
});
|
|
328
|
+
controller.close();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Stream ended without explicit finish_reason
|
|
335
|
+
for (const [, tc] of pendingToolCalls) {
|
|
336
|
+
writeSSE(controller, { type: "tool_call_end", id: tc.id });
|
|
337
|
+
}
|
|
338
|
+
pendingToolCalls.clear();
|
|
339
|
+
writeSSE(controller, { type: "done", stopReason: "stop" });
|
|
340
|
+
controller.close();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
void reader.cancel();
|
|
343
|
+
if ((error as Error).name === "AbortError") {
|
|
344
|
+
writeSSE(controller, { type: "done", stopReason: "cancelled" });
|
|
345
|
+
controller.close();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
writeSSE(controller, {
|
|
349
|
+
type: "error",
|
|
350
|
+
message: `Stream error: ${(error as Error).message}`,
|
|
351
|
+
});
|
|
352
|
+
controller.close();
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
return new Response(stream, {
|
|
358
|
+
headers: {
|
|
359
|
+
"Content-Type": "text/event-stream",
|
|
360
|
+
"Cache-Control": "no-cache",
|
|
361
|
+
Connection: "keep-alive",
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ─── /__studio/ai/models — model listing ────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Handle GET /__studio/ai/models — return available models.
|
|
370
|
+
*
|
|
371
|
+
* When the caller has configured an API key (header or env), the request is proxied to the upstream
|
|
372
|
+
* provider's /models endpoint so any OpenAI-compatible endpoint (OpenRouter, local LLM, OpenCode
|
|
373
|
+
* Zen, Azure, etc.) returns its actual model list. Falls back to a hardcoded default list when no
|
|
374
|
+
* key is available.
|
|
375
|
+
*/
|
|
376
|
+
export async function handleModels(req: Request): Promise<Response> {
|
|
377
|
+
const { apiKey, baseUrl, missingKey } = getConfig(req);
|
|
378
|
+
|
|
379
|
+
// No key available → return hardcoded defaults so the UI can at least render.
|
|
380
|
+
if (missingKey) {
|
|
381
|
+
const defaults = [
|
|
382
|
+
{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 },
|
|
383
|
+
{ id: "gpt-4.1", name: "GPT-4.1", contextWindow: 1_000_000 },
|
|
384
|
+
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextWindow: 1_000_000 },
|
|
385
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextWindow: 128_000 },
|
|
386
|
+
];
|
|
387
|
+
return Response.json(
|
|
388
|
+
{ models: defaults, configured: false },
|
|
389
|
+
{
|
|
390
|
+
headers: { "Content-Type": "application/json" },
|
|
391
|
+
},
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Key is available — proxy to the upstream /models endpoint.
|
|
396
|
+
try {
|
|
397
|
+
const upstreamUrl = `${baseUrl}/models`;
|
|
398
|
+
const upstreamResp = await fetch(upstreamUrl, {
|
|
399
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (!upstreamResp.ok) {
|
|
403
|
+
// Upstream failed — return defaults with configured flag so user can still try.
|
|
404
|
+
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
405
|
+
return Response.json(
|
|
406
|
+
{ models: defaults, configured: true, upstreamError: upstreamResp.status },
|
|
407
|
+
{
|
|
408
|
+
headers: { "Content-Type": "application/json" },
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const data = (await upstreamResp.json()) as { data?: ModelEntry[] } | ModelEntry[];
|
|
414
|
+
// OpenAI /models returns { object: "list", data: [{ id, ... }] }
|
|
415
|
+
// Map to our simpler format.
|
|
416
|
+
const rawModels: ModelEntry[] = Array.isArray(data)
|
|
417
|
+
? data
|
|
418
|
+
: Array.isArray(data.data)
|
|
419
|
+
? data.data
|
|
420
|
+
: [];
|
|
421
|
+
const models = rawModels.map(({ id, context_window, owned_by }) => ({
|
|
422
|
+
id,
|
|
423
|
+
name: id,
|
|
424
|
+
contextWindow: context_window || 0,
|
|
425
|
+
ownedBy: owned_by,
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
return Response.json(
|
|
429
|
+
{ models, configured: true },
|
|
430
|
+
{
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
} catch {
|
|
435
|
+
// Network error → return defaults.
|
|
436
|
+
const defaults = [{ id: "gpt-4o", name: "GPT-4o", contextWindow: 128_000 }];
|
|
437
|
+
return Response.json(
|
|
438
|
+
{ models: defaults, configured: true, upstreamError: "network" },
|
|
439
|
+
{
|
|
440
|
+
headers: { "Content-Type": "application/json" },
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─── Route handler ───────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Main handler for /__studio/ai/* requests.
|
|
450
|
+
*
|
|
451
|
+
* @returns Response if handled, null if route doesn't match
|
|
452
|
+
*/
|
|
453
|
+
export async function handleAiApi(req: Request, url: URL): Promise<Response | null> {
|
|
454
|
+
const { pathname } = url;
|
|
455
|
+
|
|
456
|
+
if (pathname === "/__studio/ai/chat" && req.method === "POST") {
|
|
457
|
+
return handleChat(req);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (pathname === "/__studio/ai/models" && req.method === "GET") {
|
|
461
|
+
return handleModels(req);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return null;
|
|
465
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { createWatcher, injectSSE } from "./watch.ts";
|
|
|
19
19
|
import { handleResolve, handleServerFunction } from "./resolve.ts";
|
|
20
20
|
import { handleStudioApi } from "./studio-api.ts";
|
|
21
21
|
import { handleCodeApi } from "./code-api.ts";
|
|
22
|
+
import { handleAiApi } from "./ai-api.ts";
|
|
22
23
|
import { existsSync, readFileSync } from "node:fs";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -252,6 +253,12 @@ export async function createDevServer(options: {
|
|
|
252
253
|
return Response.json({ ok: true, root: activeProjectRoot });
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
// AI proxy endpoints (/__studio/ai/chat, /__studio/ai/models)
|
|
257
|
+
const aiRes = await handleAiApi(req, url);
|
|
258
|
+
if (aiRes) {
|
|
259
|
+
return aiRes;
|
|
260
|
+
}
|
|
261
|
+
|
|
255
262
|
const codeRes = await handleCodeApi(req, url);
|
|
256
263
|
if (codeRes) {
|
|
257
264
|
return codeRes;
|
|
@@ -332,7 +339,11 @@ export async function createDevServer(options: {
|
|
|
332
339
|
return new Response("Not found", { status: 404 });
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
|
|
342
|
+
// Inject the live-reload script into served HTML — but NOT into the Studio editor.
|
|
343
|
+
// Studio manages its own state (open tabs, undo history, chat) and refreshes edited
|
|
344
|
+
// Files in-place; a blanket location.reload() would destroy that, e.g. when the AI
|
|
345
|
+
// Assistant writes a file matching a build glob inside the watched root.
|
|
346
|
+
if (handleSSE && path.endsWith(".html") && !path.startsWith("/packages/studio/")) {
|
|
336
347
|
const html = await file.text();
|
|
337
348
|
return new Response(injectSSE(html), {
|
|
338
349
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
@@ -343,6 +354,9 @@ export async function createDevServer(options: {
|
|
|
343
354
|
},
|
|
344
355
|
|
|
345
356
|
port,
|
|
357
|
+
// Keep SSE connections alive — heartbeats are every 15 s, and AI streaming can take
|
|
358
|
+
// 30+ s. The default 10 s idleTimeout kills them prematurely.
|
|
359
|
+
idleTimeout: 120,
|
|
346
360
|
});
|
|
347
361
|
|
|
348
362
|
console.log(`\n@jxsuite/server listening on http://localhost:${server.port}`);
|
package/src/studio-api.ts
CHANGED
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
setPackageVersions,
|
|
22
22
|
} from "./packages.ts";
|
|
23
23
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
24
|
-
import * as claude from "./claude-session.ts";
|
|
25
24
|
import type { ClassJsonDef } from "./types.ts";
|
|
26
25
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
27
26
|
|
|
@@ -1438,79 +1437,6 @@ export async function handleStudioApi(
|
|
|
1438
1437
|
}
|
|
1439
1438
|
}
|
|
1440
1439
|
|
|
1441
|
-
// ─── AI Assistant ─────────────────────────────────────────────────────────
|
|
1442
|
-
|
|
1443
|
-
if (path === "/__studio/ai/auth-status" && req.method === "GET") {
|
|
1444
|
-
const status = await claude.getAuthStatus();
|
|
1445
|
-
return Response.json(status);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
if (path === "/__studio/ai/session" && req.method === "POST") {
|
|
1449
|
-
try {
|
|
1450
|
-
const body = (await req.json()) as { message?: string; systemPrompt?: string };
|
|
1451
|
-
const projectDir = activeProjectRoot || root;
|
|
1452
|
-
const opts: { systemPrompt?: string } = {};
|
|
1453
|
-
if (body.systemPrompt !== undefined) {
|
|
1454
|
-
opts.systemPrompt = body.systemPrompt;
|
|
1455
|
-
}
|
|
1456
|
-
const result = claude.createSession(projectDir, body.message!, opts);
|
|
1457
|
-
return Response.json(result);
|
|
1458
|
-
} catch (error) {
|
|
1459
|
-
return Response.json(
|
|
1460
|
-
{ error: error instanceof Error ? error.message : String(error) },
|
|
1461
|
-
{ status: 500 },
|
|
1462
|
-
);
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
if (
|
|
1467
|
-
path.startsWith("/__studio/ai/session/") &&
|
|
1468
|
-
path.endsWith("/stream") &&
|
|
1469
|
-
req.method === "GET"
|
|
1470
|
-
) {
|
|
1471
|
-
const id = path.split("/").slice(4)[0]!;
|
|
1472
|
-
return claude.streamSession(id);
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
if (
|
|
1476
|
-
path.startsWith("/__studio/ai/session/") &&
|
|
1477
|
-
path.endsWith("/message") &&
|
|
1478
|
-
req.method === "POST"
|
|
1479
|
-
) {
|
|
1480
|
-
try {
|
|
1481
|
-
const id = path.split("/").slice(4)[0]!;
|
|
1482
|
-
const body = (await req.json()) as { message?: string };
|
|
1483
|
-
claude.sendMessage(id, body.message!);
|
|
1484
|
-
return Response.json({ ok: true });
|
|
1485
|
-
} catch (error) {
|
|
1486
|
-
return Response.json(
|
|
1487
|
-
{ error: error instanceof Error ? error.message : String(error) },
|
|
1488
|
-
{ status: 500 },
|
|
1489
|
-
);
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
if (path.startsWith("/__studio/ai/session/") && path.endsWith("/stop") && req.method === "POST") {
|
|
1494
|
-
const id = path.split("/").slice(4)[0]!;
|
|
1495
|
-
claude.stopSession(id);
|
|
1496
|
-
return Response.json({ ok: true });
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
if (path.startsWith("/__studio/ai/session/") && req.method === "DELETE") {
|
|
1500
|
-
const id = path.split("/").slice(4)[0]!;
|
|
1501
|
-
claude.deleteSession(id);
|
|
1502
|
-
return Response.json({ ok: true });
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
if (path.startsWith("/__studio/ai/session/") && req.method === "GET") {
|
|
1506
|
-
const id = path.split("/").slice(4)[0]!;
|
|
1507
|
-
const info = claude.getSession(id);
|
|
1508
|
-
if (!info) {
|
|
1509
|
-
return Response.json({ error: "Not found" }, { status: 404 });
|
|
1510
|
-
}
|
|
1511
|
-
return Response.json(info);
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
1440
|
return null;
|
|
1515
1441
|
}
|
|
1516
1442
|
|
package/src/watch.ts
CHANGED
|
@@ -16,6 +16,9 @@ const DEFAULT_IGNORE = [
|
|
|
16
16
|
"**/.direnv/**",
|
|
17
17
|
"**/bun.lockb",
|
|
18
18
|
"**/bun.lock",
|
|
19
|
+
// Bun test temporary directories — created/deleted transiently, cause EINVAL on
|
|
20
|
+
// Fs.watch when cleaned up mid-scan.
|
|
21
|
+
"**/__test-*/**",
|
|
19
22
|
];
|
|
20
23
|
|
|
21
24
|
/** @param {string} value */
|
|
@@ -186,5 +189,14 @@ export function createWatcher(
|
|
|
186
189
|
}, debounceMs);
|
|
187
190
|
});
|
|
188
191
|
|
|
192
|
+
// Gracefully handle watch errors (e.g. EINVAL on transient Bun test dirs).
|
|
193
|
+
watcher.on("error", (err) => {
|
|
194
|
+
const error = err as Error;
|
|
195
|
+
if (error.message?.includes("EINVAL")) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
console.error("[watch] chokidar error:", error.message ?? error);
|
|
199
|
+
});
|
|
200
|
+
|
|
189
201
|
return { broadcast, broadcastEvent, handleSSE, watcher };
|
|
190
202
|
}
|
package/src/claude-session.ts
DELETED
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Claude-session.js — Claude Agent SDK session manager
|
|
3
|
-
*
|
|
4
|
-
* Manages Claude Code sessions via @anthropic-ai/claude-agent-sdk, providing: - Session creation
|
|
5
|
-
* with streaming output - Follow-up messages via session resume - SSE broadcasting to connected
|
|
6
|
-
* frontend clients - Session interruption and cleanup
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
10
|
-
|
|
11
|
-
import type { Options as AgentOptions, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
12
|
-
|
|
13
|
-
interface Session {
|
|
14
|
-
id: string;
|
|
15
|
-
sessionId: string | null;
|
|
16
|
-
projectRoot: string;
|
|
17
|
-
messages: SDKMessage[];
|
|
18
|
-
sseClients: Set<(event: string, data: unknown) => void>;
|
|
19
|
-
status: "active" | "idle" | "stopped";
|
|
20
|
-
abortController: AbortController | null;
|
|
21
|
-
query: AsyncGenerator | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Build a clean env for SDK child processes. Strips session-specific vars from a parent Claude Code
|
|
26
|
-
* instance that would override the SDK's own credential resolution (OAuth from ~/.claude/).
|
|
27
|
-
*/
|
|
28
|
-
function buildSdkEnv() {
|
|
29
|
-
const env = { ...process.env };
|
|
30
|
-
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
31
|
-
delete env.ANTHROPIC_BASE_URL;
|
|
32
|
-
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
33
|
-
delete env.CLAUDE_CODE_EXECPATH;
|
|
34
|
-
delete env.CLAUDE_AGENT_SDK_VERSION;
|
|
35
|
-
delete env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
|
36
|
-
delete env.CLAUDECODE;
|
|
37
|
-
env.CLAUDE_AGENT_SDK_CLIENT_APP = "jx-studio/0.17.0";
|
|
38
|
-
return env;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const sessions = new Map<string, Session>();
|
|
42
|
-
|
|
43
|
-
let idCounter = 0;
|
|
44
|
-
|
|
45
|
-
function genId() {
|
|
46
|
-
return `ai_${Date.now().toString(36)}_${(idCounter += 1).toString(36)}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Broadcast an SSE event to all connected clients for a session.
|
|
51
|
-
*
|
|
52
|
-
* @param {Session} session
|
|
53
|
-
* @param {string} event
|
|
54
|
-
* @param {unknown} data
|
|
55
|
-
*/
|
|
56
|
-
function broadcast(session: Session, event: string, data: unknown) {
|
|
57
|
-
session.messages.push(data as SDKMessage);
|
|
58
|
-
for (const send of session.sseClients) {
|
|
59
|
-
send(event, data);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Process the async message stream from a query.
|
|
65
|
-
*
|
|
66
|
-
* @param {Session} session
|
|
67
|
-
* @param {AsyncGenerator<SDKMessage>} stream
|
|
68
|
-
*/
|
|
69
|
-
async function processStream(session: Session, stream: AsyncGenerator<SDKMessage>) {
|
|
70
|
-
session.status = "active";
|
|
71
|
-
try {
|
|
72
|
-
for await (const message of stream) {
|
|
73
|
-
if ((session.status as string) === "stopped") {
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
if (message.session_id && !session.sessionId) {
|
|
77
|
-
session.sessionId = message.session_id;
|
|
78
|
-
}
|
|
79
|
-
broadcast(session, message.type, message);
|
|
80
|
-
}
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (error instanceof Error && error.name !== "AbortError") {
|
|
83
|
-
broadcast(session, "error", { error: String(error), type: "error" });
|
|
84
|
-
}
|
|
85
|
-
} finally {
|
|
86
|
-
session.status = "idle";
|
|
87
|
-
session.query = null;
|
|
88
|
-
session.abortController = null;
|
|
89
|
-
broadcast(session, "done", { type: "done" });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Create a new AI session and begin streaming.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} projectRoot
|
|
97
|
-
* @param {string} message
|
|
98
|
-
* @param {{ systemPrompt?: string }} [opts]
|
|
99
|
-
* @returns {{ id: string }}
|
|
100
|
-
*/
|
|
101
|
-
export function createSession(
|
|
102
|
-
projectRoot: string,
|
|
103
|
-
message: string,
|
|
104
|
-
opts: { systemPrompt?: string } = {},
|
|
105
|
-
) {
|
|
106
|
-
const id = genId();
|
|
107
|
-
const abortController = new AbortController();
|
|
108
|
-
|
|
109
|
-
const session: Session = {
|
|
110
|
-
abortController,
|
|
111
|
-
id,
|
|
112
|
-
messages: [],
|
|
113
|
-
projectRoot,
|
|
114
|
-
query: null,
|
|
115
|
-
sessionId: null,
|
|
116
|
-
sseClients: new Set(),
|
|
117
|
-
status: "active",
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
sessions.set(id, session);
|
|
121
|
-
|
|
122
|
-
const queryOpts: AgentOptions = {
|
|
123
|
-
abortController,
|
|
124
|
-
allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
|
|
125
|
-
cwd: projectRoot,
|
|
126
|
-
env: buildSdkEnv(),
|
|
127
|
-
includePartialMessages: true,
|
|
128
|
-
maxTurns: 30,
|
|
129
|
-
permissionMode: "acceptEdits",
|
|
130
|
-
persistSession: true,
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
if (opts.systemPrompt) {
|
|
134
|
-
queryOpts.systemPrompt = opts.systemPrompt;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const stream = query({ options: queryOpts, prompt: message });
|
|
138
|
-
session.query = stream;
|
|
139
|
-
void processStream(session, stream);
|
|
140
|
-
|
|
141
|
-
return { id };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Send a follow-up message to an existing session.
|
|
146
|
-
*
|
|
147
|
-
* @param {string} id
|
|
148
|
-
* @param {string} message
|
|
149
|
-
*/
|
|
150
|
-
export function sendMessage(id: string, message: string) {
|
|
151
|
-
const session = sessions.get(id);
|
|
152
|
-
if (!session) {
|
|
153
|
-
throw new Error(`Session not found: ${id}`);
|
|
154
|
-
}
|
|
155
|
-
if (session.status === "active") {
|
|
156
|
-
throw new Error("Session is still processing");
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const abortController = new AbortController();
|
|
160
|
-
session.abortController = abortController;
|
|
161
|
-
|
|
162
|
-
const queryOpts: AgentOptions = {
|
|
163
|
-
abortController,
|
|
164
|
-
allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
|
|
165
|
-
continue: true,
|
|
166
|
-
cwd: session.projectRoot,
|
|
167
|
-
env: buildSdkEnv(),
|
|
168
|
-
includePartialMessages: true,
|
|
169
|
-
maxTurns: 30,
|
|
170
|
-
permissionMode: "acceptEdits",
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
if (session.sessionId) {
|
|
174
|
-
queryOpts.resume = session.sessionId;
|
|
175
|
-
delete queryOpts.continue;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const stream = query({ options: queryOpts, prompt: message });
|
|
179
|
-
session.query = stream;
|
|
180
|
-
void processStream(session, stream);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Stop a running session (interrupt the current query).
|
|
185
|
-
*
|
|
186
|
-
* @param {string} id
|
|
187
|
-
*/
|
|
188
|
-
export function stopSession(id: string) {
|
|
189
|
-
const session = sessions.get(id);
|
|
190
|
-
if (!session) {
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
session.status = "stopped";
|
|
194
|
-
if (session.abortController) {
|
|
195
|
-
session.abortController.abort();
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Remove a session entirely.
|
|
201
|
-
*
|
|
202
|
-
* @param {string} id
|
|
203
|
-
*/
|
|
204
|
-
export function deleteSession(id: string) {
|
|
205
|
-
stopSession(id);
|
|
206
|
-
sessions.delete(id);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Get an SSE Response stream for a session.
|
|
211
|
-
*
|
|
212
|
-
* @param {string} id
|
|
213
|
-
* @returns {Response}
|
|
214
|
-
*/
|
|
215
|
-
export function streamSession(id: string) {
|
|
216
|
-
const session = sessions.get(id);
|
|
217
|
-
if (!session) {
|
|
218
|
-
return new Response("Session not found", { status: 404 });
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const encoder = new TextEncoder();
|
|
222
|
-
let sendRef: (event: string, data: unknown) => void = () => {};
|
|
223
|
-
const stream = new ReadableStream({
|
|
224
|
-
cancel() {
|
|
225
|
-
session.sseClients.delete(sendRef);
|
|
226
|
-
},
|
|
227
|
-
start(controller) {
|
|
228
|
-
/** @param {string} event @param {unknown} data */
|
|
229
|
-
sendRef = (event: string, data: unknown) => {
|
|
230
|
-
try {
|
|
231
|
-
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
232
|
-
} catch {
|
|
233
|
-
// Stream closed
|
|
234
|
-
}
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
session.sseClients.add(sendRef);
|
|
238
|
-
|
|
239
|
-
for (const msg of session.messages) {
|
|
240
|
-
sendRef(((msg as Record<string, unknown>).type as string) || "message", msg);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const heartbeat = setInterval(() => {
|
|
244
|
-
try {
|
|
245
|
-
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
|
246
|
-
} catch {
|
|
247
|
-
clearInterval(heartbeat);
|
|
248
|
-
session.sseClients.delete(sendRef);
|
|
249
|
-
}
|
|
250
|
-
}, 15_000);
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
return new Response(stream, {
|
|
255
|
-
headers: {
|
|
256
|
-
"Cache-Control": "no-cache",
|
|
257
|
-
Connection: "keep-alive",
|
|
258
|
-
"Content-Type": "text/event-stream",
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Check if the claude agent SDK can authenticate.
|
|
265
|
-
*
|
|
266
|
-
* @returns {Promise<{ authenticated: boolean; error?: string }>}
|
|
267
|
-
*/
|
|
268
|
-
export async function getAuthStatus() {
|
|
269
|
-
try {
|
|
270
|
-
const ctrl = new AbortController();
|
|
271
|
-
const timeout = setTimeout(() => ctrl.abort(), 15_000);
|
|
272
|
-
const testStream = query({
|
|
273
|
-
options: {
|
|
274
|
-
abortController: ctrl,
|
|
275
|
-
env: buildSdkEnv(),
|
|
276
|
-
includePartialMessages: true,
|
|
277
|
-
maxTurns: 1,
|
|
278
|
-
persistSession: false,
|
|
279
|
-
},
|
|
280
|
-
prompt: "Say OK",
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
for await (const msg of testStream) {
|
|
284
|
-
if (msg.type === "assistant") {
|
|
285
|
-
const content = (msg as Record<string, unknown>).message as
|
|
286
|
-
| { content?: unknown[] }
|
|
287
|
-
| undefined;
|
|
288
|
-
if (Array.isArray(content?.content)) {
|
|
289
|
-
const text = content.content.find(
|
|
290
|
-
(b: unknown) => (b as { type?: string }).type === "text",
|
|
291
|
-
) as { text?: string } | undefined;
|
|
292
|
-
if (text?.text?.toLowerCase().includes("error")) {
|
|
293
|
-
clearTimeout(timeout);
|
|
294
|
-
ctrl.abort();
|
|
295
|
-
return { authenticated: false, error: text.text };
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
clearTimeout(timeout);
|
|
299
|
-
ctrl.abort();
|
|
300
|
-
return { authenticated: true };
|
|
301
|
-
}
|
|
302
|
-
if (msg.type === "result") {
|
|
303
|
-
const result = msg as Record<string, unknown>;
|
|
304
|
-
clearTimeout(timeout);
|
|
305
|
-
ctrl.abort();
|
|
306
|
-
if (result.is_error) {
|
|
307
|
-
return {
|
|
308
|
-
authenticated: false,
|
|
309
|
-
error: (result.result as string) || "API error",
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
return { authenticated: true };
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
clearTimeout(timeout);
|
|
317
|
-
return { authenticated: true };
|
|
318
|
-
} catch (error) {
|
|
319
|
-
return { authenticated: false, error: String(error) };
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Get session info.
|
|
325
|
-
*
|
|
326
|
-
* @param {string} id
|
|
327
|
-
*/
|
|
328
|
-
export function getSession(id: string) {
|
|
329
|
-
const session = sessions.get(id);
|
|
330
|
-
if (!session) {
|
|
331
|
-
return null;
|
|
332
|
-
}
|
|
333
|
-
return {
|
|
334
|
-
id: session.id,
|
|
335
|
-
messageCount: session.messages.length,
|
|
336
|
-
status: session.status,
|
|
337
|
-
};
|
|
338
|
-
}
|