@evandu/super-pi 0.1.136 → 0.1.138

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.
Files changed (35) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +5 -5
  3. package/dist/client/assets/{CodeMirrorEditor-CWN8JIx4.js → CodeMirrorEditor-CVImD0nh.js} +2 -2
  4. package/dist/client/assets/{dist-BSOBu3lQ.js → dist-1H_gIENV.js} +1 -1
  5. package/dist/client/assets/{dist-yOveRczL.js → dist-4uPxkRdw.js} +1 -1
  6. package/dist/client/assets/{dist-0vYGgCC5.js → dist-At94VK1P.js} +1 -1
  7. package/dist/client/assets/{dist-DCuAT0n-.js → dist-B75OT6A9.js} +1 -1
  8. package/dist/client/assets/{dist-CYLUz40X.js → dist-BIgRwYsI.js} +1 -1
  9. package/dist/client/assets/{dist-BQjtlfpY.js → dist-BqblSnWL.js} +1 -1
  10. package/dist/client/assets/{dist-DHTyhm7w.js → dist-BwQM8_Mk.js} +1 -1
  11. package/dist/client/assets/{dist-ZvUavnTX.js → dist-CKI1xVeE.js} +1 -1
  12. package/dist/client/assets/{dist-B0vHKLg1.js → dist-CNP-Q5Gs.js} +1 -1
  13. package/dist/client/assets/{dist-DMDxxHpj.js → dist-CUACXe1Y.js} +1 -1
  14. package/dist/client/assets/{dist-CTXz1R9C.js → dist-CiEW7LQW.js} +1 -1
  15. package/dist/client/assets/{dist-BtNKV30Z.js → dist-Clf26yld.js} +1 -1
  16. package/dist/client/assets/{dist-DErmxG4Q.js → dist-Cocw5XJ_.js} +1 -1
  17. package/dist/client/assets/{dist-CV9QN-qU.js → dist-DSCjkO-y.js} +1 -1
  18. package/dist/client/assets/{dist-CwNZuQ4i.js → dist-DhEa5hHR.js} +1 -1
  19. package/dist/client/assets/{dist-Qc0xt1y7.js → dist-DozjvPAZ.js} +1 -1
  20. package/dist/client/assets/{dist-q1T0uV1c.js → dist-YB4GJD3u.js} +1 -1
  21. package/dist/client/assets/{dist-Dalb5yHq.js → dist-amySbADh.js} +1 -1
  22. package/dist/client/assets/{dist-WkcafMsD.js → dist-faxwvvOU.js} +1 -1
  23. package/dist/client/assets/{dist-t0oHfWqq.js → dist-pnGHl3nq.js} +1 -1
  24. package/dist/client/assets/{dist-OKarkelj.js → dist-quQ6u6Rw.js} +1 -1
  25. package/dist/client/assets/{dist-BA6BeFaf.js → dist-yONALz0V.js} +1 -1
  26. package/dist/client/assets/{index-oDRgSKPX.js → index-CNoiRdJC.js} +12 -12
  27. package/dist/client/assets/{index-r-L_XCYs.css → index-FxTrUapG.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.js +9 -2
  30. package/dist/server/application/catalog-service.js +8 -6
  31. package/dist/server/http-server.d.ts +0 -3
  32. package/dist/server/http-server.js +131 -87
  33. package/dist/server/store.d.ts +5 -0
  34. package/dist/server/store.js +16 -0
  35. package/package.json +5 -1
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-oDRgSKPX.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-r-L_XCYs.css">
11
+ <script type="module" crossorigin src="/assets/index-CNoiRdJC.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-FxTrUapG.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -139,7 +139,11 @@ export class ActiveMemory {
139
139
  if (this.indexedWorkspaces.has(workspaceId))
140
140
  return;
141
141
  const index = new Map();
142
- for (const conversation of this.store.snapshot().conversations.filter(c => c.workspaceId === workspaceId))
142
+ const store = this.store;
143
+ const conversations = store.conversationsForWorkspace
144
+ ? store.conversationsForWorkspace(workspaceId)
145
+ : store.snapshot().conversations.filter(c => c.workspaceId === workspaceId);
146
+ for (const conversation of conversations)
143
147
  for (const message of conversation.messages) {
144
148
  if (message.role !== "user" && message.role !== "assistant")
145
149
  continue;
@@ -362,7 +366,10 @@ export class ActiveMemory {
362
366
  const settings = await this.configuration();
363
367
  if (!settings.enabled || !settings.reviewInterval)
364
368
  return;
365
- const workspace = this.store.snapshot().workspaces.find(w => w.id === snapshot.workspaceId);
369
+ const store = this.store;
370
+ const workspace = store.workspace
371
+ ? store.workspace(snapshot.workspaceId)
372
+ : store.snapshot().workspaces.find(w => w.id === snapshot.workspaceId);
366
373
  if (!workspace)
367
374
  return;
368
375
  const agent = await this.runtime.agentFor?.(snapshot);
@@ -1,4 +1,4 @@
1
- import { assertModelAllowed, conversationWorkspace } from "../model-filter.js";
1
+ import { assertModelAllowed } from "../model-filter.js";
2
2
  import { readAgents, resolveAgentDirectories } from "../agents.js";
3
3
  import { loadPromptTemplateSummaries } from "../pi-session.js";
4
4
  function summarize(agent) {
@@ -75,10 +75,12 @@ export class CatalogService {
75
75
  const catalog = await this.runtime.catalog(selection);
76
76
  if (!conversationId || !this.store)
77
77
  return catalog;
78
- const state = this.store.snapshot();
79
- const workspace = conversationWorkspace(state, conversationId);
78
+ const workspace = this.store.workspaceForConversation(conversationId);
79
+ if (!workspace)
80
+ throw new Error("会话所属工作区不存在");
81
+ const rules = this.store.modelFilterRules();
80
82
  return { ...catalog, models: catalog.models.filter(model => { try {
81
- assertModelAllowed(state.modelFilterRules, workspace, model);
83
+ assertModelAllowed(rules, workspace, model);
82
84
  return true;
83
85
  }
84
86
  catch {
@@ -108,7 +110,7 @@ export class CatalogService {
108
110
  throw new Error("Store unavailable");
109
111
  await this.store.setModelFilterRules(rules);
110
112
  this.broadcaster.broadcastState();
111
- for (const conversation of this.store.snapshot().conversations) {
113
+ for (const conversation of this.store.catalogConversationTargets()) {
112
114
  if (!conversation.archivedAt) {
113
115
  try {
114
116
  await this.broadcastCatalog(conversation.model, conversation.id);
@@ -182,7 +184,7 @@ export class CatalogService {
182
184
  const cached = this.promptCatalogs.get(workspaceId);
183
185
  if (cached && cached.expiresAt > Date.now())
184
186
  return cached.catalog;
185
- const workspace = this.store?.snapshot().workspaces.find((item) => item.id === workspaceId);
187
+ const workspace = this.store?.workspace(workspaceId);
186
188
  if (!workspace)
187
189
  return { source: "unavailable", prompts: [], workspaceId, fetchedAt: new Date().toISOString(), error: "workspace not found" };
188
190
  try {
@@ -30,7 +30,4 @@ export interface HttpServerOptions {
30
30
  context: ServerContext;
31
31
  onShutdown: (reason: string) => Promise<void>;
32
32
  }
33
- /**
34
- * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
35
- */
36
33
  export declare function createHttpServer(options: HttpServerOptions): NodeHttpServer;
@@ -230,6 +230,47 @@ function abortedAs(request) {
230
230
  /**
231
231
  * Creates the HTTP server providing static client assets and all `/api/*` endpoints.
232
232
  */
233
+ /**
234
+ * A REQUEST BODY BEING READ AS TEXT, decoded across chunk boundaries.
235
+ *
236
+ * The counterpart of `readRequestText` for the sites that need to inspect the body WHILE it streams
237
+ * (the file uploads, which refuse an oversized body without reading the rest of it).
238
+ */
239
+ function textBodyReader() {
240
+ const decoder = new TextDecoder("utf-8");
241
+ let text = "";
242
+ return {
243
+ push: (chunk) => { text += decoder.decode(chunk, { stream: true }); return text; },
244
+ finish: () => text + decoder.decode(),
245
+ };
246
+ }
247
+ /**
248
+ * THE REQUEST BODY AS TEXT, DECODED ACROSS CHUNK BOUNDARIES.
249
+ *
250
+ * `raw += chunk` decodes EVERY CHUNK ON ITS OWN: the chunk is a Buffer, and concatenating it into a
251
+ * string calls `toString("utf8")` on it. A UTF-8 character that straddles two chunks is then two
252
+ * half-characters, and each half decodes to U+FFFD - the replacement character. So a Chinese
253
+ * sentence that happens to be split in the wrong place is silently corrupted, and the corrupted text
254
+ * is what the endpoint PERSISTS (a knowledge note, a memory, a scheduled task). The corruption is
255
+ * invisible - the JSON still parses - and permanent.
256
+ *
257
+ * A `TextDecoder` in streaming mode keeps the incomplete tail of a chunk and completes it with the
258
+ * next one, which is what "the body is one UTF-8 document" means. The size guards count BYTES, which
259
+ * is what a body limit is about anyway.
260
+ */
261
+ async function readRequestText(request, limitBytes) {
262
+ const decoder = new TextDecoder("utf-8");
263
+ let text = "";
264
+ let bytes = 0;
265
+ for await (const chunk of request) {
266
+ const piece = chunk;
267
+ bytes += piece.length;
268
+ if (bytes > limitBytes)
269
+ return { text: "", tooLarge: true };
270
+ text += decoder.decode(piece, { stream: true });
271
+ }
272
+ return { text: text + decoder.decode(), tooLarge: false };
273
+ }
233
274
  export function createHttpServer(options) {
234
275
  const { context, onShutdown } = options;
235
276
  const { store, paths, accessToken, broadcaster, runService, catalogService, execService, toolRegistry, toolRunner } = context;
@@ -320,16 +361,12 @@ export function createHttpServer(options) {
320
361
  // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
321
362
  // units at 256,000, below the registry's own 512,000-byte asset limit, so JSON escaping decided
322
363
  // where the real ceiling was and an exported bundle could be refused on the way back in.
323
- let raw = "", bytes = 0;
324
- for await (const chunk of request) {
325
- const piece = chunk;
326
- bytes += piece.length;
327
- if (bytes > MAX_TOOL_BODY_BYTES) {
328
- json(413, { error: "导入的内容太大,超过了大小上限。" });
329
- return;
330
- }
331
- raw += piece.toString("utf8");
364
+ const imported = await readRequestText(request, MAX_TOOL_BODY_BYTES);
365
+ if (imported.tooLarge) {
366
+ json(413, { error: "导入的内容太大,超过了大小上限。" });
367
+ return;
332
368
  }
369
+ const raw = imported.text;
333
370
  const body = JSON.parse(raw);
334
371
  if (!isAuthorized(request, accessToken)) {
335
372
  if (!body.conversationId) {
@@ -404,14 +441,12 @@ export function createHttpServer(options) {
404
441
  toolApi(405, { error: "method" });
405
442
  return;
406
443
  }
407
- let raw = "";
408
- for await (const chunk of request) {
409
- raw += chunk;
410
- if (raw.length > 64_000) {
411
- toolApi(413, { error: "request too large" });
412
- return;
413
- }
444
+ const executed = await readRequestText(request, 64_000);
445
+ if (executed.tooLarge) {
446
+ toolApi(413, { error: "request too large" });
447
+ return;
414
448
  }
449
+ const raw = executed.text;
415
450
  try {
416
451
  const body = JSON.parse(raw);
417
452
  const resolved = await toolRegistry.resolve(id);
@@ -501,14 +536,12 @@ export function createHttpServer(options) {
501
536
  toolApi(405, { error: "method" });
502
537
  return;
503
538
  }
504
- let raw = "";
505
- for await (const chunk of request) {
506
- raw += chunk;
507
- if (raw.length > 64_000) {
508
- toolApi(413, { error: "request too large" });
509
- return;
510
- }
539
+ const asked = await readRequestText(request, 64_000);
540
+ if (asked.tooLarge) {
541
+ toolApi(413, { error: "request too large" });
542
+ return;
511
543
  }
544
+ const raw = asked.text;
512
545
  try {
513
546
  const body = JSON.parse(raw);
514
547
  toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
@@ -598,14 +631,10 @@ export function createHttpServer(options) {
598
631
  return;
599
632
  }
600
633
  try {
601
- let raw = "";
602
- let totalBytes = 0;
603
- for await (const chunk of request) {
604
- totalBytes += chunk.length;
605
- if (totalBytes > 512000)
606
- throw new Error("记忆请求过大");
607
- raw += chunk;
608
- }
634
+ const remembered = await readRequestText(request, 512000);
635
+ if (remembered.tooLarge)
636
+ throw new Error("记忆请求过大");
637
+ const raw = remembered.text;
609
638
  const body = raw ? JSON.parse(raw) : {};
610
639
  const result = url.pathname.endsWith("/tool")
611
640
  ? await context.memory.tool(String(request.headers["x-memory-capability"] ?? ""), body)
@@ -634,9 +663,10 @@ export function createHttpServer(options) {
634
663
  let name = url.searchParams.get("name") ?? url.searchParams.get("skillId") ?? "";
635
664
  if (request.method === "POST") {
636
665
  try {
637
- let raw = "";
638
- for await (const chunk of request)
639
- raw += chunk;
666
+ const asked = await readRequestText(request, 512000);
667
+ if (asked.tooLarge)
668
+ throw new Error("记忆请求过大");
669
+ const raw = asked.text;
640
670
  if (raw) {
641
671
  const body = JSON.parse(raw);
642
672
  if (body.workspaceId)
@@ -673,9 +703,12 @@ export function createHttpServer(options) {
673
703
  return;
674
704
  }
675
705
  try {
676
- let raw = "";
677
- for await (const chunk of request)
678
- raw += chunk;
706
+ const asked = await readRequestText(request, 512000);
707
+ if (asked.tooLarge) {
708
+ json(413, { error: "请求过大" });
709
+ return;
710
+ }
711
+ const raw = asked.text;
679
712
  const body = raw ? JSON.parse(raw) : {};
680
713
  const models = await context.providerService.fetchModels(body);
681
714
  json(200, { ok: true, models });
@@ -704,14 +737,12 @@ export function createHttpServer(options) {
704
737
  let totalBytes = 0;
705
738
  // A pasted document rides in the body, so the cap is the document cap - past it the request is
706
739
  // refused outright rather than truncated into a document that silently lost its tail.
707
- for await (const chunk of request) {
708
- totalBytes += chunk.length;
709
- if (totalBytes > MAX_KNOWLEDGE_REQUEST_BYTES) {
710
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "知识库请求过大" }));
711
- return;
712
- }
713
- raw += chunk;
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: "知识库请求过大" }));
743
+ return;
714
744
  }
745
+ raw = known.text;
715
746
  const body = raw ? JSON.parse(raw) : {};
716
747
  const result = url.pathname.endsWith("/tool")
717
748
  ? await context.knowledge.tool(String(request.headers["x-knowledge-capability"] ?? ""), body, abortedAs(request))
@@ -743,14 +774,12 @@ export function createHttpServer(options) {
743
774
  // A DOCUMENT'S TEXT RIDES IN THE REQUEST BODY, so the cap is the document cap: a request
744
775
  // past it is refused outright rather than truncated into a document that silently lost its
745
776
  // tail.
746
- for await (const chunk of request) {
747
- totalBytes += chunk.length;
748
- if (totalBytes > MAX_DOCUMENT_REQUEST_BYTES) {
749
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "文档请求过大" }));
750
- return;
751
- }
752
- raw += chunk;
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: "文档请求过大" }));
780
+ return;
753
781
  }
782
+ raw = documented.text;
754
783
  const body = raw ? JSON.parse(raw) : {};
755
784
  // The capability says WHICH CONVERSATION is asking, and the workspace is derived from it
756
785
  // rather than taken from the request: a request that named its own workspace would be a
@@ -787,14 +816,12 @@ export function createHttpServer(options) {
787
816
  }
788
817
  try {
789
818
  let raw = "", totalBytes = 0;
790
- for await (const chunk of request) {
791
- totalBytes += chunk.length;
792
- if (totalBytes > MAX_WEB_REQUEST_BYTES) {
793
- response.writeHead(413).end();
794
- return;
795
- }
796
- raw += chunk;
819
+ const asked = await readRequestText(request, MAX_WEB_REQUEST_BYTES);
820
+ if (asked.tooLarge) {
821
+ response.writeHead(413).end();
822
+ return;
797
823
  }
824
+ raw = asked.text;
798
825
  const conversationId = String(request.headers["x-web-conversation"] ?? "");
799
826
  const verdict = verifyCapability(conversationId, request.headers["x-web-capability"], { running: context.runService.isRunning(conversationId) });
800
827
  if (!verdict.ok) {
@@ -831,14 +858,12 @@ export function createHttpServer(options) {
831
858
  try {
832
859
  let raw = "", totalBytes = 0;
833
860
  // The Markdown of a document rides in the request body, so the cap is the document cap.
834
- for await (const chunk of request) {
835
- totalBytes += chunk.length;
836
- if (totalBytes > MAX_FEISHU_REQUEST_BYTES) {
837
- response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "飞书请求过大" }));
838
- return;
839
- }
840
- raw += chunk;
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: "飞书请求过大" }));
864
+ return;
841
865
  }
866
+ raw = asked.text;
842
867
  const conversationId = String(request.headers["x-feishu-conversation"] ?? "");
843
868
  // THE SAME PROOF THE DOCUMENT TOOLS USE: the request must carry the session's own
844
869
  // capability, and the session must actually be running a turn. The Feishu credential is a
@@ -1050,9 +1075,12 @@ export function createHttpServer(options) {
1050
1075
  json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
1051
1076
  return;
1052
1077
  }
1053
- let raw = "";
1054
- for await (const chunk of request)
1055
- raw += chunk;
1078
+ const asked = await readRequestText(request, 512000);
1079
+ if (asked.tooLarge) {
1080
+ json(413, { error: "请求过大" });
1081
+ return;
1082
+ }
1083
+ const raw = asked.text;
1056
1084
  const body = raw ? JSON.parse(raw) : {};
1057
1085
  json(200, await gitMutate(workspace.path, op, body.path, body.message));
1058
1086
  return;
@@ -1113,15 +1141,18 @@ export function createHttpServer(options) {
1113
1141
  }
1114
1142
  let raw = "";
1115
1143
  let totalBytes = 0;
1144
+ const reader = textBodyReader();
1116
1145
  for await (const chunk of request) {
1117
- totalBytes += chunk.length;
1146
+ const piece = chunk;
1147
+ totalBytes += piece.length;
1118
1148
  if (totalBytes > MAX_FILE_BYTES * 2) {
1119
1149
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1120
1150
  request.destroy();
1121
1151
  return;
1122
1152
  }
1123
- raw += chunk;
1153
+ raw = reader.push(piece);
1124
1154
  }
1155
+ raw = reader.finish();
1125
1156
  const json = (status, body) => {
1126
1157
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1127
1158
  };
@@ -1151,15 +1182,18 @@ export function createHttpServer(options) {
1151
1182
  }
1152
1183
  let raw = "";
1153
1184
  let totalBytes = 0;
1185
+ const reader = textBodyReader();
1154
1186
  for await (const chunk of request) {
1155
- totalBytes += chunk.length;
1187
+ const piece = chunk;
1188
+ totalBytes += piece.length;
1156
1189
  if (totalBytes > MAX_FILE_BYTES * 2) {
1157
1190
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1158
1191
  request.destroy();
1159
1192
  return;
1160
1193
  }
1161
- raw += chunk;
1194
+ raw = reader.push(piece);
1162
1195
  }
1196
+ raw = reader.finish();
1163
1197
  const json = (status, body) => {
1164
1198
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1165
1199
  };
@@ -1234,15 +1268,18 @@ export function createHttpServer(options) {
1234
1268
  }
1235
1269
  let raw = "";
1236
1270
  let totalBytes = 0;
1271
+ const reader = textBodyReader();
1237
1272
  for await (const chunk of request) {
1238
- totalBytes += chunk.length;
1273
+ const piece = chunk;
1274
+ totalBytes += piece.length;
1239
1275
  if (totalBytes > MAX_FILE_BYTES * 2) {
1240
1276
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1241
1277
  request.destroy();
1242
1278
  return;
1243
1279
  }
1244
- raw += chunk;
1280
+ raw = reader.push(piece);
1245
1281
  }
1282
+ raw = reader.finish();
1246
1283
  const json = (status, body) => {
1247
1284
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1248
1285
  };
@@ -1262,15 +1299,18 @@ export function createHttpServer(options) {
1262
1299
  }
1263
1300
  let raw = "";
1264
1301
  let totalBytes = 0;
1302
+ const reader = textBodyReader();
1265
1303
  for await (const chunk of request) {
1266
- totalBytes += chunk.length;
1304
+ const piece = chunk;
1305
+ totalBytes += piece.length;
1267
1306
  if (totalBytes > MAX_FILE_BYTES * 2) {
1268
1307
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1269
1308
  request.destroy();
1270
1309
  return;
1271
1310
  }
1272
- raw += chunk;
1311
+ raw = reader.push(piece);
1273
1312
  }
1313
+ raw = reader.finish();
1274
1314
  const json = (status, body) => {
1275
1315
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1276
1316
  };
@@ -1303,15 +1343,18 @@ export function createHttpServer(options) {
1303
1343
  }
1304
1344
  let raw = "";
1305
1345
  let totalBytes = 0;
1346
+ const reader = textBodyReader();
1306
1347
  for await (const chunk of request) {
1307
- totalBytes += chunk.length;
1348
+ const piece = chunk;
1349
+ totalBytes += piece.length;
1308
1350
  if (totalBytes > MAX_FILE_BYTES * 2) {
1309
1351
  response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1310
1352
  request.destroy();
1311
1353
  return;
1312
1354
  }
1313
- raw += chunk;
1355
+ raw = reader.push(piece);
1314
1356
  }
1357
+ raw = reader.finish();
1315
1358
  const json = (status, body) => {
1316
1359
  response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1317
1360
  };
@@ -1407,14 +1450,12 @@ export function createHttpServer(options) {
1407
1450
  // body cost its full size in memory before the 403 said no. A question is a few hundred bytes;
1408
1451
  // 64KB is orders of magnitude above any honest one and far below what a hostile body could use,
1409
1452
  // and the shape is the same ceiling the `/api/tools` endpoints already enforce.
1410
- let raw = "";
1411
- for await (const chunk of request) {
1412
- raw += chunk;
1413
- if (raw.length > 64_000) {
1414
- response.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ error: "question request too large" }));
1415
- return;
1416
- }
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
+ return;
1417
1457
  }
1458
+ const raw = asked.text;
1418
1459
  try {
1419
1460
  const body = JSON.parse(raw);
1420
1461
  if (!body.conversationId || !body.question || !Array.isArray(body.options))
@@ -1633,15 +1674,18 @@ export function createHttpServer(options) {
1633
1674
  }
1634
1675
  let raw = "";
1635
1676
  let totalBytes = 0;
1677
+ const reader = textBodyReader();
1636
1678
  for await (const chunk of request) {
1637
- totalBytes += chunk.length;
1679
+ const piece = chunk;
1680
+ totalBytes += piece.length;
1638
1681
  if (totalBytes > MAX_FILE_BYTES * 2) {
1639
1682
  json(413, { error: "请求体过大", code: "too_large" });
1640
1683
  request.destroy();
1641
1684
  return;
1642
1685
  }
1643
- raw += chunk;
1686
+ raw = reader.push(piece);
1644
1687
  }
1688
+ raw = reader.finish();
1645
1689
  const declared = Number(request.headers["content-length"] ?? "0");
1646
1690
  if (Number.isFinite(declared) && declared > MAX_FILE_BYTES * 2) {
1647
1691
  json(413, { error: "请求体过大", code: "too_large" });
@@ -118,6 +118,11 @@ export declare class Store {
118
118
  * caller cannot reach into the store's state through it.
119
119
  */
120
120
  workspaceForConversation(conversationId: string): Workspace | undefined;
121
+ workspace(workspaceId: string): Workspace | undefined;
122
+ /** Small detached rows used when refreshing each conversation's scoped model catalog. */
123
+ catalogConversationTargets(): Array<Pick<Conversation, "id" | "archivedAt" | "model">>;
124
+ /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
125
+ conversationsForWorkspace(workspaceId: string): Conversation[];
121
126
  setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
122
127
  /**
123
128
  * Sets (or clears) the app-wide default model.
@@ -717,6 +717,22 @@ export class Store {
717
717
  const workspace = this.state.workspaces.find((item) => item.id === conversation?.workspaceId);
718
718
  return workspace ? { ...workspace } : undefined;
719
719
  }
720
+ workspace(workspaceId) {
721
+ const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
722
+ return workspace ? { ...workspace } : undefined;
723
+ }
724
+ /** Small detached rows used when refreshing each conversation's scoped model catalog. */
725
+ catalogConversationTargets() {
726
+ return this.state.conversations.map(({ id, archivedAt, model }) => ({
727
+ id,
728
+ archivedAt,
729
+ model: model ? { ...model } : undefined,
730
+ }));
731
+ }
732
+ /** Clone only one workspace's transcripts, never unrelated workspaces or app state. */
733
+ conversationsForWorkspace(workspaceId) {
734
+ return structuredClone(this.state.conversations.filter((item) => item.workspaceId === workspaceId));
735
+ }
720
736
  async setModelFilterRules(rules) {
721
737
  if (!Array.isArray(rules) || rules.some(rule => !rule || typeof rule.id !== "string" || typeof rule.workspacePattern !== "string" || typeof rule.modelPattern !== "string" || (rule.label !== undefined && typeof rule.label !== "string")))
722
738
  throw new Error("模型过滤规则格式不合法");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.136",
3
+ "version": "0.1.138",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -111,6 +111,7 @@
111
111
  "scripts/todo-tool.test.mjs",
112
112
  "scripts/tool-calls.test.mjs",
113
113
  "scripts/files-api.test.mjs",
114
+ "scripts/http-body-utf8-chunking.test.mjs",
114
115
  "scripts/files-eol-roundtrip.test.mjs",
115
116
  "scripts/files-missing-path.test.mjs",
116
117
  "scripts/file-changes.test.mjs",
@@ -187,6 +188,9 @@
187
188
  "scripts/visual-defects-browser.test.mjs",
188
189
  "scripts/session-state-animation-browser.test.mjs",
189
190
  "scripts/session-state-delivery-browser.test.mjs",
191
+ "scripts/transcript-render-scaling-browser.test.mjs",
192
+ "scripts/question-awaiting-signal-browser.test.mjs",
193
+ "scripts/withdraw-then-send-browser.test.mjs",
190
194
  "scripts/agent-vis-card-browser.test.mjs",
191
195
  "scripts/queue-dock.test.mjs",
192
196
  "scripts/scheduled-tasks-browser.test.mjs",