@evandu/super-pi 0.1.137 → 0.1.139
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/agents/super-pi-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
- package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
- package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
- package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
- package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
- package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
- package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
- package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
- package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
- package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
- package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
- package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
- package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
- package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
- package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
- package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
- package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
- package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
- package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
- package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
- package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
- package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
- package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
- package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
- package/dist/client/assets/index-Bdym8BPo.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1455 -1412
- package/dist/server/index.js +3 -4
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +47 -0
- package/dist/server/runtime.js +183 -84
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.js +18 -7
- package/dist/server/subagents.js +7 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +27 -0
- package/dist/server/ws-server.d.ts +34 -5
- package/dist/server/ws-server.js +170 -46
- package/package.json +15 -1
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { readdir, rm, stat } from "node:fs/promises";
|
|
4
5
|
import { createServer } from "node:http";
|
|
6
|
+
import { gzip } from "node:zlib";
|
|
7
|
+
import { promisify } from "node:util";
|
|
5
8
|
import { homedir, networkInterfaces } from "node:os";
|
|
6
9
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
10
|
import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
|
|
@@ -276,258 +279,173 @@ export function createHttpServer(options) {
|
|
|
276
279
|
const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry, toolRunner } = context;
|
|
277
280
|
const { uploadsDir, clientDir, rootDir } = paths;
|
|
278
281
|
return createServer(async (request, response) => {
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (!id) {
|
|
315
|
-
json(400, { error: "缺少工具 id。" });
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const exported = await toolRegistry.exportBundle(id);
|
|
319
|
-
if (!exported) {
|
|
320
|
-
json(404, { error: "找不到这个工具。" });
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
json(200, exported);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
catch (error) {
|
|
327
|
-
json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
|
|
282
|
+
// Every HTTP request gets one stable id. It is returned to the browser and
|
|
283
|
+
// included in the server log, so a report containing a status code or a
|
|
284
|
+
// timeout can be joined to the exact failing request without guessing from
|
|
285
|
+
// timestamps. Do not echo arbitrary input into a header.
|
|
286
|
+
const suppliedRequestId = request.headers["x-request-id"];
|
|
287
|
+
const requestId = typeof suppliedRequestId === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(suppliedRequestId)
|
|
288
|
+
? suppliedRequestId
|
|
289
|
+
: randomUUID();
|
|
290
|
+
response.setHeader("x-request-id", requestId);
|
|
291
|
+
try {
|
|
292
|
+
// Internal tools use a narrow per-run capability, never the global management token.
|
|
293
|
+
const requestPath = request.url?.split("?")[0] ?? "";
|
|
294
|
+
// Tool REGISTRATION is checked after its JSON body is parsed: an agent proves its turn capability
|
|
295
|
+
// there, while an interactive browser uses the ordinary server access token. Reading and DELETING
|
|
296
|
+
// the registry have no such second channel, so they are NOT exempted here and fall through to the
|
|
297
|
+
// ordinary token check below - exempting the whole path is what let any local process list and
|
|
298
|
+
// delete a person's mounted tools on a server that had `SUPER_PI_ACCESS_TOKEN` set.
|
|
299
|
+
//
|
|
300
|
+
// `/api/knowledge/tool` is the same shape as `/api/memory/tool` and carries the same exemption for
|
|
301
|
+
// the same reason: it is reached only by the Pi process of one turn, carrying that turn's
|
|
302
|
+
// capability in a header. The MANAGEMENT half (`/api/knowledge`) is deliberately NOT exempted - it
|
|
303
|
+
// imports and deletes documents, so it stays behind the ordinary token.
|
|
304
|
+
//
|
|
305
|
+
// `/api/documents/tool` is the same shape again, and the exemption is safe for the same reason:
|
|
306
|
+
// the endpoint verifies the SESSION's capability on its own, and refuses a request whose turn is
|
|
307
|
+
// not running. The global token is a server-wide secret; the capability is a per-conversation
|
|
308
|
+
// one that only the Pi process of that conversation holds, so the exemption does not widen who
|
|
309
|
+
// can reach the server - it only spares the one caller that already proves who it is.
|
|
310
|
+
//
|
|
311
|
+
// `/api/feishu/tool` is that shape once more. The Feishu credential is a company secret, so
|
|
312
|
+
// the endpoint that can spend it is exactly the one an agent's own turn can reach, and the
|
|
313
|
+
// management half (`/api/feishu/config`) stays behind the ordinary token AND the local-address
|
|
314
|
+
// predicate.
|
|
315
|
+
if (requestPath !== "/api/memory/tool" && requestPath !== "/api/knowledge/tool" && requestPath !== "/api/documents/tool" && requestPath !== "/api/web/tool" && requestPath !== "/api/feishu/tool" && requestPath !== "/api/todos" && requestPath !== "/api/scheduled-tasks" && requestPath !== "/api/question" && (requestPath !== "/api/tools" || request.method !== "POST") && !requestPath.startsWith("/tools/") && !isAuthorized(request, accessToken)) {
|
|
316
|
+
response.writeHead(401, { "www-authenticate": "Bearer" }).end("Authentication required");
|
|
328
317
|
return;
|
|
329
318
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
if (request.method === "DELETE") {
|
|
319
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
|
|
320
|
+
const remote = request.socket.remoteAddress ?? "";
|
|
321
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(body)); };
|
|
322
|
+
if (url.pathname === "/api/tools/export") {
|
|
323
|
+
// NOT EXEMPT FROM THE TOKEN CHECK ABOVE, and that is deliberate: an export hands out a tool's
|
|
324
|
+
// whole source, so it is a management read exactly like GET and DELETE, not a capability call.
|
|
325
|
+
try {
|
|
341
326
|
const id = url.searchParams.get("id");
|
|
342
327
|
if (!id) {
|
|
343
328
|
json(400, { error: "缺少工具 id。" });
|
|
344
329
|
return;
|
|
345
330
|
}
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (removed)
|
|
353
|
-
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
354
|
-
json(removed ? 200 : 404, { ok: removed });
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
if (request.method !== "POST") {
|
|
358
|
-
json(405, { error: "这个接口只接受 POST 请求。" });
|
|
331
|
+
const exported = await toolRegistry.exportBundle(id);
|
|
332
|
+
if (!exported) {
|
|
333
|
+
json(404, { error: "找不到这个工具。" });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
json(200, exported);
|
|
359
337
|
return;
|
|
360
338
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// where the real ceiling was and an exported bundle could be refused on the way back in.
|
|
364
|
-
const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
|
|
365
|
-
if (imported.tooLarge) {
|
|
366
|
-
json(413, { error: "导入的内容太大,超过了大小上限。" });
|
|
339
|
+
catch (error) {
|
|
340
|
+
json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
|
|
367
341
|
return;
|
|
368
342
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
|
|
343
|
+
}
|
|
344
|
+
if (url.pathname === "/api/tools") {
|
|
345
|
+
try {
|
|
346
|
+
if (request.method === "GET") {
|
|
347
|
+
// The environment state is DERIVED per request and deliberately not stored: it changes when
|
|
348
|
+
// the user installs Python, when a tool's venv is built, and when nothing at all happens.
|
|
349
|
+
const tools = await toolRegistry.list();
|
|
350
|
+
json(200, { tools: await Promise.all(tools.map(async (tool) => ({ ...tool, environmentState: await toolRunner.environmentState(tool) }))) });
|
|
374
351
|
return;
|
|
375
352
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
353
|
+
if (request.method === "DELETE") {
|
|
354
|
+
const id = url.searchParams.get("id");
|
|
355
|
+
if (!id) {
|
|
356
|
+
json(400, { error: "缺少工具 id。" });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const removed = await toolRegistry.remove(id);
|
|
360
|
+
// The venv is machine-local state that belongs to that tool and nothing else, so it goes with
|
|
361
|
+
// it. Leaving it behind would mean a re-registered tool of the same slug silently inherits a
|
|
362
|
+
// stale environment.
|
|
363
|
+
if (removed)
|
|
364
|
+
await rm(toolRunner.envDir(id), { recursive: true, force: true });
|
|
365
|
+
if (removed)
|
|
366
|
+
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
367
|
+
json(removed ? 200 : 404, { ok: removed });
|
|
379
368
|
return;
|
|
380
369
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
|
|
396
|
-
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
397
|
-
json(201, tool);
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
catch (error) {
|
|
401
|
-
json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
if (url.pathname.startsWith("/tools/")) {
|
|
406
|
-
let id;
|
|
407
|
-
let resource;
|
|
408
|
-
try {
|
|
409
|
-
const parts = url.pathname.slice("/tools/".length).split("/");
|
|
410
|
-
id = decodeURIComponent(parts.shift() ?? "");
|
|
411
|
-
resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
|
|
412
|
-
}
|
|
413
|
-
catch {
|
|
414
|
-
response.writeHead(400).end("Malformed tool resource");
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
|
|
418
|
-
if (!listed) {
|
|
419
|
-
response.writeHead(404).end("Tool not found");
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
// A mounted tool never learns its random registry id. Its API is instead addressed relative
|
|
423
|
-
// to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
|
|
424
|
-
// making a generated tool usable on its first render, this binds status and cancellation to the
|
|
425
|
-
// same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
|
|
426
|
-
const toolOrigin = request.headers.origin === "null";
|
|
427
|
-
const toolApi = (status, body) => {
|
|
428
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
|
|
429
|
-
response.end(JSON.stringify(body));
|
|
430
|
-
};
|
|
431
|
-
if (resource === "api/run") {
|
|
432
|
-
if (!toolOrigin) {
|
|
433
|
-
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
if (request.method === "OPTIONS") {
|
|
437
|
-
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
if (request.method !== "POST") {
|
|
441
|
-
toolApi(405, { error: "method" });
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
const executed = await readRequestText(request, 64_000);
|
|
445
|
-
if (executed.tooLarge) {
|
|
446
|
-
toolApi(413, { error: "request too large" });
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const raw = executed.text;
|
|
450
|
-
try {
|
|
370
|
+
if (request.method !== "POST") {
|
|
371
|
+
json(405, { error: "这个接口只接受 POST 请求。" });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
|
|
375
|
+
// units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
|
|
376
|
+
// where the real ceiling was and an exported bundle could be refused on the way back in.
|
|
377
|
+
const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
|
|
378
|
+
if (imported.tooLarge) {
|
|
379
|
+
json(413, { error: "导入的内容太大,超过了大小上限。" });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const raw = imported.text;
|
|
451
383
|
const body = JSON.parse(raw);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
384
|
+
if (!isAuthorized(request, accessToken)) {
|
|
385
|
+
if (!body.conversationId) {
|
|
386
|
+
json(401, { error: "需要访问凭据,或正在运行的会话凭据。" });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
|
|
390
|
+
if (!verdict.ok) {
|
|
391
|
+
json(403, { error: verdict.message });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!body.slug || !body.name || !Array.isArray(body.files)) {
|
|
396
|
+
json(400, { error: "导入需要 slug、名称和文件列表。" });
|
|
455
397
|
return;
|
|
456
398
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
399
|
+
// AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
|
|
400
|
+
// and verified, and overwriting it with the importer's own machine would erase the very
|
|
401
|
+
// difference the tool list exists to show. A tool with no fingerprint gets one measured here.
|
|
402
|
+
let environment = body.environment;
|
|
403
|
+
if (environment && !environment.fingerprint) {
|
|
404
|
+
const local = await toolRunner.describeLocalPython();
|
|
405
|
+
if (local)
|
|
406
|
+
environment = { ...environment, fingerprint: { python: local.python, platform: local.platform, packages: environment.requires } };
|
|
407
|
+
}
|
|
408
|
+
const tool = await toolRegistry.register({ slug: body.slug, name: body.name, description: body.description, entrypoint: body.entrypoint, script: body.script, environment, permissions: body.permissions, files: body.files });
|
|
409
|
+
broadcaster.broadcast({ type: "tools.updated", tools: await toolRegistry.list() });
|
|
410
|
+
json(201, tool);
|
|
469
411
|
return;
|
|
470
412
|
}
|
|
471
|
-
|
|
472
|
-
|
|
413
|
+
catch (error) {
|
|
414
|
+
json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
|
|
473
415
|
return;
|
|
474
416
|
}
|
|
475
|
-
|
|
417
|
+
}
|
|
418
|
+
if (url.pathname.startsWith("/tools/")) {
|
|
419
|
+
let id;
|
|
420
|
+
let resource;
|
|
476
421
|
try {
|
|
477
|
-
|
|
422
|
+
const parts = url.pathname.slice("/tools/".length).split("/");
|
|
423
|
+
id = decodeURIComponent(parts.shift() ?? "");
|
|
424
|
+
resource = parts.filter(Boolean).map(decodeURIComponent).join("/");
|
|
478
425
|
}
|
|
479
426
|
catch {
|
|
480
|
-
|
|
427
|
+
response.writeHead(400).end("Malformed tool resource");
|
|
481
428
|
return;
|
|
482
429
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
toolApi(404, { error: "run not found" });
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
toolApi(200, run);
|
|
430
|
+
const listed = (await toolRegistry.list()).find((x) => x.id === id && x.enabled);
|
|
431
|
+
if (!listed) {
|
|
432
|
+
response.writeHead(404).end("Tool not found");
|
|
490
433
|
return;
|
|
491
434
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
435
|
+
// A mounted tool never learns its random registry id. Its API is instead addressed relative
|
|
436
|
+
// to its own mount (`./api/run`), and this route supplies that identity from the URL. Besides
|
|
437
|
+
// making a generated tool usable on its first render, this binds status and cancellation to the
|
|
438
|
+
// same tool that created a run: an opaque frame for tool A cannot inspect tool B's run ids.
|
|
439
|
+
const toolOrigin = request.headers.origin === "null";
|
|
440
|
+
const toolApi = (status, body) => {
|
|
441
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "access-control-allow-origin": "null", "vary": "Origin" });
|
|
442
|
+
response.end(JSON.stringify(body));
|
|
443
|
+
};
|
|
444
|
+
if (resource === "api/run") {
|
|
445
|
+
if (!toolOrigin) {
|
|
446
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
496
447
|
return;
|
|
497
448
|
}
|
|
498
|
-
toolApi(200, run);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
toolApi(405, { error: "method" });
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
// ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
|
|
505
|
-
// A tool asking the model a question, over the same relative API as its Python jobs, so the frame
|
|
506
|
-
// still never learns an id, a path or a credential. Three gates, in this order:
|
|
507
|
-
// 1. the opaque sandbox origin - identical to every other tool API here;
|
|
508
|
-
// 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
|
|
509
|
-
// the person before they accepted the file. A tool that quietly started asking the model
|
|
510
|
-
// would be spending the user's quota from inside a sandbox they were told was isolated;
|
|
511
|
-
// 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
|
|
512
|
-
const llmStart = /^api\/llm$/.exec(resource);
|
|
513
|
-
const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
|
|
514
|
-
if (llmStart || llmRun) {
|
|
515
|
-
if (!toolOrigin) {
|
|
516
|
-
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
// GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
|
|
520
|
-
// depend on whether this machine happens to have a model, so a tool with no grant gets the same
|
|
521
|
-
// 403 whether or not Pi is installed.
|
|
522
|
-
if (!listed.permissions?.llm) {
|
|
523
|
-
toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
if (!context.toolLlm?.available) {
|
|
527
|
-
toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
if (llmStart) {
|
|
531
449
|
if (request.method === "OPTIONS") {
|
|
532
450
|
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
533
451
|
return;
|
|
@@ -536,1378 +454,1503 @@ export function createHttpServer(options) {
|
|
|
536
454
|
toolApi(405, { error: "method" });
|
|
537
455
|
return;
|
|
538
456
|
}
|
|
539
|
-
const
|
|
540
|
-
if (
|
|
457
|
+
const executed = await readRequestText(request, 64_000);
|
|
458
|
+
if (executed.tooLarge) {
|
|
541
459
|
toolApi(413, { error: "request too large" });
|
|
542
460
|
return;
|
|
543
461
|
}
|
|
544
|
-
const raw =
|
|
462
|
+
const raw = executed.text;
|
|
545
463
|
try {
|
|
546
464
|
const body = JSON.parse(raw);
|
|
547
|
-
|
|
465
|
+
const resolved = await toolRegistry.resolve(id);
|
|
466
|
+
if (!resolved?.tool.script) {
|
|
467
|
+
toolApi(404, { error: "python script is not registered" });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const run = toolRunner.start({ toolId: id, script: resolve(resolved.root, resolved.tool.script.path), cwd: resolved.root, definition: resolved.tool.script, params: body.params ?? {}, environment: resolved.tool.environment });
|
|
471
|
+
toolApi(202, run);
|
|
548
472
|
}
|
|
549
473
|
catch (error) {
|
|
550
|
-
toolApi(400, { error: describeThrown(error) || "
|
|
474
|
+
toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
|
|
551
475
|
}
|
|
552
476
|
return;
|
|
553
477
|
}
|
|
554
|
-
|
|
555
|
-
|
|
478
|
+
const runMatch = /^api\/runs\/([^/]+)$/.exec(resource);
|
|
479
|
+
if (runMatch) {
|
|
480
|
+
if (!toolOrigin) {
|
|
481
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (request.method === "OPTIONS") {
|
|
485
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
let runId;
|
|
489
|
+
try {
|
|
490
|
+
runId = decodeURIComponent(runMatch[1]);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (request.method === "GET") {
|
|
497
|
+
const run = toolRunner.get(runId, id);
|
|
498
|
+
if (!run) {
|
|
499
|
+
toolApi(404, { error: "run not found" });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
toolApi(200, run);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (request.method === "DELETE") {
|
|
506
|
+
const run = toolRunner.cancel(runId, id);
|
|
507
|
+
if (!run) {
|
|
508
|
+
toolApi(404, { error: "run not found" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
toolApi(200, run);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
toolApi(405, { error: "method" });
|
|
556
515
|
return;
|
|
557
516
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
517
|
+
// ---- THE MODEL, ON THE TOOL'S OWN BEHALF ---------------------------------------------------
|
|
518
|
+
// A tool asking the model a question, over the same relative API as its Python jobs, so the frame
|
|
519
|
+
// still never learns an id, a path or a credential. Three gates, in this order:
|
|
520
|
+
// 1. the opaque sandbox origin - identical to every other tool API here;
|
|
521
|
+
// 2. the tool must CARRY the grant (`permissions.llm`), which is the thing the review card showed
|
|
522
|
+
// the person before they accepted the file. A tool that quietly started asking the model
|
|
523
|
+
// would be spending the user's quota from inside a sandbox they were told was isolated;
|
|
524
|
+
// 3. the run is bound to this tool id, so one frame cannot read or cancel another's answer.
|
|
525
|
+
const llmStart = /^api\/llm$/.exec(resource);
|
|
526
|
+
const llmRun = /^api\/llm\/([^/]+)$/.exec(resource);
|
|
527
|
+
if (llmStart || llmRun) {
|
|
528
|
+
if (!toolOrigin) {
|
|
529
|
+
toolApi(403, { error: "tool runtime API is only available to the sandboxed tool frame" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// GRANT FIRST, THEN RUNTIME. What a tool is allowed to ask is a fact about the tool and does not
|
|
533
|
+
// depend on whether this machine happens to have a model, so a tool with no grant gets the same
|
|
534
|
+
// 403 whether or not Pi is installed.
|
|
535
|
+
if (!listed.permissions?.llm) {
|
|
536
|
+
toolApi(403, { error: "这个工具没有调用模型的权限;注册或导入时它必须声明 permissions: { llm: true }。" });
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!context.toolLlm?.available) {
|
|
540
|
+
toolApi(503, { error: "这台机器上没有可用的 Pi 运行时,工具无法调用模型。" });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (llmStart) {
|
|
544
|
+
if (request.method === "OPTIONS") {
|
|
545
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (request.method !== "POST") {
|
|
549
|
+
toolApi(405, { error: "method" });
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const asked = await readRequestText(request, 64_000);
|
|
553
|
+
if (asked.tooLarge) {
|
|
554
|
+
toolApi(413, { error: "request too large" });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const raw = asked.text;
|
|
558
|
+
try {
|
|
559
|
+
const body = JSON.parse(raw);
|
|
560
|
+
toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (request.method === "OPTIONS") {
|
|
568
|
+
response.writeHead(204, { "access-control-allow-origin": "null", "access-control-allow-methods": "GET, DELETE", "access-control-allow-headers": "content-type", "access-control-max-age": "300", "vary": "Origin" }).end();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
let llmId;
|
|
572
|
+
try {
|
|
573
|
+
llmId = decodeURIComponent(llmRun[1]);
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
toolApi(400, { error: "Malformed tool resource" });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (request.method === "GET") {
|
|
580
|
+
const run = context.toolLlm.get(llmId, id);
|
|
581
|
+
if (!run) {
|
|
582
|
+
toolApi(404, { error: "model call not found" });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
toolApi(200, run);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (request.method === "DELETE") {
|
|
589
|
+
const run = context.toolLlm.cancel(llmId, id);
|
|
590
|
+
if (!run) {
|
|
591
|
+
toolApi(404, { error: "model call not found" });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
toolApi(200, run);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
toolApi(405, { error: "method" });
|
|
598
|
+
return;
|
|
561
599
|
}
|
|
562
|
-
|
|
563
|
-
|
|
600
|
+
if (resource === "super-pi-tool.js") {
|
|
601
|
+
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
|
|
564
602
|
return;
|
|
565
603
|
}
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
if (!
|
|
569
|
-
|
|
604
|
+
try {
|
|
605
|
+
const resolved = await toolRegistry.resolve(id, resource);
|
|
606
|
+
if (!resolved) {
|
|
607
|
+
response.writeHead(404).end("Tool not found");
|
|
570
608
|
return;
|
|
571
609
|
}
|
|
572
|
-
|
|
610
|
+
// Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
|
|
611
|
+
// `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
|
|
612
|
+
// does not authorize a request back to this server, even for a relative URL.
|
|
613
|
+
const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
|
|
614
|
+
response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
|
|
615
|
+
createReadStream(resolved.file).pipe(response);
|
|
573
616
|
return;
|
|
574
617
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (!run) {
|
|
578
|
-
toolApi(404, { error: "model call not found" });
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
toolApi(200, run);
|
|
618
|
+
catch {
|
|
619
|
+
response.writeHead(404).end("Tool file not found");
|
|
582
620
|
return;
|
|
583
621
|
}
|
|
584
|
-
toolApi(405, { error: "method" });
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
if (resource === "super-pi-tool.js") {
|
|
588
|
-
response.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" }).end(toolBrowserSdk());
|
|
589
|
-
return;
|
|
590
622
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
623
|
+
/**
|
|
624
|
+
* THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
|
|
625
|
+
*
|
|
626
|
+
* Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
|
|
627
|
+
* is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
|
|
628
|
+
* predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
|
|
629
|
+
* There is consequently no code path from an HTTP request to a spawned shell, which is the property
|
|
630
|
+
* worth having: the browser's own origin could not reach it even with a forged request.
|
|
631
|
+
*/
|
|
632
|
+
if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
|
|
633
|
+
if (!context.memory) {
|
|
634
|
+
response.writeHead(503).end();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
|
|
638
|
+
response.writeHead(405).end();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const origin = request.headers.origin;
|
|
642
|
+
if (origin && origin !== url.origin) {
|
|
643
|
+
response.writeHead(403).end("Origin not allowed");
|
|
595
644
|
return;
|
|
596
645
|
}
|
|
597
|
-
// Mounted tools run in an opaque sandbox origin (the iframe deliberately omits
|
|
598
|
-
// `allow-same-origin`). In that context CSP's `'self'` is the opaque origin and
|
|
599
|
-
// does not authorize a request back to this server, even for a relative URL.
|
|
600
|
-
const policy = "default-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'";
|
|
601
|
-
response.writeHead(200, { "content-type": MIME_TYPES[extname(resolved.file)] ?? "application/octet-stream", "cache-control": "no-store", "content-security-policy": policy });
|
|
602
|
-
createReadStream(resolved.file).pipe(response);
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
catch {
|
|
606
|
-
response.writeHead(404).end("Tool file not found");
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* THE TERMINAL IS NOT AN API ENDPOINT, and the routing above deliberately cannot reach one.
|
|
612
|
-
*
|
|
613
|
-
* Every command family in `CommandHandler` is answerable over plain HTTP except the terminal, which
|
|
614
|
-
* is WebSocket-only (`terminal.*` messages). `serverContext` therefore carries only the two
|
|
615
|
-
* predicates `/api/terminal` needs - is it on, and if not why - and NOT a handle on `TerminalService`.
|
|
616
|
-
* There is consequently no code path from an HTTP request to a spawned shell, which is the property
|
|
617
|
-
* worth having: the browser's own origin could not reach it even with a forged request.
|
|
618
|
-
*/
|
|
619
|
-
if (url.pathname === "/api/memory/tool" || url.pathname === "/api/memory") {
|
|
620
|
-
if (!context.memory) {
|
|
621
|
-
response.writeHead(503).end();
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
|
|
625
|
-
response.writeHead(405).end();
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
const origin = request.headers.origin;
|
|
629
|
-
if (origin && origin !== url.origin) {
|
|
630
|
-
response.writeHead(403).end("Origin not allowed");
|
|
631
|
-
return;
|
|
632
|
-
}
|
|
633
|
-
try {
|
|
634
|
-
const remembered = await readRequestText(request, 512000);
|
|
635
|
-
if (remembered.tooLarge)
|
|
636
|
-
throw new Error("记忆请求过大");
|
|
637
|
-
const raw = remembered.text;
|
|
638
|
-
const body = raw ? JSON.parse(raw) : {};
|
|
639
|
-
const result = url.pathname.endsWith("/tool")
|
|
640
|
-
? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
|
|
641
|
-
: await context.memory.manage(url.searchParams.get("workspaceId") ?? "", body);
|
|
642
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
646
|
-
}
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
if (url.pathname === "/api/skills") {
|
|
650
|
-
const workspaceId = url.searchParams.get("workspaceId") ?? "";
|
|
651
|
-
const workspace = context.store.snapshot().workspaces.find((w) => w.id === workspaceId);
|
|
652
|
-
try {
|
|
653
|
-
const skills = await getAvailableSkills(workspace?.path, { memory: context.memory, workspaceId });
|
|
654
|
-
json(200, { skills });
|
|
655
|
-
}
|
|
656
|
-
catch (error) {
|
|
657
|
-
json(500, { error: error instanceof Error ? error.message : "获取技能失败" });
|
|
658
|
-
}
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
if (url.pathname === "/api/skills/read") {
|
|
662
|
-
let workspaceId = url.searchParams.get("workspaceId") ?? "";
|
|
663
|
-
let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
|
|
664
|
-
if (request.method === "POST") {
|
|
665
646
|
try {
|
|
666
|
-
const
|
|
667
|
-
if (
|
|
647
|
+
const remembered = await readRequestText(request, 512000);
|
|
648
|
+
if (remembered.tooLarge)
|
|
668
649
|
throw new Error("记忆请求过大");
|
|
669
|
-
const raw =
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
name = body.name || body.skillId;
|
|
676
|
-
}
|
|
650
|
+
const raw = remembered.text;
|
|
651
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
652
|
+
const result = url.pathname.endsWith("/tool")
|
|
653
|
+
? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
|
|
654
|
+
: await context.memory.manage(url.searchParams.get("workspaceId") ?? "", body);
|
|
655
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
677
656
|
}
|
|
678
|
-
catch {
|
|
679
|
-
|
|
657
|
+
catch (error) {
|
|
658
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
680
659
|
}
|
|
681
|
-
}
|
|
682
|
-
if (!name) {
|
|
683
|
-
json(400, { error: "技能名称不能为空" });
|
|
684
660
|
return;
|
|
685
661
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
662
|
+
if (url.pathname === "/api/skills") {
|
|
663
|
+
const workspaceId = url.searchParams.get("workspaceId") ?? "";
|
|
664
|
+
const workspace = context.store.workspace(workspaceId);
|
|
665
|
+
try {
|
|
666
|
+
const skills = await getAvailableSkills(workspace?.path, { memory: context.memory, workspaceId });
|
|
667
|
+
json(200, { skills });
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
json(500, { error: error instanceof Error ? error.message : "获取技能失败" });
|
|
692
671
|
}
|
|
693
|
-
json(200, result);
|
|
694
|
-
}
|
|
695
|
-
catch (error) {
|
|
696
|
-
json(500, { error: error instanceof Error ? error.message : "读取技能失败" });
|
|
697
|
-
}
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
if (url.pathname === "/api/providers/fetch-models") {
|
|
701
|
-
if (request.method !== "POST") {
|
|
702
|
-
response.writeHead(405).end();
|
|
703
672
|
return;
|
|
704
673
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
674
|
+
if (url.pathname === "/api/skills/read") {
|
|
675
|
+
let workspaceId = url.searchParams.get("workspaceId") ?? "";
|
|
676
|
+
let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
|
|
677
|
+
if (request.method === "POST") {
|
|
678
|
+
try {
|
|
679
|
+
const asked = await readRequestText(request, 512000);
|
|
680
|
+
if (asked.tooLarge)
|
|
681
|
+
throw new Error("记忆请求过大");
|
|
682
|
+
const raw = asked.text;
|
|
683
|
+
if (raw) {
|
|
684
|
+
const body = JSON.parse(raw);
|
|
685
|
+
if (body.workspaceId)
|
|
686
|
+
workspaceId = body.workspaceId;
|
|
687
|
+
if (body.name || body.skillId)
|
|
688
|
+
name = body.name || body.skillId;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// ignore json parse error, fall back to url params
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (!name) {
|
|
696
|
+
json(400, { error: "技能名称不能为空" });
|
|
709
697
|
return;
|
|
710
698
|
}
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
response.writeHead(503).end();
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
|
|
727
|
-
response.writeHead(405).end();
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
const origin = request.headers.origin;
|
|
731
|
-
if (origin && origin !== url.origin) {
|
|
732
|
-
response.writeHead(403).end("Origin not allowed");
|
|
699
|
+
const workspace = context.store.workspace(workspaceId);
|
|
700
|
+
try {
|
|
701
|
+
const result = await readSkill(name, workspace?.path, { memory: context.memory, workspaceId });
|
|
702
|
+
if (!result) {
|
|
703
|
+
json(404, { error: "技能不存在" });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
json(200, result);
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
json(500, { error: error instanceof Error ? error.message : "读取技能失败" });
|
|
710
|
+
}
|
|
733
711
|
return;
|
|
734
712
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// A pasted document rides in the body, so the cap is the document cap - past it the request is
|
|
739
|
-
// refused outright rather than truncated into a document that silently lost its tail.
|
|
740
|
-
const known = await readRequestText(request, MAX_KNOWLEDGE_REQUEST_BYTES);
|
|
741
|
-
if (known.tooLarge) {
|
|
742
|
-
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
|
|
713
|
+
if (url.pathname === "/api/providers/fetch-models") {
|
|
714
|
+
if (request.method !== "POST") {
|
|
715
|
+
response.writeHead(405).end();
|
|
743
716
|
return;
|
|
744
717
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
response.writeHead(503).end();
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
if (request.method !== "POST") {
|
|
763
|
-
response.writeHead(405).end();
|
|
764
|
-
return;
|
|
765
|
-
}
|
|
766
|
-
const origin = request.headers.origin;
|
|
767
|
-
if (origin && origin !== url.origin) {
|
|
768
|
-
response.writeHead(403).end("Origin not allowed");
|
|
718
|
+
try {
|
|
719
|
+
const asked = await readRequestText(request, 512000);
|
|
720
|
+
if (asked.tooLarge) {
|
|
721
|
+
json(413, { error: "请求过大" });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const raw = asked.text;
|
|
725
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
726
|
+
const models = await context.providerService.fetchModels(body);
|
|
727
|
+
json(200, { ok: true, models });
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
json(400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
731
|
+
}
|
|
769
732
|
return;
|
|
770
733
|
}
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
// A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
|
|
775
|
-
// past it is refused outright rather than truncated into a document that silently lost its
|
|
776
|
-
// tail.
|
|
777
|
-
const documented = await readRequestText(request, MAX_DOCUMENT_REQUEST_BYTES);
|
|
778
|
-
if (documented.tooLarge) {
|
|
779
|
-
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
|
|
734
|
+
if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
|
|
735
|
+
if (!context.knowledge) {
|
|
736
|
+
response.writeHead(503).end();
|
|
780
737
|
return;
|
|
781
738
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
// The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
|
|
785
|
-
// rather than taken from the request: a request that named its own workspace would be a
|
|
786
|
-
// request that could read another workspace.
|
|
787
|
-
const conversationId = String(request.headers["x-documents-conversation"] ?? "");
|
|
788
|
-
const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
|
|
789
|
-
if (!verdict.ok) {
|
|
790
|
-
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
739
|
+
if ((url.pathname.endsWith("/tool") && request.method !== "POST") || !["GET", "POST"].includes(request.method ?? "")) {
|
|
740
|
+
response.writeHead(405).end();
|
|
791
741
|
return;
|
|
792
742
|
}
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
797
|
-
}
|
|
798
|
-
catch (error) {
|
|
799
|
-
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
800
|
-
}
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
if (url.pathname === "/api/web/tool") {
|
|
804
|
-
if (!context.web) {
|
|
805
|
-
response.writeHead(503).end();
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
if (request.method !== "POST") {
|
|
809
|
-
response.writeHead(405).end();
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
const origin = request.headers.origin;
|
|
813
|
-
if (origin && origin !== url.origin) {
|
|
814
|
-
response.writeHead(403).end("Origin not allowed");
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
try {
|
|
818
|
-
let raw = "", totalBytes = 0;
|
|
819
|
-
const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
|
|
820
|
-
if (asked.tooLarge) {
|
|
821
|
-
response.writeHead(413).end();
|
|
743
|
+
const origin = request.headers.origin;
|
|
744
|
+
if (origin && origin !== url.origin) {
|
|
745
|
+
response.writeHead(403).end("Origin not allowed");
|
|
822
746
|
return;
|
|
823
747
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
748
|
+
try {
|
|
749
|
+
let raw = "";
|
|
750
|
+
let totalBytes = 0;
|
|
751
|
+
// A pasted document rides in the body, so the cap is the document cap - past it the request is
|
|
752
|
+
// refused outright rather than truncated into a document that silently lost its tail.
|
|
753
|
+
const known = await readRequestText(request, MAX_KNOWLEDGE_REQUEST_BYTES);
|
|
754
|
+
if (known.tooLarge) {
|
|
755
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
raw = known.text;
|
|
759
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
760
|
+
const result = url.pathname.endsWith("/tool")
|
|
761
|
+
? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
|
|
762
|
+
: await context.knowledge.manage(url.searchParams.get("workspaceId") ?? "", body);
|
|
763
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
830
767
|
}
|
|
831
|
-
const body = raw ? JSON.parse(raw) : {};
|
|
832
|
-
const result = body.action === "search"
|
|
833
|
-
? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
|
|
834
|
-
: body.action === "fetch"
|
|
835
|
-
? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
|
|
836
|
-
: await Promise.reject(new Error("未知网页工具操作"));
|
|
837
|
-
json(200, result);
|
|
838
|
-
}
|
|
839
|
-
catch (error) {
|
|
840
|
-
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
841
|
-
}
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
if (url.pathname === "/api/feishu/tool") {
|
|
845
|
-
if (!context.feishu) {
|
|
846
|
-
response.writeHead(503).end();
|
|
847
|
-
return;
|
|
848
|
-
}
|
|
849
|
-
if (request.method !== "POST") {
|
|
850
|
-
response.writeHead(405).end();
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
const origin = request.headers.origin;
|
|
854
|
-
if (origin && origin !== url.origin) {
|
|
855
|
-
response.writeHead(403).end("Origin not allowed");
|
|
856
768
|
return;
|
|
857
769
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const asked = await readRequestText(request, MAX_FEISHU_REQUEST_BYTES);
|
|
862
|
-
if (asked.tooLarge) {
|
|
863
|
-
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
|
|
770
|
+
if (url.pathname === "/api/documents/tool") {
|
|
771
|
+
if (!context.documentService) {
|
|
772
|
+
response.writeHead(503).end();
|
|
864
773
|
return;
|
|
865
774
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
// THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
|
|
869
|
-
// capability, and the session must actually be running a turn. The Feishu credential is a
|
|
870
|
-
// company secret, so this endpoint is the only way to spend it.
|
|
871
|
-
const verdict = verifyCapability(conversationId, request.headers["x-feishu-capability"], { running: context.runService.isRunning(conversationId) });
|
|
872
|
-
if (!verdict.ok) {
|
|
873
|
-
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
775
|
+
if (request.method !== "POST") {
|
|
776
|
+
response.writeHead(405).end();
|
|
874
777
|
return;
|
|
875
778
|
}
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
* The first POST asks the CLI for a device code (or for it to register an application), and
|
|
911
|
-
* answers with the URL the person has to open. The second one hands the device code back to
|
|
912
|
-
* the CLI once they have authorized it, which is where the token is written - into the keychain,
|
|
913
|
-
* not into this server.
|
|
914
|
-
*
|
|
915
|
-
* The CLI's own documentation describes exactly this two-step shape for agents, and the two
|
|
916
|
-
* calls are separate endpoints because the person has to go and do something in between.
|
|
917
|
-
*/
|
|
918
|
-
if (url.pathname === "/api/feishu/connect" || url.pathname === "/api/feishu/connect/complete") {
|
|
919
|
-
if (!context.feishu) {
|
|
920
|
-
response.writeHead(503).end();
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
if (request.method !== "POST") {
|
|
924
|
-
json(405, { error: "这个接口只接受 POST 请求。" });
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
if (!isLocalOrPrivateAddress(remote)) {
|
|
928
|
-
json(403, { error: "连接飞书账号只允许来自本机或局域网的请求。" });
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
try {
|
|
932
|
-
const result = url.pathname.endsWith("/complete")
|
|
933
|
-
? await context.feishu.completeConnect()
|
|
934
|
-
: await context.feishu.connect();
|
|
935
|
-
json(result.ok ? 200 : 400, result);
|
|
936
|
-
}
|
|
937
|
-
catch (error) {
|
|
938
|
-
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
939
|
-
}
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* CHECKS THE CONNECTION, and says why it does not work when it does not.
|
|
944
|
-
*
|
|
945
|
-
* The status already verifies the credential against Feishu, so this is the same answer shaped
|
|
946
|
-
* for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
|
|
947
|
-
* than as a state the panel has to interpret.
|
|
948
|
-
*/
|
|
949
|
-
if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
|
|
950
|
-
if (!context.feishu) {
|
|
951
|
-
response.writeHead(503).end();
|
|
952
|
-
return;
|
|
953
|
-
}
|
|
954
|
-
if (!isLocalOrPrivateAddress(remote)) {
|
|
955
|
-
json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
const result = await context.feishu.verify();
|
|
959
|
-
json(result.ok ? 200 : 400, result);
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
if (url.pathname === "/api/shutdown") {
|
|
963
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
964
|
-
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
|
|
968
|
-
.end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
|
|
969
|
-
setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
|
|
970
|
-
return;
|
|
971
|
-
}
|
|
972
|
-
if (url.pathname === "/api/update" && request.method === "POST") {
|
|
973
|
-
if (!isLocalOrPrivateAddress(remote)) {
|
|
974
|
-
response.writeHead(403).end("Update requires a loopback or private network POST");
|
|
975
|
-
return;
|
|
976
|
-
}
|
|
977
|
-
response.writeHead(202, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ status: "updating" }));
|
|
978
|
-
// Use npm directly instead of `pi update` to avoid Windows detached+shell exit timing issues.
|
|
979
|
-
// npm's exit code correctly reflects whether the update succeeded.
|
|
980
|
-
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
981
|
-
const update = packageUpdateArgs(rootDir);
|
|
982
|
-
const child = spawn(npmCmd, update.args, { stdio: "ignore", shell: process.platform === "win32", env: process.env });
|
|
983
|
-
child.on("close", (code) => {
|
|
984
|
-
if (code === 0 || code === null) {
|
|
985
|
-
console.log("Super Pi: npm install completed, triggering restart.");
|
|
986
|
-
const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
|
|
987
|
-
restart.on("error", (restartError) => console.error(`Super Pi: restart after update failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`));
|
|
988
|
-
restart.unref();
|
|
989
|
-
}
|
|
990
|
-
else {
|
|
991
|
-
console.error(`Super Pi: npm install failed with code ${code}`);
|
|
779
|
+
const origin = request.headers.origin;
|
|
780
|
+
if (origin && origin !== url.origin) {
|
|
781
|
+
response.writeHead(403).end("Origin not allowed");
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
let raw = "";
|
|
786
|
+
let totalBytes = 0;
|
|
787
|
+
// A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
|
|
788
|
+
// past it is refused outright rather than truncated into a document that silently lost its
|
|
789
|
+
// tail.
|
|
790
|
+
const documented = await readRequestText(request, MAX_DOCUMENT_REQUEST_BYTES);
|
|
791
|
+
if (documented.tooLarge) {
|
|
792
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
raw = documented.text;
|
|
796
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
797
|
+
// The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
|
|
798
|
+
// rather than taken from the request: a request that named its own workspace would be a
|
|
799
|
+
// request that could read another workspace.
|
|
800
|
+
const conversationId = String(request.headers["x-documents-conversation"] ?? "");
|
|
801
|
+
const verdict = verifyCapability(conversationId, request.headers["x-documents-capability"], { running: context.runService.isRunning(conversationId) });
|
|
802
|
+
if (!verdict.ok) {
|
|
803
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const result = body.action === "write"
|
|
807
|
+
? await context.documentService.write(conversationId, String(body.path ?? ""), String(body.content ?? ""), typeof body.title === "string" ? body.title : undefined, typeof body.baseEtag === "string" ? body.baseEtag : undefined)
|
|
808
|
+
: await context.documentService.read(conversationId, String(body.path ?? ""));
|
|
809
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(result));
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
992
813
|
}
|
|
993
|
-
});
|
|
994
|
-
child.on("error", (error) => { console.warn(`Super Pi: update spawn failed: ${error instanceof Error ? error.message : "unknown error"}`); });
|
|
995
|
-
child.unref();
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
if (url.pathname === "/api/update-check" && request.method === "GET") {
|
|
999
|
-
if (!isLocalOrPrivateAddress(remote)) {
|
|
1000
|
-
response.writeHead(403).end("Update check requires a loopback or private network request");
|
|
1001
814
|
return;
|
|
1002
815
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
});
|
|
1011
|
-
const latestVersion = typeof remoteVersion.version === "string" ? remoteVersion.version : undefined;
|
|
1012
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ...current, latestVersion, updateAvailable: Boolean(latestVersion && latestVersion !== current.diskVersion) }));
|
|
1013
|
-
}
|
|
1014
|
-
catch (error) {
|
|
1015
|
-
response.writeHead(502, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1016
|
-
}
|
|
1017
|
-
return;
|
|
1018
|
-
}
|
|
1019
|
-
if (url.pathname === "/api/health") {
|
|
1020
|
-
const version = checkDiskVersion(rootDir);
|
|
1021
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1022
|
-
.end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
/**
|
|
1026
|
-
* WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
|
|
1027
|
-
*
|
|
1028
|
-
* It is deployment configuration, not application state: it does not change while the server runs,
|
|
1029
|
-
* it is identical for every workspace, and putting it on `state` would drag it into the persistence
|
|
1030
|
-
* layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
|
|
1031
|
-
* can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
|
|
1032
|
-
* that is present and refuses is a tab that invites a user to keep clicking a closed door.
|
|
1033
|
-
*
|
|
1034
|
-
* No address check: this leaks nothing beyond "is this build configured for a shell", which is
|
|
1035
|
-
* already printed in the server's own startup banner to whoever can see the console.
|
|
1036
|
-
*/
|
|
1037
|
-
if (url.pathname === "/api/terminal") {
|
|
1038
|
-
const enabled = context.terminalEnabled?.() ?? false;
|
|
1039
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1040
|
-
.end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
if (url.pathname === "/api/terminal-shell") {
|
|
1044
|
-
const { defaultShell, shellSource, terminalShellCandidates } = await import("./terminal.js");
|
|
1045
|
-
const configured = context.store.snapshot().terminalShell;
|
|
1046
|
-
const shell = defaultShell(process.env, configured);
|
|
1047
|
-
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1048
|
-
.end(JSON.stringify({ shell, source: shellSource(process.env, configured), candidates: terminalShellCandidates() }));
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
if (url.pathname === "/api/git") {
|
|
1052
|
-
const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1053
|
-
try {
|
|
1054
|
-
const workspaceId = url.searchParams.get("workspaceId");
|
|
1055
|
-
const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1056
|
-
if (!workspace) {
|
|
1057
|
-
json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
|
|
816
|
+
if (url.pathname === "/api/web/tool") {
|
|
817
|
+
if (!context.web) {
|
|
818
|
+
response.writeHead(503).end();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (request.method !== "POST") {
|
|
822
|
+
response.writeHead(405).end();
|
|
1058
823
|
return;
|
|
1059
824
|
}
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (op === "blame" && request.method !== "GET") {
|
|
1064
|
-
json(405, { error: "Git 请求方法不支持", code: "method" });
|
|
825
|
+
const origin = request.headers.origin;
|
|
826
|
+
if (origin && origin !== url.origin) {
|
|
827
|
+
response.writeHead(403).end("Origin not allowed");
|
|
1065
828
|
return;
|
|
1066
829
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
// access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
|
|
1073
|
-
// predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
|
|
1074
|
-
if (!isLocalOrPrivateAddress(remote)) {
|
|
1075
|
-
json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
|
|
830
|
+
try {
|
|
831
|
+
let raw = "", totalBytes = 0;
|
|
832
|
+
const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
|
|
833
|
+
if (asked.tooLarge) {
|
|
834
|
+
response.writeHead(413).end();
|
|
1076
835
|
return;
|
|
1077
836
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
837
|
+
raw = asked.text;
|
|
838
|
+
const conversationId = String(request.headers["x-web-conversation"] ?? "");
|
|
839
|
+
const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
|
|
840
|
+
if (!verdict.ok) {
|
|
841
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
1081
842
|
return;
|
|
1082
843
|
}
|
|
1083
|
-
const raw = asked.text;
|
|
1084
844
|
const body = raw ? JSON.parse(raw) : {};
|
|
1085
|
-
|
|
845
|
+
const result = body.action === "search"
|
|
846
|
+
? await context.web.search(String(body.query ?? ""), Number(body.limit ?? 5), abortedAs(request))
|
|
847
|
+
: body.action === "fetch"
|
|
848
|
+
? await context.web.fetch(String(body.url ?? ""), abortedAs(request))
|
|
849
|
+
: await Promise.reject(new Error("未知网页工具操作"));
|
|
850
|
+
json(200, result);
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
854
|
+
}
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (url.pathname === "/api/feishu/tool") {
|
|
858
|
+
if (!context.feishu) {
|
|
859
|
+
response.writeHead(503).end();
|
|
1086
860
|
return;
|
|
1087
861
|
}
|
|
1088
|
-
if (request.method !== "
|
|
1089
|
-
|
|
862
|
+
if (request.method !== "POST") {
|
|
863
|
+
response.writeHead(405).end();
|
|
1090
864
|
return;
|
|
1091
865
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
866
|
+
const origin = request.headers.origin;
|
|
867
|
+
if (origin && origin !== url.origin) {
|
|
868
|
+
response.writeHead(403).end("Origin not allowed");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
let raw = "", totalBytes = 0;
|
|
873
|
+
// The Markdown of a document rides in the request body, so the cap is the document cap.
|
|
874
|
+
const asked = await readRequestText(request, MAX_FEISHU_REQUEST_BYTES);
|
|
875
|
+
if (asked.tooLarge) {
|
|
876
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
|
|
1100
877
|
return;
|
|
1101
878
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
879
|
+
raw = asked.text;
|
|
880
|
+
const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
|
|
881
|
+
// THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
|
|
882
|
+
// capability, and the session must actually be running a turn. The Feishu credential is a
|
|
883
|
+
// company secret, so this endpoint is the only way to spend it.
|
|
884
|
+
const verdict = verifyCapability(conversationId, request.headers["x-feishu-capability"], { running: context.runService.isRunning(conversationId) });
|
|
885
|
+
if (!verdict.ok) {
|
|
886
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: verdict.message }));
|
|
1108
887
|
return;
|
|
1109
888
|
}
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
endLine: endLine === null ? undefined : Number(endLine),
|
|
1116
|
-
}));
|
|
889
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
890
|
+
json(200, await context.feishu.tool(body));
|
|
891
|
+
}
|
|
892
|
+
catch (error) {
|
|
893
|
+
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
1117
894
|
}
|
|
1118
|
-
else
|
|
1119
|
-
json(400, { error: "未知 Git 操作", code: "bad_op" });
|
|
1120
|
-
}
|
|
1121
|
-
catch (error) {
|
|
1122
|
-
const message = error instanceof Error ? error.message : "Git 读取失败";
|
|
1123
|
-
const code = error?.code;
|
|
1124
|
-
if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
|
|
1125
|
-
json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
|
|
1126
|
-
else if (code === "path")
|
|
1127
|
-
json(400, { error: message, code: "bad_path" });
|
|
1128
|
-
else if (code === "not_found")
|
|
1129
|
-
json(404, { error: message, code: "not_found" });
|
|
1130
|
-
else if (code === "bad_range" || code === "bad_revision")
|
|
1131
|
-
json(400, { error: message, code });
|
|
1132
|
-
else
|
|
1133
|
-
json(400, { error: message, code: "git_error" });
|
|
1134
|
-
}
|
|
1135
|
-
return;
|
|
1136
|
-
}
|
|
1137
|
-
if (url.pathname === "/api/subagent/dispatch") {
|
|
1138
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1139
|
-
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
|
|
1140
895
|
return;
|
|
1141
896
|
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
897
|
+
/**
|
|
898
|
+
* THE FEISHU CONNECTION, as the settings panel sees it.
|
|
899
|
+
*
|
|
900
|
+
* A GET answers with what the official CLI can do right now: whether it is installed, whether
|
|
901
|
+
* it has an application, and whether a person has authorized it. It never answers with a
|
|
902
|
+
* credential - the CLI keeps those in the OS keychain, and this server never sees them.
|
|
903
|
+
*
|
|
904
|
+
* The address predicate is on the POSTs and not on the GET: connecting is a change to the
|
|
905
|
+
* machine, and the project's rule is that changes like this come from the local network.
|
|
906
|
+
* Reading whether the CLI is connected leaks nothing beyond what the panel already shows.
|
|
907
|
+
*/
|
|
908
|
+
if (url.pathname === "/api/feishu/status") {
|
|
909
|
+
if (!context.feishu) {
|
|
910
|
+
response.writeHead(503).end();
|
|
1151
911
|
return;
|
|
1152
912
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
};
|
|
1159
|
-
try {
|
|
1160
|
-
const body = JSON.parse(raw);
|
|
1161
|
-
const result = await runService.dispatchTask(body);
|
|
1162
|
-
json(result.status, result.body);
|
|
1163
|
-
}
|
|
1164
|
-
catch (error) {
|
|
1165
|
-
json(400, { error: error instanceof Error ? error.message : "task dispatch failed" });
|
|
1166
|
-
}
|
|
1167
|
-
return;
|
|
1168
|
-
}
|
|
1169
|
-
/**
|
|
1170
|
-
* Lightweight background command execution, independent from subagent dispatch.
|
|
1171
|
-
* POST with { cmd, cwd, conversationId, capability } -> 202 { sessionId, status: "running" }
|
|
1172
|
-
* Result is delivered via WebSocket exec.exit event and injected into the conversation.
|
|
1173
|
-
*/
|
|
1174
|
-
if (url.pathname === "/api/exec") {
|
|
1175
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1176
|
-
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Exec requires a loopback or private network POST");
|
|
913
|
+
if (request.method !== "GET") {
|
|
914
|
+
json(405, { error: "这个接口只接受 GET 请求。" });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
json(200, await context.feishu.status());
|
|
1177
918
|
return;
|
|
1178
919
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
920
|
+
/**
|
|
921
|
+
* STARTS THE CONNECT FLOW, and finishes it.
|
|
922
|
+
*
|
|
923
|
+
* The first POST asks the CLI for a device code (or for it to register an application), and
|
|
924
|
+
* answers with the URL the person has to open. The second one hands the device code back to
|
|
925
|
+
* the CLI once they have authorized it, which is where the token is written - into the keychain,
|
|
926
|
+
* not into this server.
|
|
927
|
+
*
|
|
928
|
+
* The CLI's own documentation describes exactly this two-step shape for agents, and the two
|
|
929
|
+
* calls are separate endpoints because the person has to go and do something in between.
|
|
930
|
+
*/
|
|
931
|
+
if (url.pathname === "/api/feishu/connect" || url.pathname === "/api/feishu/connect/complete") {
|
|
932
|
+
if (!context.feishu) {
|
|
933
|
+
response.writeHead(503).end();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
if (request.method !== "POST") {
|
|
937
|
+
json(405, { error: "这个接口只接受 POST 请求。" });
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
941
|
+
json(403, { error: "连接飞书账号只允许来自本机或局域网的请求。" });
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
try {
|
|
945
|
+
const result = url.pathname.endsWith("/complete")
|
|
946
|
+
? await context.feishu.completeConnect()
|
|
947
|
+
: await context.feishu.connect();
|
|
948
|
+
json(result.ok ? 200 : 400, result);
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
json(400, { error: error instanceof Error ? error.message : String(error) });
|
|
952
|
+
}
|
|
1181
953
|
return;
|
|
1182
954
|
}
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
955
|
+
/**
|
|
956
|
+
* CHECKS THE CONNECTION, and says why it does not work when it does not.
|
|
957
|
+
*
|
|
958
|
+
* The status already verifies the credential against Feishu, so this is the same answer shaped
|
|
959
|
+
* for a button - the difference is that a failure comes back as a MESSAGE AND A HINT rather
|
|
960
|
+
* than as a state the panel has to interpret.
|
|
961
|
+
*/
|
|
962
|
+
if (url.pathname === "/api/feishu/verify" && request.method === "POST") {
|
|
963
|
+
if (!context.feishu) {
|
|
964
|
+
response.writeHead(503).end();
|
|
1192
965
|
return;
|
|
1193
966
|
}
|
|
1194
|
-
|
|
967
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
968
|
+
json(403, { error: "测试飞书连接只允许来自本机或局域网的请求。" });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const result = await context.feishu.verify();
|
|
972
|
+
json(result.ok ? 200 : 400, result);
|
|
973
|
+
return;
|
|
1195
974
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
975
|
+
if (url.pathname === "/api/shutdown") {
|
|
976
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
977
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Shutdown requires a loopback or private network POST");
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
response.writeHead(202, { "content-type": "application/json; charset=utf-8" })
|
|
981
|
+
.end(JSON.stringify({ status: "shutting_down", message: "Server is stopping" }));
|
|
982
|
+
setTimeout(() => { void onShutdown("api-shutdown"); }, 50);
|
|
983
|
+
return;
|
|
1203
984
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
985
|
+
if (url.pathname === "/api/update" && request.method === "POST") {
|
|
986
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
987
|
+
response.writeHead(403).end("Update requires a loopback or private network POST");
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
response.writeHead(202, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ status: "updating" }));
|
|
991
|
+
// Use npm directly instead of `pi update` to avoid Windows detached+shell exit timing issues.
|
|
992
|
+
// npm's exit code correctly reflects whether the update succeeded.
|
|
993
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
994
|
+
const update = packageUpdateArgs(rootDir);
|
|
995
|
+
const child = spawn(npmCmd, update.args, { stdio: "ignore", shell: process.platform === "win32", env: process.env });
|
|
996
|
+
child.on("close", (code) => {
|
|
997
|
+
if (code === 0 || code === null) {
|
|
998
|
+
console.log("Super Pi: npm install completed, triggering restart.");
|
|
999
|
+
const restart = spawn(process.execPath, [join(rootDir, "scripts", "restart-server.mjs")], { cwd: rootDir, detached: true, stdio: "ignore", env: process.env });
|
|
1000
|
+
restart.on("error", (restartError) => console.error(`Super Pi: restart after update failed: ${restartError instanceof Error ? restartError.message : String(restartError)}`));
|
|
1001
|
+
restart.unref();
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
console.error(`Super Pi: npm install failed with code ${code}`);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
child.on("error", (error) => { console.warn(`Super Pi: update spawn failed: ${error instanceof Error ? error.message : "unknown error"}`); });
|
|
1008
|
+
child.unref();
|
|
1206
1009
|
return;
|
|
1207
1010
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1011
|
+
if (url.pathname === "/api/update-check" && request.method === "GET") {
|
|
1012
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
1013
|
+
response.writeHead(403).end("Update check requires a loopback or private network request");
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
try {
|
|
1017
|
+
const current = checkDiskVersion(rootDir);
|
|
1018
|
+
// Query the npm registry for the latest @evandu/super-pi version.
|
|
1019
|
+
const remoteVersion = await fetch("https://registry.npmjs.org/@evandu/super-pi/latest").then(async (result) => {
|
|
1020
|
+
if (!result.ok)
|
|
1021
|
+
throw new Error(`npm registry HTTP ${result.status}`);
|
|
1022
|
+
return result.json();
|
|
1023
|
+
});
|
|
1024
|
+
const latestVersion = typeof remoteVersion.version === "string" ? remoteVersion.version : undefined;
|
|
1025
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ ...current, latestVersion, updateAvailable: Boolean(latestVersion && latestVersion !== current.diskVersion) }));
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
response.writeHead(502, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1029
|
+
}
|
|
1211
1030
|
return;
|
|
1212
1031
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1032
|
+
if (url.pathname === "/api/health") {
|
|
1033
|
+
const version = checkDiskVersion(rootDir);
|
|
1034
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1035
|
+
.end(JSON.stringify({ status: "ok", service: "super-pi", ...version }));
|
|
1217
1036
|
return;
|
|
1218
1037
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
conversationId,
|
|
1237
|
-
title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
|
|
1238
|
-
agentId: "exec",
|
|
1239
|
-
status: "running",
|
|
1240
|
-
startedAt: new Date().toISOString(),
|
|
1241
|
-
resultBlockId: sessionId,
|
|
1242
|
-
...(origin ? origin : {}),
|
|
1243
|
-
});
|
|
1244
|
-
if (origin) {
|
|
1245
|
-
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
|
|
1246
|
-
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
1038
|
+
/**
|
|
1039
|
+
* WHETHER THE TERMINAL EXISTS, as a plain GET rather than part of the `state` snapshot.
|
|
1040
|
+
*
|
|
1041
|
+
* It is deployment configuration, not application state: it does not change while the server runs,
|
|
1042
|
+
* it is identical for every workspace, and putting it on `state` would drag it into the persistence
|
|
1043
|
+
* layer and into every existing snapshot schema for no benefit. A `GET` also means the right rail
|
|
1044
|
+
* can decide whether to RENDER the tab at all - and not rendering it is the point, because a tab
|
|
1045
|
+
* that is present and refuses is a tab that invites a user to keep clicking a closed door.
|
|
1046
|
+
*
|
|
1047
|
+
* No address check: this leaks nothing beyond "is this build configured for a shell", which is
|
|
1048
|
+
* already printed in the server's own startup banner to whoever can see the console.
|
|
1049
|
+
*/
|
|
1050
|
+
if (url.pathname === "/api/terminal") {
|
|
1051
|
+
const enabled = context.terminalEnabled?.() ?? false;
|
|
1052
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1053
|
+
.end(JSON.stringify({ enabled, reason: enabled ? "" : (context.terminalDisabledReason?.() ?? "终端未启用。") }));
|
|
1054
|
+
return;
|
|
1247
1055
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1056
|
+
if (url.pathname === "/api/terminal-shell") {
|
|
1057
|
+
const { defaultShell, shellSource, terminalShellCandidates } = await import("./terminal.js");
|
|
1058
|
+
const configured = context.store.snapshot().terminalShell;
|
|
1059
|
+
const shell = defaultShell(process.env, configured);
|
|
1060
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" })
|
|
1061
|
+
.end(JSON.stringify({ shell, source: shellSource(process.env, configured), candidates: terminalShellCandidates() }));
|
|
1062
|
+
return;
|
|
1252
1063
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1064
|
+
if (url.pathname === "/api/git") {
|
|
1065
|
+
const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1066
|
+
try {
|
|
1067
|
+
const workspaceId = url.searchParams.get("workspaceId");
|
|
1068
|
+
const workspace = workspaceId ? context.store.workspace(workspaceId) : undefined;
|
|
1069
|
+
if (!workspace) {
|
|
1070
|
+
json(404, { error: `工作区 ${workspaceId ?? "(missing)"} 不存在`, code: "no_workspace" });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
const op = url.searchParams.get("op") ?? "status";
|
|
1074
|
+
// Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
|
|
1075
|
+
// could ever route the op into gitMutate and before any body is read.
|
|
1076
|
+
if (op === "blame" && request.method !== "GET") {
|
|
1077
|
+
json(405, { error: "Git 请求方法不支持", code: "method" });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (request.method === "POST") {
|
|
1081
|
+
// A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
|
|
1082
|
+
// stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
|
|
1083
|
+
// pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
|
|
1084
|
+
// keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
|
|
1085
|
+
// access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
|
|
1086
|
+
// predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
|
|
1087
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
1088
|
+
json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const asked = await readRequestText(request, 512000);
|
|
1092
|
+
if (asked.tooLarge) {
|
|
1093
|
+
json(413, { error: "请求过大" });
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const raw = asked.text;
|
|
1097
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
1098
|
+
json(200, await gitMutate(workspace.path, op, body.path, body.message));
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
if (request.method !== "GET") {
|
|
1102
|
+
json(405, { error: "Git 请求方法不支持", code: "method" });
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
if (op === "status")
|
|
1106
|
+
json(200, await gitStatus(workspace.path));
|
|
1107
|
+
else if (op === "log")
|
|
1108
|
+
json(200, await gitLog(workspace.path, Number(url.searchParams.get("limit") ?? 20)));
|
|
1109
|
+
else if (op === "diff") {
|
|
1110
|
+
const path = url.searchParams.get("path");
|
|
1111
|
+
if (!path) {
|
|
1112
|
+
json(400, { error: "diff 需要 path", code: "bad_path" });
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
|
|
1116
|
+
}
|
|
1117
|
+
else if (op === "blame") {
|
|
1118
|
+
const path = url.searchParams.get("path");
|
|
1119
|
+
if (!path) {
|
|
1120
|
+
json(400, { error: "blame 需要 path", code: "bad_path" });
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const startLine = url.searchParams.get("startLine");
|
|
1124
|
+
const endLine = url.searchParams.get("endLine");
|
|
1125
|
+
json(200, await gitBlame(workspace.path, path, {
|
|
1126
|
+
revision: url.searchParams.get("revision") ?? undefined,
|
|
1127
|
+
startLine: startLine === null ? undefined : Number(startLine),
|
|
1128
|
+
endLine: endLine === null ? undefined : Number(endLine),
|
|
1129
|
+
}));
|
|
1130
|
+
}
|
|
1131
|
+
else
|
|
1132
|
+
json(400, { error: "未知 Git 操作", code: "bad_op" });
|
|
1133
|
+
}
|
|
1134
|
+
catch (error) {
|
|
1135
|
+
const message = error instanceof Error ? error.message : "Git 读取失败";
|
|
1136
|
+
const code = error?.code;
|
|
1137
|
+
if (code === 128 || code === "not_a_repo" || /not a git repository/i.test(message))
|
|
1138
|
+
json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
|
|
1139
|
+
else if (code === "path")
|
|
1140
|
+
json(400, { error: message, code: "bad_path" });
|
|
1141
|
+
else if (code === "not_found")
|
|
1142
|
+
json(404, { error: message, code: "not_found" });
|
|
1143
|
+
else if (code === "bad_range" || code === "bad_revision")
|
|
1144
|
+
json(400, { error: message, code });
|
|
1145
|
+
else
|
|
1146
|
+
json(400, { error: message, code: "git_error" });
|
|
1257
1147
|
}
|
|
1258
|
-
await store.removeBackgroundTask(sessionId);
|
|
1259
|
-
broadcaster.broadcastState();
|
|
1260
|
-
json(500, { error: error instanceof Error ? error.message : "spawn failed" });
|
|
1261
|
-
}
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
if (url.pathname === "/api/workflow/run") {
|
|
1265
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1266
|
-
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback or private network POST");
|
|
1267
1148
|
return;
|
|
1268
1149
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
for await (const chunk of request) {
|
|
1273
|
-
const piece = chunk;
|
|
1274
|
-
totalBytes += piece.length;
|
|
1275
|
-
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1276
|
-
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1277
|
-
request.destroy();
|
|
1150
|
+
if (url.pathname === "/api/subagent/dispatch") {
|
|
1151
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1152
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Sub-agent dispatch requires a loopback or private network POST");
|
|
1278
1153
|
return;
|
|
1279
1154
|
}
|
|
1280
|
-
raw =
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1155
|
+
let raw = "";
|
|
1156
|
+
let totalBytes = 0;
|
|
1157
|
+
const reader = textBodyReader();
|
|
1158
|
+
for await (const chunk of request) {
|
|
1159
|
+
const piece = chunk;
|
|
1160
|
+
totalBytes += piece.length;
|
|
1161
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1162
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1163
|
+
request.destroy();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
raw = reader.push(piece);
|
|
1167
|
+
}
|
|
1168
|
+
raw = reader.finish();
|
|
1169
|
+
const json = (status, body) => {
|
|
1170
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1171
|
+
};
|
|
1172
|
+
try {
|
|
1173
|
+
const body = JSON.parse(raw);
|
|
1174
|
+
const result = await runService.dispatchTask(body);
|
|
1175
|
+
json(result.status, result.body);
|
|
1176
|
+
}
|
|
1177
|
+
catch (error) {
|
|
1178
|
+
json(400, { error: error instanceof Error ? error.message : "task dispatch failed" });
|
|
1179
|
+
}
|
|
1298
1180
|
return;
|
|
1299
1181
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
if (
|
|
1307
|
-
response.writeHead(
|
|
1308
|
-
request.destroy();
|
|
1182
|
+
/**
|
|
1183
|
+
* Lightweight background command execution, independent from subagent dispatch.
|
|
1184
|
+
* POST with { cmd, cwd, conversationId, capability } -> 202 { sessionId, status: "running" }
|
|
1185
|
+
* Result is delivered via WebSocket exec.exit event and injected into the conversation.
|
|
1186
|
+
*/
|
|
1187
|
+
if (url.pathname === "/api/exec") {
|
|
1188
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1189
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Exec requires a loopback or private network POST");
|
|
1309
1190
|
return;
|
|
1310
1191
|
}
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
raw = reader.finish();
|
|
1314
|
-
const json = (status, body) => {
|
|
1315
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1316
|
-
};
|
|
1317
|
-
try {
|
|
1318
|
-
const body = JSON.parse(raw);
|
|
1319
|
-
const conversationId = body.conversationId?.trim();
|
|
1320
|
-
if (!conversationId)
|
|
1321
|
-
throw new Error("todowrite requires conversationId");
|
|
1322
|
-
const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
|
|
1323
|
-
if (!verdict.ok) {
|
|
1324
|
-
json(403, { error: verdict.message });
|
|
1192
|
+
if (!execService) {
|
|
1193
|
+
response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "后台命令服务没有启用。", code: "unavailable" }));
|
|
1325
1194
|
return;
|
|
1326
1195
|
}
|
|
1327
|
-
|
|
1196
|
+
let raw = "";
|
|
1197
|
+
let totalBytes = 0;
|
|
1198
|
+
const reader = textBodyReader();
|
|
1199
|
+
for await (const chunk of request) {
|
|
1200
|
+
const piece = chunk;
|
|
1201
|
+
totalBytes += piece.length;
|
|
1202
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1203
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1204
|
+
request.destroy();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
raw = reader.push(piece);
|
|
1208
|
+
}
|
|
1209
|
+
raw = reader.finish();
|
|
1210
|
+
const json = (status, body) => {
|
|
1211
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1212
|
+
};
|
|
1213
|
+
let body;
|
|
1214
|
+
try {
|
|
1215
|
+
body = JSON.parse(raw);
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
json(400, { error: "请求体不是合法的 JSON。" });
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
|
|
1222
|
+
if (!cmd || !cwd || !conversationId) {
|
|
1223
|
+
json(400, { error: "需要 cmd、cwd 和 conversationId。" });
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
// Verify both the credential and RunService's authoritative active-turn state.
|
|
1227
|
+
const result = runService.verifyPrimaryCapability(conversationId, capability);
|
|
1228
|
+
if (!result.ok) {
|
|
1229
|
+
json(403, { error: result.message });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
// The `cwd` here is the directory the Pi child was launched in - which is the conversation's
|
|
1233
|
+
// workspace, already authenticated by the per-conversation capability check above. Validating it
|
|
1234
|
+
// against the workspace list looked cheap, but Windows path normalization (8.3 short names, UNC
|
|
1235
|
+
// prefixes, drive-letter case, separator styles) routinely produces the same path in two different
|
|
1236
|
+
// strings and a strict equality check refused valid calls; a relaxed comparison is no longer
|
|
1237
|
+
// providing a security boundary that the capability check is not already providing. The trust
|
|
1238
|
+
// decision is the one above; this stays as a no-op.
|
|
1239
|
+
void cwd;
|
|
1240
|
+
// Set up the background task record BEFORE spawning, so the UI shows it immediately
|
|
1241
|
+
const { randomUUID } = await import("node:crypto");
|
|
1242
|
+
const sessionId = `exec_${randomUUID()}`;
|
|
1243
|
+
// The same anchor a sub-agent card carries, for the same reason: a background command asked for
|
|
1244
|
+
// inside a turn belongs at that call. Resolved AFTER the capability check above, because a position
|
|
1245
|
+
// is worth reading only from a caller already inside the turn.
|
|
1246
|
+
const origin = await runService.waitForToolCall(conversationId, toolCallId);
|
|
1247
|
+
await store.setBackgroundTask({
|
|
1248
|
+
id: sessionId,
|
|
1249
|
+
conversationId,
|
|
1250
|
+
title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
|
|
1251
|
+
agentId: "exec",
|
|
1252
|
+
status: "running",
|
|
1253
|
+
startedAt: new Date().toISOString(),
|
|
1254
|
+
resultBlockId: sessionId,
|
|
1255
|
+
...(origin ? origin : {}),
|
|
1256
|
+
});
|
|
1257
|
+
if (origin) {
|
|
1258
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "running" } });
|
|
1259
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
1260
|
+
}
|
|
1328
1261
|
broadcaster.broadcastState();
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1262
|
+
try {
|
|
1263
|
+
execService.spawn(sessionId, cmd, cwd);
|
|
1264
|
+
json(202, { sessionId, status: "running" });
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
if (origin) {
|
|
1268
|
+
const toolCalls = await store.updateToolCall(conversationId, origin.originMessageId, origin.originCallRow, { backgroundTask: { id: sessionId, status: "failed", output: error instanceof Error ? error.message : "spawn failed" } });
|
|
1269
|
+
broadcaster.broadcast({ type: "message.tools", conversationId, messageId: origin.originMessageId, toolCalls });
|
|
1270
|
+
}
|
|
1271
|
+
await store.removeBackgroundTask(sessionId);
|
|
1272
|
+
broadcaster.broadcastState();
|
|
1273
|
+
json(500, { error: error instanceof Error ? error.message : "spawn failed" });
|
|
1274
|
+
}
|
|
1342
1275
|
return;
|
|
1343
1276
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
for await (const chunk of request) {
|
|
1348
|
-
const piece = chunk;
|
|
1349
|
-
totalBytes += piece.length;
|
|
1350
|
-
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1351
|
-
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1352
|
-
request.destroy();
|
|
1277
|
+
if (url.pathname === "/api/workflow/run") {
|
|
1278
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1279
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Workflow dispatch requires a loopback or private network POST");
|
|
1353
1280
|
return;
|
|
1354
1281
|
}
|
|
1355
|
-
raw =
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
if (!verdict.ok) {
|
|
1368
|
-
json(403, { error: verdict.message });
|
|
1369
|
-
return;
|
|
1282
|
+
let raw = "";
|
|
1283
|
+
let totalBytes = 0;
|
|
1284
|
+
const reader = textBodyReader();
|
|
1285
|
+
for await (const chunk of request) {
|
|
1286
|
+
const piece = chunk;
|
|
1287
|
+
totalBytes += piece.length;
|
|
1288
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1289
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1290
|
+
request.destroy();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
raw = reader.push(piece);
|
|
1370
1294
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1295
|
+
raw = reader.finish();
|
|
1296
|
+
const json = (status, body) => {
|
|
1297
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1298
|
+
};
|
|
1299
|
+
try {
|
|
1300
|
+
const result = await runService.runWorkflow(JSON.parse(raw));
|
|
1301
|
+
json(result.status, result.body);
|
|
1375
1302
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
// a workspace its own author cannot see.
|
|
1379
|
-
const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
|
|
1380
|
-
const action = (body.action ?? "create").trim();
|
|
1381
|
-
if (action === "list") {
|
|
1382
|
-
json(200, { tasks: service.listForWorkspace(workspaceId) });
|
|
1383
|
-
return;
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
json(400, { error: error instanceof Error ? error.message : "workflow dispatch failed" });
|
|
1384
1305
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
workspaceId,
|
|
1391
|
-
model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
|
|
1392
|
-
agentId: body.agentId,
|
|
1393
|
-
}, "agent");
|
|
1394
|
-
json(200, { task, describe: describeSchedule(task.schedule) });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (url.pathname === "/api/todos") {
|
|
1309
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1310
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Todo writes require a loopback or private network POST");
|
|
1395
1311
|
return;
|
|
1396
1312
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
schedule: body.schedule,
|
|
1410
|
-
// "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
|
|
1411
|
-
// fields the model actually wrote, so a model that changes the prompt and says nothing
|
|
1412
|
-
// about the model leaves the key ABSENT - and the service reads an absent model as
|
|
1413
|
-
// "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
|
|
1414
|
-
// model the user had picked for this task was silently dropped and the task fell back to
|
|
1415
|
-
// the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
|
|
1416
|
-
// TEST-025). Every other field here already has this asymmetry right: name, prompt,
|
|
1417
|
-
// schedule and agentId are all "absent means unchanged".
|
|
1418
|
-
model: body.model === undefined
|
|
1419
|
-
? undefined
|
|
1420
|
-
: (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
|
|
1421
|
-
agentId: body.agentId,
|
|
1422
|
-
});
|
|
1423
|
-
json(200, { task, describe: describeSchedule(task.schedule) });
|
|
1424
|
-
return;
|
|
1313
|
+
let raw = "";
|
|
1314
|
+
let totalBytes = 0;
|
|
1315
|
+
const reader = textBodyReader();
|
|
1316
|
+
for await (const chunk of request) {
|
|
1317
|
+
const piece = chunk;
|
|
1318
|
+
totalBytes += piece.length;
|
|
1319
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1320
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1321
|
+
request.destroy();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
raw = reader.push(piece);
|
|
1425
1325
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1326
|
+
raw = reader.finish();
|
|
1327
|
+
const json = (status, body) => {
|
|
1328
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1329
|
+
};
|
|
1330
|
+
try {
|
|
1331
|
+
const body = JSON.parse(raw);
|
|
1332
|
+
const conversationId = body.conversationId?.trim();
|
|
1333
|
+
if (!conversationId)
|
|
1334
|
+
throw new Error("todowrite requires conversationId");
|
|
1335
|
+
const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
|
|
1336
|
+
if (!verdict.ok) {
|
|
1337
|
+
json(403, { error: verdict.message });
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
const { todos, dropped } = await store.setTodos(conversationId, body.todos);
|
|
1341
|
+
broadcaster.broadcastState();
|
|
1342
|
+
json(200, { todos, dropped, conversationId });
|
|
1343
|
+
}
|
|
1344
|
+
catch (error) {
|
|
1345
|
+
json(400, { error: error instanceof Error ? error.message : "todo write failed" });
|
|
1434
1346
|
}
|
|
1435
|
-
json(400, { error: "action 只能是 create、list、update 或 delete。" });
|
|
1436
|
-
}
|
|
1437
|
-
catch (error) {
|
|
1438
|
-
json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
|
|
1439
|
-
}
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
if (url.pathname === "/api/question") {
|
|
1443
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1444
|
-
response.writeHead(403).end();
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
// READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
|
|
1448
|
-
// with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
|
|
1449
|
-
// this loop used to read the whole body into one string before checking anything, so an oversized
|
|
1450
|
-
// body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
|
|
1451
|
-
// 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
|
|
1452
|
-
// and the shape is the same ceiling the `/api/tools` endpoints already enforce.
|
|
1453
|
-
const asked = await readRequestText(request, 64_000);
|
|
1454
|
-
if (asked.tooLarge) {
|
|
1455
|
-
response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
|
|
1456
1347
|
return;
|
|
1457
1348
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
if (!verdict.ok) {
|
|
1465
|
-
response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
|
|
1349
|
+
if (url.pathname === "/api/scheduled-tasks") {
|
|
1350
|
+
// THE AGENT'S DOOR TO THE SCHEDULED TASKS, and the same shape as `/api/todos`: exempt from the
|
|
1351
|
+
// global token because the caller proves itself with a per-conversation capability instead, and
|
|
1352
|
+
// restricted to loopback or a private network for the same reason `/api/todos` is.
|
|
1353
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1354
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Scheduled task writes require a loopback or private network POST");
|
|
1466
1355
|
return;
|
|
1467
1356
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
try {
|
|
1481
|
-
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1482
|
-
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1483
|
-
if (!workspaceId || !workspace) {
|
|
1484
|
-
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1485
|
-
return;
|
|
1357
|
+
let raw = "";
|
|
1358
|
+
let totalBytes = 0;
|
|
1359
|
+
const reader = textBodyReader();
|
|
1360
|
+
for await (const chunk of request) {
|
|
1361
|
+
const piece = chunk;
|
|
1362
|
+
totalBytes += piece.length;
|
|
1363
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1364
|
+
response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
|
|
1365
|
+
request.destroy();
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
raw = reader.push(piece);
|
|
1486
1369
|
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1370
|
+
raw = reader.finish();
|
|
1371
|
+
const json = (status, body) => {
|
|
1372
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1373
|
+
};
|
|
1374
|
+
try {
|
|
1375
|
+
const body = JSON.parse(raw);
|
|
1376
|
+
const conversationId = body.conversationId?.trim();
|
|
1377
|
+
if (!conversationId)
|
|
1378
|
+
throw new Error("scheduled_task requires conversationId");
|
|
1379
|
+
const verdict = runService.verifyPrimaryCapability(conversationId, body.capability);
|
|
1380
|
+
if (!verdict.ok) {
|
|
1381
|
+
json(403, { error: verdict.message });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const service = context.scheduledTaskService;
|
|
1385
|
+
if (!service) {
|
|
1386
|
+
json(503, { error: "定时任务功能在这个实例上没有启用。" });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
// THE WORKSPACE IS THE CALLER'S, not the agent's choice. The conversation the turn belongs to
|
|
1390
|
+
// decides where the task runs and what its defaults are; a task could otherwise be filed under
|
|
1391
|
+
// a workspace its own author cannot see.
|
|
1392
|
+
const workspaceId = conversationWorkspace(store.snapshot(), conversationId).id;
|
|
1393
|
+
const action = (body.action ?? "create").trim();
|
|
1394
|
+
if (action === "list") {
|
|
1395
|
+
json(200, { tasks: service.listForWorkspace(workspaceId) });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (action === "create") {
|
|
1399
|
+
const task = await service.create({
|
|
1400
|
+
name: body.name,
|
|
1401
|
+
prompt: body.prompt,
|
|
1402
|
+
schedule: body.schedule,
|
|
1403
|
+
workspaceId,
|
|
1404
|
+
model: body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null,
|
|
1405
|
+
agentId: body.agentId,
|
|
1406
|
+
}, "agent");
|
|
1407
|
+
json(200, { task, describe: describeSchedule(task.schedule) });
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (action === "update") {
|
|
1411
|
+
const id = body.id?.trim();
|
|
1412
|
+
if (!id)
|
|
1413
|
+
throw new Error("scheduled_task update requires an id");
|
|
1414
|
+
// THE TASK MUST BE THE CALLER'S TO CHANGE. A task runs in a workspace: it opens its
|
|
1415
|
+
// conversation there, uses that workspace's defaults and its tools. The caller cannot even
|
|
1416
|
+
// LIST another workspace's tasks (`list` is scoped), so it must not be able to change or
|
|
1417
|
+
// delete them either - and the refusal must not say "it exists, over there".
|
|
1418
|
+
service.requireForWorkspace(id, workspaceId);
|
|
1419
|
+
const task = await service.update(id, {
|
|
1420
|
+
name: body.name,
|
|
1421
|
+
prompt: body.prompt,
|
|
1422
|
+
schedule: body.schedule,
|
|
1423
|
+
// "NOT MENTIONED" AND "CLEARED" ARE DIFFERENT REQUESTS. The extension only sends the
|
|
1424
|
+
// fields the model actually wrote, so a model that changes the prompt and says nothing
|
|
1425
|
+
// about the model leaves the key ABSENT - and the service reads an absent model as
|
|
1426
|
+
// "keep what is there". Mapping absence to null made "not mentioned" mean "delete", so a
|
|
1427
|
+
// model the user had picked for this task was silently dropped and the task fell back to
|
|
1428
|
+
// the default model (measured by scripts/scheduled-task-scope.test.mjs, ledger
|
|
1429
|
+
// TEST-025). Every other field here already has this asymmetry right: name, prompt,
|
|
1430
|
+
// schedule and agentId are all "absent means unchanged".
|
|
1431
|
+
model: body.model === undefined
|
|
1432
|
+
? undefined
|
|
1433
|
+
: (body.model ? { provider: body.model.provider, modelId: body.model.modelId, label: body.model.label ?? body.model.modelId } : null),
|
|
1434
|
+
agentId: body.agentId,
|
|
1435
|
+
});
|
|
1436
|
+
json(200, { task, describe: describeSchedule(task.schedule) });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (action === "delete") {
|
|
1440
|
+
const id = body.id?.trim();
|
|
1441
|
+
if (!id)
|
|
1442
|
+
throw new Error("scheduled_task delete requires an id");
|
|
1443
|
+
service.requireForWorkspace(id, workspaceId);
|
|
1444
|
+
await service.remove(id);
|
|
1445
|
+
json(200, { deleted: id });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
json(400, { error: "action 只能是 create、list、update 或 delete。" });
|
|
1491
1449
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
const diff = await computeFileDiff({
|
|
1497
|
-
root: workspace.path,
|
|
1498
|
-
path,
|
|
1499
|
-
preferred,
|
|
1500
|
-
tracker: context.fileChanges,
|
|
1501
|
-
workspaceId,
|
|
1502
|
-
});
|
|
1503
|
-
json(200, diff);
|
|
1504
|
-
}
|
|
1505
|
-
catch (error) {
|
|
1506
|
-
const described = describeFilesError(error);
|
|
1507
|
-
json(described.status, described.body);
|
|
1450
|
+
catch (error) {
|
|
1451
|
+
json(400, { error: error instanceof Error ? error.message : "scheduled task request failed" });
|
|
1452
|
+
}
|
|
1453
|
+
return;
|
|
1508
1454
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
1513
|
-
try {
|
|
1514
|
-
if (request.method !== "GET") {
|
|
1515
|
-
json(405, { error: "内容搜索需要 GET 请求。", code: "method" });
|
|
1455
|
+
if (url.pathname === "/api/question") {
|
|
1456
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1457
|
+
response.writeHead(403).end();
|
|
1516
1458
|
return;
|
|
1517
1459
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1460
|
+
// READ WITH A CEILING. This endpoint is exempt from the global token (the caller proves itself
|
|
1461
|
+
// with a per-conversation capability instead), which means anyone on the LAN can REACH it - and
|
|
1462
|
+
// this loop used to read the whole body into one string before checking anything, so an oversized
|
|
1463
|
+
// body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
|
|
1464
|
+
// 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
|
|
1465
|
+
// and the shape is the same ceiling the `/api/tools` endpoints already enforce.
|
|
1466
|
+
const asked = await readRequestText(request, 64_000);
|
|
1467
|
+
if (asked.tooLarge) {
|
|
1468
|
+
response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
|
|
1523
1469
|
return;
|
|
1524
1470
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1471
|
+
const raw = asked.text;
|
|
1472
|
+
try {
|
|
1473
|
+
const body = JSON.parse(raw);
|
|
1474
|
+
if (!body.conversationId || !body.question || !Array.isArray(body.options))
|
|
1475
|
+
throw new Error("invalid question");
|
|
1476
|
+
const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability);
|
|
1477
|
+
if (!verdict.ok) {
|
|
1478
|
+
response.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: verdict.message }));
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const result = await runService.askQuestion(body.conversationId);
|
|
1482
|
+
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
|
|
1529
1483
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
return;
|
|
1484
|
+
catch (error) {
|
|
1485
|
+
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
|
|
1533
1486
|
}
|
|
1534
|
-
|
|
1535
|
-
if (!context.fileIndexes)
|
|
1536
|
-
context.fileIndexes = indexes;
|
|
1537
|
-
let index = indexes.get(workspaceId);
|
|
1538
|
-
if (!index) {
|
|
1539
|
-
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1540
|
-
indexes.set(workspaceId, index);
|
|
1541
|
-
}
|
|
1542
|
-
index.start(workspace.path);
|
|
1543
|
-
const deadline = Date.now() + 4500;
|
|
1544
|
-
while (!index.builtAt && Date.now() < deadline)
|
|
1545
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1546
|
-
const snapshot = index.snapshot();
|
|
1547
|
-
const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
|
|
1548
|
-
json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1549
|
-
}
|
|
1550
|
-
catch (error) {
|
|
1551
|
-
const described = describeFilesError(error);
|
|
1552
|
-
json(described.status, described.body);
|
|
1487
|
+
return;
|
|
1553
1488
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1489
|
+
if (url.pathname === "/api/files/diff") {
|
|
1490
|
+
const json = (status, body) => {
|
|
1491
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1492
|
+
};
|
|
1493
|
+
try {
|
|
1494
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1495
|
+
const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
|
|
1496
|
+
if (!workspaceId || !workspace) {
|
|
1497
|
+
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const path = url.searchParams.get("path")?.trim() ?? "";
|
|
1501
|
+
if (!path) {
|
|
1502
|
+
json(400, { error: "需要 path 参数", code: "bad_request" });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
const requested = url.searchParams.get("base")?.trim();
|
|
1506
|
+
const preferred = requested === "git" || requested === "agent" ? requested : "auto";
|
|
1507
|
+
// The baseline is chosen by the server, but the client can name one - and when it names one, that
|
|
1508
|
+
// is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
|
|
1509
|
+
const diff = await computeFileDiff({
|
|
1510
|
+
root: workspace.path,
|
|
1511
|
+
path,
|
|
1512
|
+
preferred,
|
|
1513
|
+
tracker: context.fileChanges,
|
|
1514
|
+
workspaceId,
|
|
1515
|
+
});
|
|
1516
|
+
json(200, diff);
|
|
1562
1517
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1567
|
-
return;
|
|
1518
|
+
catch (error) {
|
|
1519
|
+
const described = describeFilesError(error);
|
|
1520
|
+
json(described.status, described.body);
|
|
1568
1521
|
}
|
|
1569
|
-
|
|
1570
|
-
if (!context.fileIndexes)
|
|
1571
|
-
context.fileIndexes = indexes;
|
|
1572
|
-
let index = indexes.get(workspaceId);
|
|
1573
|
-
if (!index) {
|
|
1574
|
-
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1575
|
-
indexes.set(workspaceId, index);
|
|
1576
|
-
}
|
|
1577
|
-
index.start(workspace.path);
|
|
1578
|
-
// The NAME index is started here too, and this is the whole reason the panel can promise a
|
|
1579
|
-
// complete search: the client fetches this endpoint the moment a workspace opens, so the
|
|
1580
|
-
// background scan is already running before anybody types. Without this the index would
|
|
1581
|
-
// start on the first keystroke and the first search would be answered from a half-built map.
|
|
1582
|
-
nameIndexFor(context, workspaceId, workspace.path);
|
|
1583
|
-
const sinceRaw = url.searchParams.get("since");
|
|
1584
|
-
const since = sinceRaw === null ? undefined : Number(sinceRaw);
|
|
1585
|
-
json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
|
|
1586
|
-
}
|
|
1587
|
-
catch (error) {
|
|
1588
|
-
const described = describeFilesError(error);
|
|
1589
|
-
json(described.status, described.body);
|
|
1522
|
+
return;
|
|
1590
1523
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
const json = (status, body) => {
|
|
1595
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1596
|
-
};
|
|
1597
|
-
try {
|
|
1598
|
-
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1599
|
-
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1600
|
-
if (!workspaceId || !workspace) {
|
|
1601
|
-
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1602
|
-
return;
|
|
1603
|
-
}
|
|
1604
|
-
const root = workspace.path;
|
|
1605
|
-
if (url.pathname === "/api/files") {
|
|
1524
|
+
if (url.pathname === "/api/files/search") {
|
|
1525
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
1526
|
+
try {
|
|
1606
1527
|
if (request.method !== "GET") {
|
|
1607
|
-
json(405, { error: "
|
|
1608
|
-
return;
|
|
1609
|
-
}
|
|
1610
|
-
const query = url.searchParams.get("q");
|
|
1611
|
-
if (query !== null && query.trim() !== "") {
|
|
1612
|
-
// THE SEARCH IS AN INDEX QUERY, not a walk.
|
|
1613
|
-
//
|
|
1614
|
-
// The old path re-walked the entire tree on every keystroke and then reported its own
|
|
1615
|
-
// limits ("已跳过 N 个目录", "结果不完整"). The index answers from memory - no filesystem
|
|
1616
|
-
// access at all - and covers every directory in the workspace, so there is nothing to
|
|
1617
|
-
// report a limit about. `truncated` and `skipped` stay in the response so the protocol
|
|
1618
|
-
// shape does not change under the client, but they are now constants that say the
|
|
1619
|
-
// opposite of what they used to.
|
|
1620
|
-
const index = nameIndexFor(context, workspaceId, root);
|
|
1621
|
-
// THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
|
|
1622
|
-
//
|
|
1623
|
-
// The background scan records a root it could not read rather than throwing it - a scan
|
|
1624
|
-
// has no caller to receive a rejection, and an unhandled one terminates the process. So
|
|
1625
|
-
// the route asks the index what happened and turns the answer into the status it
|
|
1626
|
-
// deserves. Without this a workspace whose directory is gone would answer 200 with an
|
|
1627
|
-
// empty list, which is byte-for-byte the response for "your search matched nothing" -
|
|
1628
|
-
// the exact confusion this endpoint is supposed to avoid.
|
|
1629
|
-
await index.waitUntilIndexed(4000);
|
|
1630
|
-
index.throwIfBroken();
|
|
1631
|
-
// WAIT FOR THE INDEX, briefly. A search answered from a half-built map would say "no
|
|
1632
|
-
// matches" for files that are on disk - which is precisely the "搜不出他想搜的东西"
|
|
1633
|
-
// failure this work exists to remove. Past the deadline the answer is still returned, with
|
|
1634
|
-
// `indexed: false` so the UI can say the index is still building rather than pretending
|
|
1635
|
-
// the tree is empty.
|
|
1636
|
-
const result = index.search(query);
|
|
1637
|
-
json(200, {
|
|
1638
|
-
mode: "search",
|
|
1639
|
-
query: query.trim(),
|
|
1640
|
-
hits: result.hits.map((hit) => ({ path: hit.path, name: hit.name, directory: hit.directory })),
|
|
1641
|
-
total: result.total,
|
|
1642
|
-
truncated: false,
|
|
1643
|
-
skipped: [],
|
|
1644
|
-
indexed: result.indexed,
|
|
1645
|
-
progress: result.progress,
|
|
1646
|
-
incomplete: result.incomplete,
|
|
1647
|
-
});
|
|
1528
|
+
json(405, { error: "内容搜索需要 GET 请求。", code: "method" });
|
|
1648
1529
|
return;
|
|
1649
1530
|
}
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1652
|
-
|
|
1531
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1532
|
+
const query = url.searchParams.get("q")?.trim() ?? "";
|
|
1533
|
+
const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
|
|
1534
|
+
if (!workspaceId || !workspace) {
|
|
1535
|
+
json(404, { error: "找不到这个工作区——它可能已经被删除。", code: "no_workspace" });
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
await resolveInsideRoot(workspace.path, "");
|
|
1539
|
+
if (query.length > 256 || /[\r\n]/.test(query)) {
|
|
1540
|
+
json(400, { error: "搜索词只能是一行,且不超过 256 个字符。", code: "bad_query" });
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
if (!query) {
|
|
1544
|
+
json(200, { mode: "content", query, hits: [], truncated: false, skipped: [], complete: true, reasons: [], maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1548
|
+
if (!context.fileIndexes)
|
|
1549
|
+
context.fileIndexes = indexes;
|
|
1550
|
+
let index = indexes.get(workspaceId);
|
|
1551
|
+
if (!index) {
|
|
1552
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1553
|
+
indexes.set(workspaceId, index);
|
|
1554
|
+
}
|
|
1555
|
+
index.start(workspace.path);
|
|
1556
|
+
const deadline = Date.now() + 4500;
|
|
1557
|
+
while (!index.builtAt && Date.now() < deadline)
|
|
1558
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1559
|
+
const snapshot = index.snapshot();
|
|
1560
|
+
const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
|
|
1561
|
+
json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1562
|
+
}
|
|
1563
|
+
catch (error) {
|
|
1564
|
+
const described = describeFilesError(error);
|
|
1565
|
+
json(described.status, described.body);
|
|
1653
1566
|
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (url.pathname === "/api/files/index") {
|
|
1570
|
+
const json = (status, body) => {
|
|
1571
|
+
const raw = Buffer.from(JSON.stringify(body), "utf8");
|
|
1572
|
+
// A workspace index is intentionally a JSON snapshot for quick-open, but it can be several MB.
|
|
1573
|
+
// Compress only when the browser advertises support; this keeps local callers and old clients
|
|
1574
|
+
// compatible while cutting remote transfer time by an order of magnitude on repetitive paths.
|
|
1575
|
+
const encodings = (request.headers["accept-encoding"] ?? "").split(",").map((part) => part.trim().split(";").map((value) => value.trim()));
|
|
1576
|
+
const gzipEntry = encodings.find(([name]) => name.toLowerCase() === "gzip") ?? encodings.find(([name]) => name === "*");
|
|
1577
|
+
const quality = gzipEntry?.find((part) => /^q=/i.test(part));
|
|
1578
|
+
if (gzipEntry && (!quality || Number(quality.slice(2)) > 0) && raw.length >= 1024) {
|
|
1579
|
+
void promisify(gzip)(raw, { level: 6 }).then((encoded) => {
|
|
1580
|
+
if (!response.destroyed)
|
|
1581
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-encoding": "gzip", "vary": "Accept-Encoding", "content-length": encoded.length }).end(encoded);
|
|
1582
|
+
}).catch((error) => { console.warn("Super Pi: file index compression failed", error); if (!response.destroyed)
|
|
1583
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "vary": "Accept-Encoding", "content-length": raw.length }).end(raw); });
|
|
1662
1584
|
return;
|
|
1663
1585
|
}
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1586
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "vary": "Accept-Encoding", "content-length": raw.length }).end(raw);
|
|
1587
|
+
};
|
|
1588
|
+
try {
|
|
1589
|
+
if (request.method !== "GET") {
|
|
1590
|
+
json(405, { error: "文件索引需要 GET 请求。", code: "method" });
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1594
|
+
const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
|
|
1595
|
+
if (!workspaceId || !workspace) {
|
|
1596
|
+
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1600
|
+
if (!context.fileIndexes)
|
|
1601
|
+
context.fileIndexes = indexes;
|
|
1602
|
+
let index = indexes.get(workspaceId);
|
|
1603
|
+
if (!index) {
|
|
1604
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1605
|
+
indexes.set(workspaceId, index);
|
|
1606
|
+
}
|
|
1607
|
+
index.start(workspace.path);
|
|
1608
|
+
// Quick Open requests the full index on demand. Name search can share its background scan;
|
|
1609
|
+
// merely selecting a session no longer starts a multi-MB download.
|
|
1610
|
+
nameIndexFor(context, workspaceId, workspace.path);
|
|
1611
|
+
const sinceRaw = url.searchParams.get("since");
|
|
1612
|
+
const since = sinceRaw === null ? undefined : Number(sinceRaw);
|
|
1613
|
+
json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
|
|
1614
|
+
}
|
|
1615
|
+
catch (error) {
|
|
1616
|
+
const described = describeFilesError(error);
|
|
1617
|
+
json(described.status, described.body);
|
|
1668
1618
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
if (url.pathname === "/api/files" || url.pathname === "/api/file") {
|
|
1622
|
+
const json = (status, body) => {
|
|
1623
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1624
|
+
};
|
|
1625
|
+
try {
|
|
1626
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1627
|
+
const workspace = workspaceId ? store.workspace(workspaceId) : undefined;
|
|
1628
|
+
if (!workspaceId || !workspace) {
|
|
1629
|
+
json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
|
|
1673
1630
|
return;
|
|
1674
1631
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const piece = chunk;
|
|
1680
|
-
totalBytes += piece.length;
|
|
1681
|
-
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1682
|
-
json(413, { error: "请求体过大", code: "too_large" });
|
|
1683
|
-
request.destroy();
|
|
1632
|
+
const root = workspace.path;
|
|
1633
|
+
if (url.pathname === "/api/files") {
|
|
1634
|
+
if (request.method !== "GET") {
|
|
1635
|
+
json(405, { error: "目录列表需要 GET 请求。", code: "method" });
|
|
1684
1636
|
return;
|
|
1685
1637
|
}
|
|
1686
|
-
|
|
1638
|
+
const query = url.searchParams.get("q");
|
|
1639
|
+
if (query !== null && query.trim() !== "") {
|
|
1640
|
+
// THE SEARCH IS AN INDEX QUERY, not a walk.
|
|
1641
|
+
//
|
|
1642
|
+
// The old path re-walked the entire tree on every keystroke and then reported its own
|
|
1643
|
+
// limits ("已跳过 N 个目录", "结果不完整"). The index answers from memory - no filesystem
|
|
1644
|
+
// access at all - and covers every directory in the workspace, so there is nothing to
|
|
1645
|
+
// report a limit about. `truncated` and `skipped` stay in the response so the protocol
|
|
1646
|
+
// shape does not change under the client, but they are now constants that say the
|
|
1647
|
+
// opposite of what they used to.
|
|
1648
|
+
const index = nameIndexFor(context, workspaceId, root);
|
|
1649
|
+
// THE WORKSPACE MUST EXIST, AND THE INDEX IS WHAT KNOWS.
|
|
1650
|
+
//
|
|
1651
|
+
// The background scan records a root it could not read rather than throwing it - a scan
|
|
1652
|
+
// has no caller to receive a rejection, and an unhandled one terminates the process. So
|
|
1653
|
+
// the route asks the index what happened and turns the answer into the status it
|
|
1654
|
+
// deserves. Without this a workspace whose directory is gone would answer 200 with an
|
|
1655
|
+
// empty list, which is byte-for-byte the response for "your search matched nothing" -
|
|
1656
|
+
// the exact confusion this endpoint is supposed to avoid.
|
|
1657
|
+
await index.waitUntilIndexed(4000);
|
|
1658
|
+
index.throwIfBroken();
|
|
1659
|
+
// WAIT FOR THE INDEX, briefly. A search answered from a half-built map would say "no
|
|
1660
|
+
// matches" for files that are on disk - which is precisely the "搜不出他想搜的东西"
|
|
1661
|
+
// failure this work exists to remove. Past the deadline the answer is still returned, with
|
|
1662
|
+
// `indexed: false` so the UI can say the index is still building rather than pretending
|
|
1663
|
+
// the tree is empty.
|
|
1664
|
+
const result = index.search(query);
|
|
1665
|
+
json(200, {
|
|
1666
|
+
mode: "search",
|
|
1667
|
+
query: query.trim(),
|
|
1668
|
+
hits: result.hits.map((hit) => ({ path: hit.path, name: hit.name, directory: hit.directory })),
|
|
1669
|
+
total: result.total,
|
|
1670
|
+
truncated: false,
|
|
1671
|
+
skipped: [],
|
|
1672
|
+
indexed: result.indexed,
|
|
1673
|
+
progress: result.progress,
|
|
1674
|
+
incomplete: result.incomplete,
|
|
1675
|
+
});
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const listing = await listDirectory(root, url.searchParams.get("path"));
|
|
1679
|
+
json(200, { mode: "list", ...listing, maxEntries: 2000 });
|
|
1680
|
+
return;
|
|
1687
1681
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1682
|
+
if (request.method === "GET") {
|
|
1683
|
+
const rel = url.searchParams.get("path") ?? "";
|
|
1684
|
+
if (url.searchParams.get("meta") === "1") {
|
|
1685
|
+
const resolved = await resolveInsideRoot(root, rel);
|
|
1686
|
+
const meta = await fileMeta(resolved);
|
|
1687
|
+
const path = toRelative(root, resolved);
|
|
1688
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
|
|
1689
|
+
json(200, { path, ...meta });
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
const file = await readWorkspaceFile(root, rel);
|
|
1693
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
|
|
1694
|
+
json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
|
|
1692
1695
|
return;
|
|
1693
1696
|
}
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
+
if (request.method === "PUT") {
|
|
1698
|
+
const remoteAddr = request.socket.remoteAddress ?? "";
|
|
1699
|
+
if (!isLocalOrPrivateAddress(remoteAddr)) {
|
|
1700
|
+
json(403, { error: "保存文件需要从本机或局域网发起。", code: "not_loopback" });
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
let raw = "";
|
|
1704
|
+
let totalBytes = 0;
|
|
1705
|
+
const reader = textBodyReader();
|
|
1706
|
+
for await (const chunk of request) {
|
|
1707
|
+
const piece = chunk;
|
|
1708
|
+
totalBytes += piece.length;
|
|
1709
|
+
if (totalBytes > MAX_FILE_BYTES * 2) {
|
|
1710
|
+
json(413, { error: "请求体过大", code: "too_large" });
|
|
1711
|
+
request.destroy();
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
raw = reader.push(piece);
|
|
1715
|
+
}
|
|
1716
|
+
raw = reader.finish();
|
|
1717
|
+
const declared = Number(request.headers["content-length"] ?? "0");
|
|
1718
|
+
if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
|
|
1719
|
+
json(413, { error: "请求体过大", code: "too_large" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const parsed = JSON.parse(raw);
|
|
1723
|
+
if (typeof parsed.path !== "string" || typeof parsed.content !== "string") {
|
|
1724
|
+
json(400, { error: "写入需要 path 与 content 两个字符串字段", code: "bad_request" });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
|
|
1728
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
|
|
1729
|
+
// The name index learns about the save too. A file the user just saved must be findable
|
|
1730
|
+
// by name immediately, and the index is the thing the search reads - so this is where the
|
|
1731
|
+
// index stays true without anybody re-scanning the tree.
|
|
1732
|
+
context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
|
|
1733
|
+
json(200, outcome);
|
|
1697
1734
|
return;
|
|
1698
1735
|
}
|
|
1699
|
-
|
|
1700
|
-
context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
|
|
1701
|
-
// The name index learns about the save too. A file the user just saved must be findable
|
|
1702
|
-
// by name immediately, and the index is the thing the search reads - so this is where the
|
|
1703
|
-
// index stays true without anybody re-scanning the tree.
|
|
1704
|
-
context.nameIndexes?.get(workspaceId)?.upsert(outcome.path);
|
|
1705
|
-
json(200, outcome);
|
|
1706
|
-
return;
|
|
1736
|
+
json(405, { error: `/api/file 不支持 ${request.method} 请求。`, code: "method" });
|
|
1707
1737
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
const described = describeFilesError(error);
|
|
1740
|
+
json(described.status, described.body);
|
|
1741
|
+
}
|
|
1742
|
+
return;
|
|
1713
1743
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
const fail = (status, message) => {
|
|
1718
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: message }));
|
|
1719
|
-
};
|
|
1720
|
-
try {
|
|
1721
|
-
const rawPath = url.searchParams.get("path")?.trim();
|
|
1722
|
-
const defaultDir = homedir() || rootDir;
|
|
1723
|
-
const expandTilde = (filepath) => {
|
|
1724
|
-
if (filepath === "~")
|
|
1725
|
-
return homedir();
|
|
1726
|
-
if (filepath.startsWith("~/") || filepath.startsWith("~\\")) {
|
|
1727
|
-
return join(homedir(), filepath.slice(2));
|
|
1728
|
-
}
|
|
1729
|
-
return filepath;
|
|
1744
|
+
if (url.pathname === "/api/fs/dirs") {
|
|
1745
|
+
const fail = (status, message) => {
|
|
1746
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: message }));
|
|
1730
1747
|
};
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1748
|
+
try {
|
|
1749
|
+
const rawPath = url.searchParams.get("path")?.trim();
|
|
1750
|
+
const defaultDir = homedir() || rootDir;
|
|
1751
|
+
const expandTilde = (filepath) => {
|
|
1752
|
+
if (filepath === "~")
|
|
1753
|
+
return homedir();
|
|
1754
|
+
if (filepath.startsWith("~/") || filepath.startsWith("~\\")) {
|
|
1755
|
+
return join(homedir(), filepath.slice(2));
|
|
1756
|
+
}
|
|
1757
|
+
return filepath;
|
|
1758
|
+
};
|
|
1759
|
+
const target = resolve(rawPath ? expandTilde(rawPath) : defaultDir);
|
|
1760
|
+
const info = await stat(target).catch((error) => {
|
|
1761
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR")
|
|
1762
|
+
return undefined;
|
|
1763
|
+
throw error;
|
|
1764
|
+
});
|
|
1765
|
+
if (!info) {
|
|
1766
|
+
fail(404, `目录不存在:${target}`);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
if (!info.isDirectory()) {
|
|
1770
|
+
fail(400, `该路径不是目录:${target}`);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const dirents = await readdir(target, { withFileTypes: true });
|
|
1774
|
+
const entries = dirents
|
|
1775
|
+
.filter((item) => item.isDirectory())
|
|
1776
|
+
.map((item) => ({ name: item.name, path: join(target, item.name) }))
|
|
1777
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
1778
|
+
const truncated = entries.length > 2000;
|
|
1779
|
+
const parent = dirname(target);
|
|
1780
|
+
const getAvailableDrives = () => {
|
|
1781
|
+
if (process.platform !== "win32")
|
|
1782
|
+
return [];
|
|
1783
|
+
const drives = [];
|
|
1784
|
+
for (let i = 65; i <= 90; i++) {
|
|
1785
|
+
const drive = String.fromCharCode(i) + ":\\";
|
|
1786
|
+
try {
|
|
1787
|
+
if (existsSync(drive))
|
|
1788
|
+
drives.push(drive);
|
|
1789
|
+
}
|
|
1790
|
+
catch { }
|
|
1791
|
+
}
|
|
1792
|
+
return drives;
|
|
1793
|
+
};
|
|
1794
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({
|
|
1795
|
+
path: target,
|
|
1796
|
+
parent: parent === target ? null : parent,
|
|
1797
|
+
suggestedName: basename(target) || target,
|
|
1798
|
+
entries: truncated ? entries.slice(0, 2000) : entries,
|
|
1799
|
+
truncated,
|
|
1800
|
+
drives: getAvailableDrives(),
|
|
1801
|
+
homeDir: homedir(),
|
|
1802
|
+
}));
|
|
1803
|
+
}
|
|
1804
|
+
catch (error) {
|
|
1805
|
+
const code = error.code;
|
|
1806
|
+
if (code === "EACCES" || code === "EPERM")
|
|
1807
|
+
fail(403, `没有访问权限:${url.searchParams.get("path") ?? rootDir}`);
|
|
1808
|
+
else
|
|
1809
|
+
fail(500, `读取目录失败:${error instanceof Error ? error.message : "未知错误"}`);
|
|
1740
1810
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
if (url.pathname === "/api/upload") {
|
|
1814
|
+
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1815
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传需要从本机或局域网发起。" }));
|
|
1743
1816
|
return;
|
|
1744
1817
|
}
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
const drive = String.fromCharCode(i) + ":\\";
|
|
1758
|
-
try {
|
|
1759
|
-
if (existsSync(drive))
|
|
1760
|
-
drives.push(drive);
|
|
1818
|
+
const json = (status, body) => {
|
|
1819
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1820
|
+
};
|
|
1821
|
+
try {
|
|
1822
|
+
let totalBytes = 0;
|
|
1823
|
+
const chunks = [];
|
|
1824
|
+
for await (const chunk of request) {
|
|
1825
|
+
totalBytes += chunk.length;
|
|
1826
|
+
if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
|
|
1827
|
+
json(413, { error: "图片超过了大小上限。" });
|
|
1828
|
+
request.destroy();
|
|
1829
|
+
return;
|
|
1761
1830
|
}
|
|
1762
|
-
|
|
1831
|
+
chunks.push(chunk);
|
|
1763
1832
|
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
suggestedName: basename(target) || target,
|
|
1770
|
-
entries: truncated ? entries.slice(0, 2000) : entries,
|
|
1771
|
-
truncated,
|
|
1772
|
-
drives: getAvailableDrives(),
|
|
1773
|
-
homeDir: homedir(),
|
|
1774
|
-
}));
|
|
1775
|
-
}
|
|
1776
|
-
catch (error) {
|
|
1777
|
-
const code = error.code;
|
|
1778
|
-
if (code === "EACCES" || code === "EPERM")
|
|
1779
|
-
fail(403, `没有访问权限:${url.searchParams.get("path") ?? rootDir}`);
|
|
1780
|
-
else
|
|
1781
|
-
fail(500, `读取目录失败:${error instanceof Error ? error.message : "未知错误"}`);
|
|
1782
|
-
}
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
if (url.pathname === "/api/upload") {
|
|
1786
|
-
if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
|
|
1787
|
-
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传需要从本机或局域网发起。" }));
|
|
1788
|
-
return;
|
|
1789
|
-
}
|
|
1790
|
-
const json = (status, body) => {
|
|
1791
|
-
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
1792
|
-
};
|
|
1793
|
-
try {
|
|
1794
|
-
let totalBytes = 0;
|
|
1795
|
-
const chunks = [];
|
|
1796
|
-
for await (const chunk of request) {
|
|
1797
|
-
totalBytes += chunk.length;
|
|
1798
|
-
if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
|
|
1799
|
-
json(413, { error: "图片超过了大小上限。" });
|
|
1800
|
-
request.destroy();
|
|
1833
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1834
|
+
const body = JSON.parse(raw);
|
|
1835
|
+
const conversationId = body.conversationId?.trim();
|
|
1836
|
+
if (!conversationId) {
|
|
1837
|
+
json(400, { error: "缺少会话 ID。" });
|
|
1801
1838
|
return;
|
|
1802
1839
|
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1840
|
+
const conversation = store.snapshot().conversations.find((c) => c.id === conversationId);
|
|
1841
|
+
if (!conversation) {
|
|
1842
|
+
json(404, { error: "找不到这个会话——它可能已经被删除。" });
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (!body.mimeType || typeof body.mimeType !== "string") {
|
|
1846
|
+
json(400, { error: "缺少图片类型。" });
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (!body.data || typeof body.data !== "string") {
|
|
1850
|
+
json(400, { error: "缺少图片数据。" });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const catalog = await catalogService.getCatalog(conversation.model);
|
|
1854
|
+
const currentModel = catalog.models.find((m) => m.provider === conversation.model?.provider && m.modelId === conversation.model?.modelId)
|
|
1855
|
+
?? catalog.models.find((m) => m.provider === catalog.defaultModel?.provider && m.modelId === catalog.defaultModel?.modelId);
|
|
1856
|
+
if (currentModel && !currentModel.imageInput) {
|
|
1857
|
+
json(400, { error: `当前模型「${currentModel.label || currentModel.modelId}」不支持图片输入` });
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
const record = await saveUpload(uploadsDir, conversationId, body.name, body.mimeType, body.data);
|
|
1861
|
+
json(200, record);
|
|
1811
1862
|
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
json(404, { error: "找不到这个会话——它可能已经被删除。" });
|
|
1815
|
-
return;
|
|
1863
|
+
catch (error) {
|
|
1864
|
+
json(400, { error: error instanceof Error ? error.message : "Upload failed" });
|
|
1816
1865
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (url.pathname.startsWith("/api/upload/")) {
|
|
1869
|
+
if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
|
|
1870
|
+
response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "读取上传的图片需要从本机或局域网发起。" }));
|
|
1819
1871
|
return;
|
|
1820
1872
|
}
|
|
1821
|
-
|
|
1822
|
-
|
|
1873
|
+
const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
|
|
1874
|
+
if (!pathParts.length || pathParts.length > 2) {
|
|
1875
|
+
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传路径不合法。" }));
|
|
1823
1876
|
return;
|
|
1824
1877
|
}
|
|
1825
|
-
const
|
|
1826
|
-
const
|
|
1827
|
-
|
|
1828
|
-
if (
|
|
1829
|
-
|
|
1878
|
+
const convId = pathParts.length === 2 ? pathParts[0] : undefined;
|
|
1879
|
+
const fileOrId = pathParts.length === 2 ? pathParts[1] : pathParts[0];
|
|
1880
|
+
const found = await findUploadFile(uploadsDir, convId, fileOrId);
|
|
1881
|
+
if (!found) {
|
|
1882
|
+
response.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Image not found" }));
|
|
1830
1883
|
return;
|
|
1831
1884
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1885
|
+
response.writeHead(200, {
|
|
1886
|
+
"content-type": found.mimeType,
|
|
1887
|
+
"x-content-type-options": "nosniff",
|
|
1888
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
1889
|
+
"content-length": found.size,
|
|
1890
|
+
});
|
|
1891
|
+
createReadStream(found.filePath).on("error", () => {
|
|
1892
|
+
if (!response.headersSent) {
|
|
1893
|
+
response.writeHead(500, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Failed to read upload file" }));
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
response.destroy();
|
|
1897
|
+
}).pipe(response);
|
|
1898
|
+
return;
|
|
1834
1899
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1900
|
+
// Static client asset handling
|
|
1901
|
+
const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
1902
|
+
const requestedPath = resolve(clientDir, requested);
|
|
1903
|
+
const safe = !relative(clientDir, requestedPath).startsWith("..") && !relative(clientDir, requestedPath).includes(":");
|
|
1904
|
+
if (!safe) {
|
|
1905
|
+
response.writeHead(403).end();
|
|
1906
|
+
return;
|
|
1837
1907
|
}
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1908
|
+
// Only extensionless URLs are application-navigation requests. A missing hashed
|
|
1909
|
+
// asset must stay a real 404: returning index.html with text/html for /assets/*.js
|
|
1910
|
+
// makes stale browser documents look like successful script loads and leaves the
|
|
1911
|
+
// browser with an invalid application state after a refresh.
|
|
1912
|
+
const requestedExtension = extname(requestedPath);
|
|
1913
|
+
const candidate = existsSync(requestedPath)
|
|
1914
|
+
? requestedPath
|
|
1915
|
+
: requestedExtension
|
|
1916
|
+
? undefined
|
|
1917
|
+
: join(clientDir, "index.html");
|
|
1918
|
+
if (!candidate) {
|
|
1919
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }).end("Not found");
|
|
1843
1920
|
return;
|
|
1844
1921
|
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传路径不合法。" }));
|
|
1922
|
+
if (!existsSync(candidate)) {
|
|
1923
|
+
response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
|
|
1848
1924
|
return;
|
|
1849
1925
|
}
|
|
1850
|
-
const
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
if (!found) {
|
|
1854
|
-
response.writeHead(404, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Image not found" }));
|
|
1926
|
+
const candidateInfo = await stat(candidate).catch(() => undefined);
|
|
1927
|
+
if (!candidateInfo?.isFile()) {
|
|
1928
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
1855
1929
|
return;
|
|
1856
1930
|
}
|
|
1857
1931
|
response.writeHead(200, {
|
|
1858
|
-
"content-type":
|
|
1932
|
+
"content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream",
|
|
1859
1933
|
"x-content-type-options": "nosniff",
|
|
1860
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
1861
|
-
"content-length": found.size,
|
|
1934
|
+
"cache-control": candidate === join(clientDir, "index.html") ? "no-store" : "public, max-age=31536000, immutable",
|
|
1862
1935
|
});
|
|
1863
|
-
createReadStream(
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
}
|
|
1868
|
-
response.destroy();
|
|
1869
|
-
}).pipe(response);
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
// Static client asset handling
|
|
1873
|
-
const requested = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
1874
|
-
const requestedPath = resolve(clientDir, requested);
|
|
1875
|
-
const safe = !relative(clientDir, requestedPath).startsWith("..") && !relative(clientDir, requestedPath).includes(":");
|
|
1876
|
-
if (!safe) {
|
|
1877
|
-
response.writeHead(403).end();
|
|
1878
|
-
return;
|
|
1936
|
+
const stream = createReadStream(candidate);
|
|
1937
|
+
stream.on("error", () => { if (!response.headersSent)
|
|
1938
|
+
response.writeHead(404); response.end(); });
|
|
1939
|
+
stream.pipe(response);
|
|
1879
1940
|
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
}
|
|
1894
|
-
if (!existsSync(candidate)) {
|
|
1895
|
-
response.writeHead(503, { "content-type": "text/plain; charset=utf-8" }).end("Super Pi client has not been built. Run npm run build.");
|
|
1896
|
-
return;
|
|
1897
|
-
}
|
|
1898
|
-
const candidateInfo = await stat(candidate).catch(() => undefined);
|
|
1899
|
-
if (!candidateInfo?.isFile()) {
|
|
1900
|
-
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
1901
|
-
return;
|
|
1941
|
+
catch (error) {
|
|
1942
|
+
// This is the last safety net for an async route. Before this boundary,
|
|
1943
|
+
// an unexpected throw became an unhandled rejection and left clients with
|
|
1944
|
+
// a socket that simply hung. Preserve the real reason in the detached
|
|
1945
|
+
// server log and give the caller a bounded, correlatable response.
|
|
1946
|
+
console.error(`Super Pi HTTP request failed requestId=${requestId} method=${request.method ?? "?"} url=${request.url ?? "?"}:`, describeThrown(error));
|
|
1947
|
+
if (response.headersSent) {
|
|
1948
|
+
response.destroy();
|
|
1949
|
+
}
|
|
1950
|
+
else {
|
|
1951
|
+
response.writeHead(500, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1952
|
+
response.end(JSON.stringify({ error: "服务端处理请求时发生异常。", requestId }));
|
|
1953
|
+
}
|
|
1902
1954
|
}
|
|
1903
|
-
response.writeHead(200, {
|
|
1904
|
-
"content-type": MIME_TYPES[extname(candidate)] ?? "application/octet-stream",
|
|
1905
|
-
"x-content-type-options": "nosniff",
|
|
1906
|
-
"cache-control": candidate === join(clientDir, "index.html") ? "no-store" : "public, max-age=31536000, immutable",
|
|
1907
|
-
});
|
|
1908
|
-
const stream = createReadStream(candidate);
|
|
1909
|
-
stream.on("error", () => { if (!response.headersSent)
|
|
1910
|
-
response.writeHead(404); response.end(); });
|
|
1911
|
-
stream.pipe(response);
|
|
1912
1955
|
});
|
|
1913
1956
|
}
|