@evandu/super-pi 0.1.117 → 0.1.118

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 (38) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +14 -13
  3. package/dist/client/assets/{CodeMirrorEditor-BH5J2GdQ.js → CodeMirrorEditor-D9UWxggU.js} +2 -2
  4. package/dist/client/assets/{dist-CxbavTFQ.js → dist-Adu9a_hi.js} +1 -1
  5. package/dist/client/assets/{dist-D-8cPfzK.js → dist-BIjlwIeL.js} +1 -1
  6. package/dist/client/assets/{dist-DPEoGzVX.js → dist-BSBqp9tS.js} +1 -1
  7. package/dist/client/assets/{dist-D5kvjJM0.js → dist-BUJwJ5eB.js} +1 -1
  8. package/dist/client/assets/{dist-CPSsQVsd.js → dist-BWxNjbza.js} +1 -1
  9. package/dist/client/assets/{dist-Cfl1oidr.js → dist-BewFlCBY.js} +1 -1
  10. package/dist/client/assets/{dist-CAggG58v.js → dist-Boep2KRH.js} +1 -1
  11. package/dist/client/assets/{dist-Brf99eWd.js → dist-C7waPnE-.js} +1 -1
  12. package/dist/client/assets/{dist-DL42CYQW.js → dist-CDI2Ko-l.js} +1 -1
  13. package/dist/client/assets/{dist-C_fMe2R1.js → dist-CMRuQ4se.js} +1 -1
  14. package/dist/client/assets/{dist-rVgYnzOf.js → dist-COGr0kGo.js} +1 -1
  15. package/dist/client/assets/{dist-DMyH52V_.js → dist-C_eAtLLb.js} +1 -1
  16. package/dist/client/assets/{dist-Yfb6CLpa.js → dist-ClX_xXLe.js} +1 -1
  17. package/dist/client/assets/{dist-L8KF4gPt.js → dist-D9571BVm.js} +1 -1
  18. package/dist/client/assets/{dist-85yGn29Y.js → dist-DncjdwQz.js} +1 -1
  19. package/dist/client/assets/{dist-C3jDn6Sh.js → dist-ce3zYUZG.js} +1 -1
  20. package/dist/client/assets/index-CF45rxGL.js +99 -0
  21. package/dist/client/assets/{index-zFkgUK3I.css → index-DGPYJzbU.css} +1 -1
  22. package/dist/client/index.html +2 -2
  23. package/dist/server/application/conversation-service.js +4 -4
  24. package/dist/server/application/provider-service.d.ts +11 -1
  25. package/dist/server/application/provider-service.js +27 -6
  26. package/dist/server/application/run-service.js +2 -61
  27. package/dist/server/application/workspace-service.js +1 -1
  28. package/dist/server/http-server.js +46 -28
  29. package/dist/server/providers.d.ts +10 -1
  30. package/dist/server/providers.js +95 -0
  31. package/dist/server/store.js +18 -18
  32. package/dist/server/terminal.js +3 -1
  33. package/dist/server/tool-registry.js +16 -16
  34. package/dist/server/turn-failure.d.ts +22 -0
  35. package/dist/server/turn-failure.js +82 -0
  36. package/dist/server/types.d.ts +8 -0
  37. package/package.json +5 -2
  38. package/dist/client/assets/index-7IbxTlW9.js +0 -99
@@ -436,7 +436,7 @@ function planRevert(state, conversationId, messageId, withdraw = false) {
436
436
  if (!withdraw && !boundary?.piEntryId)
437
437
  throw new Error("这条消息没有对应的 Pi 记录,不能作为回退位置。请在它之后的任意一条消息上回退。");
438
438
  if (withdraw && boundary.role !== "user")
439
- throw new Error("Only user messages can be withdrawn");
439
+ throw new Error("只能撤回你自己发送的消息。");
440
440
  const removed = conversation.messages.slice(boundaryIndex + (withdraw ? 0 : 1));
441
441
  // Unparseable stamps are compared as 0, i.e. "older than everything", which keeps a corrupted run
442
442
  // record ALIVE rather than deleting a child that may still be the user's most recent work. Deleting on a
@@ -719,10 +719,10 @@ export class Store {
719
719
  async createWorkspace(name, path) {
720
720
  const cleanName = name.trim();
721
721
  if (!cleanName || cleanName.length > 80)
722
- throw new Error("Workspace name must be 1-80 characters");
722
+ throw new Error("工作区名称需要 1-80 个字符。");
723
723
  const cleanPath = path?.trim() || process.cwd();
724
724
  if (cleanPath.length > 500)
725
- throw new Error("Workspace path is too long");
725
+ throw new Error("工作区路径太长了。");
726
726
  const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
727
727
  this.state.workspaces.push(workspace);
728
728
  await this.persist();
@@ -731,10 +731,10 @@ export class Store {
731
731
  async renameWorkspace(workspaceId, name) {
732
732
  const cleanName = name.trim();
733
733
  if (!cleanName || cleanName.length > 80)
734
- throw new Error("Workspace name must be 1-80 characters");
734
+ throw new Error("工作区名称需要 1-80 个字符。");
735
735
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
736
736
  if (!workspace)
737
- throw new Error("Workspace not found");
737
+ throw new Error("找不到这个工作区。");
738
738
  workspace.name = cleanName;
739
739
  workspace.updatedAt = now();
740
740
  await this.persist();
@@ -761,7 +761,7 @@ export class Store {
761
761
  async setWorkspaceDefaults(workspaceId, defaults) {
762
762
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
763
763
  if (!workspace)
764
- throw new Error("Workspace not found");
764
+ throw new Error("找不到这个工作区。");
765
765
  if (defaults.model === undefined)
766
766
  delete workspace.defaultModel;
767
767
  else
@@ -781,9 +781,9 @@ export class Store {
781
781
  }
782
782
  async deleteWorkspace(workspaceId) {
783
783
  if (!this.state.workspaces.some((item) => item.id === workspaceId))
784
- throw new Error("Workspace not found");
784
+ throw new Error("找不到这个工作区。");
785
785
  if (this.state.workspaces.length <= 1)
786
- throw new Error("Cannot delete the last workspace");
786
+ throw new Error("至少要保留一个工作区,不能把最后一个删掉。");
787
787
  // Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
788
788
  // both together. A waiting message needs no sweep of its own any more - it is a message inside one of
789
789
  // the conversations being removed, so it goes with its container, which is the whole reason it is a
@@ -799,7 +799,7 @@ export class Store {
799
799
  async createConversation(workspaceId, configuration) {
800
800
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
801
801
  if (!workspace)
802
- throw new Error("Workspace not found");
802
+ throw new Error("找不到这个工作区。");
803
803
  const conversation = { id: id("conversation"), title: DEFAULT_CONVERSATION_TITLE, workspaceId, updatedAt: now(), status: "idle", messages: [] };
804
804
  // INHERITANCE, AS A COPY AT BIRTH. The workspace's defaults are stamped onto the new conversation
805
805
  // here and never consulted again: the session picker edits the conversation's own fields, so an
@@ -952,7 +952,7 @@ export class Store {
952
952
  async renameConversation(conversationId, name) {
953
953
  const cleanName = name.trim();
954
954
  if (!cleanName || cleanName.length > 80)
955
- throw new Error("Conversation name must be 1-80 characters");
955
+ throw new Error("会话名称需要 1-80 个字符。");
956
956
  const conversation = this.requireConversation(conversationId);
957
957
  conversation.title = cleanName;
958
958
  conversation.titleLocked = true;
@@ -1167,7 +1167,7 @@ export class Store {
1167
1167
  const conversation = this.requireConversation(conversationId);
1168
1168
  const message = conversation.messages.find((item) => item.id === messageId);
1169
1169
  if (!message)
1170
- throw new Error("Assistant message not found");
1170
+ throw new Error("找不到对应的助手消息。");
1171
1171
  message.content += delta;
1172
1172
  conversation.updatedAt = now();
1173
1173
  }
@@ -1189,7 +1189,7 @@ export class Store {
1189
1189
  const conversation = this.requireConversation(conversationId);
1190
1190
  const message = conversation.messages.find((item) => item.id === messageId);
1191
1191
  if (!message)
1192
- throw new Error("Assistant message not found");
1192
+ throw new Error("找不到对应的助手消息。");
1193
1193
  message.thinking = `${message.thinking ?? ""}${delta}`;
1194
1194
  const blocks = (message.thinkingBlocks ??= []);
1195
1195
  const last = blocks.at(-1);
@@ -1213,7 +1213,7 @@ export class Store {
1213
1213
  const conversation = this.requireConversation(conversationId);
1214
1214
  const message = conversation.messages.find((item) => item.id === messageId);
1215
1215
  if (!message)
1216
- throw new Error("Assistant message not found");
1216
+ throw new Error("找不到对应的助手消息。");
1217
1217
  await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
1218
1218
  }
1219
1219
  /**
@@ -1240,7 +1240,7 @@ export class Store {
1240
1240
  const conversation = this.requireConversation(conversationId);
1241
1241
  const message = conversation.messages.find((item) => item.id === messageId);
1242
1242
  if (!message)
1243
- throw new Error("Assistant message not found");
1243
+ throw new Error("找不到对应的助手消息。");
1244
1244
  const calls = (message.toolCalls ??= []);
1245
1245
  // WHERE IN THE ANSWER THIS CALL HAPPENED, stamped here because this is the only place that knows both
1246
1246
  // halves: the call arrived, and the message already holds every character streamed before it. Without
@@ -1287,7 +1287,7 @@ export class Store {
1287
1287
  const conversation = this.requireConversation(conversationId);
1288
1288
  const message = conversation.messages.find((item) => item.id === messageId);
1289
1289
  if (!message)
1290
- throw new Error("Assistant message not found");
1290
+ throw new Error("找不到对应的助手消息。");
1291
1291
  const calls = message.toolCalls;
1292
1292
  const call = calls?.[index];
1293
1293
  if (!calls || !call)
@@ -1323,7 +1323,7 @@ export class Store {
1323
1323
  const conversation = this.requireConversation(conversationId);
1324
1324
  const message = conversation.messages.find((item) => item.id === messageId);
1325
1325
  if (!message)
1326
- throw new Error("Assistant message not found");
1326
+ throw new Error("找不到对应的助手消息。");
1327
1327
  const calls = message.toolCalls;
1328
1328
  const call = calls?.[index];
1329
1329
  if (!calls || !call)
@@ -1389,7 +1389,7 @@ export class Store {
1389
1389
  const conversation = this.requireConversation(conversationId);
1390
1390
  const message = conversation.messages.find((item) => item.id === messageId);
1391
1391
  if (!message)
1392
- throw new Error("Assistant message not found");
1392
+ throw new Error("找不到对应的助手消息。");
1393
1393
  const calls = message.toolCalls;
1394
1394
  if (!calls)
1395
1395
  return [];
@@ -1491,7 +1491,7 @@ export class Store {
1491
1491
  return plan;
1492
1492
  }
1493
1493
  requireConversation(conversationId) { const conversation = this.state.conversations.find((item) => item.id === conversationId); if (!conversation)
1494
- throw new Error("Conversation not found"); return conversation; }
1494
+ throw new Error("找不到这个会话。"); return conversation; }
1495
1495
  // ------------------------------------------------------------- Waiting messages
1496
1496
  //
1497
1497
  // A message the user sent while the agent was working WAITS HERE, and "here" is a field on the message
@@ -81,6 +81,7 @@ function loadPty() {
81
81
  }
82
82
  catch (error) {
83
83
  ptyLoadFailure = error instanceof Error ? error.message : String(error);
84
+ console.warn(`Super Pi: 终端组件 node-pty 加载失败,右侧栏终端将不可用:${ptyLoadFailure}`);
84
85
  return undefined;
85
86
  }
86
87
  }
@@ -172,8 +173,9 @@ export function isTerminalEnabled(env = process.env) {
172
173
  }
173
174
  /** The sentence a browser shows when the terminal is missing. Says WHICH of the three gates closed. */
174
175
  export function terminalDisabledReason(_env = process.env) {
176
+ // The raw native error is logged when it happens (loadPty); the browser gets a sentence it can act on.
175
177
  if (ptyLoadFailure)
176
- return "node-pty unavailable: " + ptyLoadFailure;
178
+ return "终端组件(node-pty)没有加载成功,请重新安装依赖或重启 Super Pi。";
177
179
  return "";
178
180
  }
179
181
  export class TerminalService {
@@ -38,27 +38,27 @@ export class ToolRegistry {
38
38
  await this.load();
39
39
  const slug = input.slug.trim().toLowerCase();
40
40
  if (!SLUG.test(slug))
41
- throw new Error("slug must contain lowercase letters, digits, and hyphens only");
41
+ throw new Error("工具名(slug)只能包含小写字母、数字和连字符");
42
42
  if (!input.name.trim() || input.name.length > 120)
43
- throw new Error("tool name is invalid");
43
+ throw new Error("工具名称不合法(1-120 个字符)");
44
44
  if (this.items.some((x) => x.slug === slug))
45
- throw new Error("tool slug already exists");
45
+ throw new Error("已经有同名(slug)的工具了,换一个名字再导入");
46
46
  if (this.items.length >= MAX_TOOLS)
47
- throw new Error("tool limit reached");
47
+ throw new Error("工具数量已经达到上限");
48
48
  if (!Array.isArray(input.files) || !input.files.length || input.files.length > MAX_ASSETS)
49
- throw new Error("tool must contain 1-32 files");
49
+ throw new Error("一个工具需要包含 1-32 个文件");
50
50
  const entrypoint = safeRelative(input.entrypoint ?? "index.html");
51
51
  if (!entrypoint.endsWith(".html"))
52
- throw new Error("entrypoint must be an HTML file");
52
+ throw new Error("入口文件必须是 .html");
53
53
  const files = normalizeAssets(input.files);
54
54
  if (!files.some((file) => file.path === entrypoint))
55
- throw new Error("entrypoint must be included in files");
55
+ throw new Error("入口文件必须出现在文件列表里");
56
56
  const script = input.script ? normalizeScript(input.script, files) : undefined;
57
57
  const environment = input.environment ? normalizeEnvironment(input.environment) : undefined;
58
58
  const permissions = normalizePermissions(input.permissions);
59
59
  const finalDir = join(this.toolsDir, slug), stagingDir = `${finalDir}.${crypto.randomUUID()}.staging`;
60
60
  if (await stat(finalDir).catch(() => undefined))
61
- throw new Error("tool directory already exists");
61
+ throw new Error("工具目录已经存在");
62
62
  try {
63
63
  await writeAssets(stagingDir, files);
64
64
  await rename(stagingDir, finalDir);
@@ -84,7 +84,7 @@ export class ToolRegistry {
84
84
  async resolve(id, resource = "") { await this.load(); const tool = this.items.find((x) => x.id === id && x.enabled); if (!tool)
85
85
  return undefined; const root = join(this.toolsDir, tool.slug), file = resolve(root, resource ? safeRelative(resource) : tool.entrypoint); if (!inside(root, file))
86
86
  throw new Error("tool resource must stay inside the tool directory"); const info = await stat(file).catch(() => undefined); if (!info?.isFile())
87
- throw new Error("tool file does not exist"); return { tool: copy(tool), file, root }; }
87
+ throw new Error("工具文件不存在"); return { tool: copy(tool), file, root }; }
88
88
  /**
89
89
  * THE EXPORT, AND THE ONLY PRODUCER OF A SHAREABLE BUNDLE.
90
90
  *
@@ -126,12 +126,12 @@ export class ToolRegistry {
126
126
  }
127
127
  }
128
128
  function normalizeAssets(files) { const paths = new Set(); let bytes = 0; return files.map((item) => { if (!item || typeof item.content !== "string")
129
- throw new Error("tool file content must be text"); const path = safeRelative(item.path); if (paths.has(path))
130
- throw new Error(`duplicate tool file: ${path}`); paths.add(path); bytes += Buffer.byteLength(item.content); if (bytes > MAX_ASSET_BYTES)
131
- throw new Error("tool files exceed 512KB"); return { path, content: item.content }; }); }
129
+ throw new Error("工具文件的内容必须是文本"); const path = safeRelative(item.path); if (paths.has(path))
130
+ throw new Error(`工具里有重复的文件:${path}`); paths.add(path); bytes += Buffer.byteLength(item.content); if (bytes > MAX_ASSET_BYTES)
131
+ throw new Error("工具文件总大小超过 512KB"); return { path, content: item.content }; }); }
132
132
  function normalizeScript(script, files) { const path = safeRelative(script.path); if (!path.endsWith(".py") || !files.some((x) => x.path === path))
133
- throw new Error("script must name an included .py file"); if (script.parameters && Object.values(script.parameters).some((x) => !isParam(x)))
134
- throw new Error("script parameter schema is invalid"); return { path, timeoutMs: script.timeoutMs, parameters: script.parameters }; }
133
+ throw new Error("脚本必须指向文件列表里的一个 .py 文件"); if (script.parameters && Object.values(script.parameters).some((x) => !isParam(x)))
134
+ throw new Error("脚本的参数定义不合法"); return { path, timeoutMs: script.timeoutMs, parameters: script.parameters }; }
135
135
  /**
136
136
  * THE PIN IS CHECKED HERE, ON THE WAY IN, AND A RANGE IS A REFUSAL RATHER THAN A WARNING.
137
137
  * A warning would throw away exactly what the environment buys: by the time the user learned that
@@ -199,12 +199,12 @@ async function collectAssets(root, dir, files, skipped) {
199
199
  async function writeAssets(root, files) { for (const asset of files) {
200
200
  const target = resolve(root, asset.path);
201
201
  if (!inside(root, target))
202
- throw new Error("tool file must stay inside its tool directory");
202
+ throw new Error("工具文件不能写到工具目录之外");
203
203
  await mkdir(dirname(target), { recursive: true });
204
204
  await writeFile(target, asset.content, "utf8");
205
205
  } }
206
206
  function safeRelative(value) { const p = value.replace(/\\/g, "/").trim(); if (!p || p.startsWith("/") || p.includes("\0") || p.split("/").includes(".."))
207
- throw new Error("path must be relative and must not escape the tool directory"); return p; }
207
+ throw new Error("路径必须是相对路径,且不能跳出工具目录"); return p; }
208
208
  function inside(root, target) { const r = relative(resolve(root), resolve(target)); return r === "" || (!r.startsWith(`..${sep}`) && r !== ".." && !r.includes(":")); }
209
209
  function isParam(value) { const x = value; return !!x && typeof x === "object" && ["string", "number", "boolean", "object", "array"].includes(x.type ?? "") && (x.required === undefined || typeof x.required === "boolean") && (x.enum === undefined || Array.isArray(x.enum)); }
210
210
  function isTool(value) { const x = value; if (!x || typeof x !== "object" || typeof x.id !== "string" || typeof x.slug !== "string" || typeof x.name !== "string" || typeof x.entrypoint !== "string" || (x.source !== "user" && x.source !== "builtin"))
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Translates a thrown error from the turn loop into a sentence a person reads.
3
+ *
4
+ * The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
5
+ * layer chose to surface as a child exit, an OOM that took the whole process down, a path that
6
+ * resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
7
+ * throw message carries the technical fact; what the reader needs is the action it implies - retry,
8
+ * change model, check the key, change workspace - and that is the level this function writes at.
9
+ *
10
+ * The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
11
+ * error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
12
+ * nothing to pattern-match against other than the message itself. The literals below are
13
+ * deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
14
+ * "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
15
+ * reader cares about the bucket, not the exact wording.
16
+ *
17
+ * THIS LIVES IN ITS OWN MODULE so the buckets can be tested without starting a run: the function is
18
+ * pure, and every branch below is a decision about what a person is told, which is worth a test that
19
+ * needs no server, socket or Pi process.
20
+ */
21
+ export declare function formatTurnFailure(error: unknown): string;
22
+ export declare function controllerAborted(error: unknown): boolean;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Translates a thrown error from the turn loop into a sentence a person reads.
3
+ *
4
+ * The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
5
+ * layer chose to surface as a child exit, an OOM that took the whole process down, a path that
6
+ * resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
7
+ * throw message carries the technical fact; what the reader needs is the action it implies - retry,
8
+ * change model, check the key, change workspace - and that is the level this function writes at.
9
+ *
10
+ * The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
11
+ * error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
12
+ * nothing to pattern-match against other than the message itself. The literals below are
13
+ * deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
14
+ * "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
15
+ * reader cares about the bucket, not the exact wording.
16
+ *
17
+ * THIS LIVES IN ITS OWN MODULE so the buckets can be tested without starting a run: the function is
18
+ * pure, and every branch below is a decision about what a person is told, which is worth a test that
19
+ * needs no server, socket or Pi process.
20
+ */
21
+ export function formatTurnFailure(error) {
22
+ if (controllerAborted(error))
23
+ return "本轮任务已停止。";
24
+ const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
25
+ const lower = raw.toLowerCase();
26
+ // PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
27
+ // answering them a moment ago and now the model will not let it. The action is "wait or change
28
+ // model"; the wording has to make that obvious from the banner alone, without a tooltip.
29
+ if (/\b429\b|quota|rate limit|usage limit|too many requests|5-hour|5h|resource_exhausted|resourc exhaust|hit your limit|usage cap|usage restrict/.test(lower)) {
30
+ return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
31
+ }
32
+ // RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
33
+ // unanswerable, but the deltas the user already saw are still on screen, so the message has to
34
+ // say "we noticed and stopped" rather than "your input was lost".
35
+ if (lower.includes("pi rpc channel closed") || lower.includes("agent rpc") || lower.includes("exited before settling")) {
36
+ return "Pi 进程异常退出,未完成本轮回答。已停止运行,可以直接重发任务继续。";
37
+ }
38
+ // NO PI - the launch path failed (binary missing, executable bit, Windows shim). The reader's
39
+ // best move is a `which pi` / reinstall, and the banner should point there.
40
+ if (lower.includes("could not find") && lower.includes("pi") || lower.includes("pi command") || lower.startsWith("pi ")) {
41
+ return `无法启动 Pi:${raw || "未知原因"}。请确认 Pi 已正确安装并可在命令行运行。`;
42
+ }
43
+ // WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
44
+ // anything is on screen, so the banner is also the only place the reason lives.
45
+ if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
46
+ return `工作区目录无效:${raw}`;
47
+ }
48
+ // PROVIDER UNREACHABLE - the request never reached the model. This is the branch a custom
49
+ // provider with a wrong Base URL lands in, and the person editing that URL needs to be told that
50
+ // the address or the network is the problem, not that "Pi failed".
51
+ if (/failed to fetch|fetch failed|econnrefused|econnreset|enotfound|eai_again|socket hang up|connection refused|connection reset|network\s*error/.test(lower)) {
52
+ return "连不上模型供应商:网络不可达或接口地址不对。请检查供应商的 Base URL 与网络连接,然后重发。";
53
+ }
54
+ // PROVIDER AUTH - the key is wrong, missing, or lacks access to this model. Pi reports this as a
55
+ // 401/403 from the provider; the actionable place is the supplier's key, not the conversation.
56
+ if (/\b401\b|\b403\b|unauthorized|forbidden|invalid api key|incorrect api key|authentication failed|insufficient permission/.test(lower)) {
57
+ return "模型供应商拒绝了这次请求:鉴权失败。请在设置里检查这个供应商的 API key,然后重发。";
58
+ }
59
+ // PROVIDER 5XX - their side is down. Retrying is the action, and saying so stops the reader from
60
+ // hunting for a configuration mistake on their own machine.
61
+ const status = /\bhttp\s*(?:status\s*)?[:=]?\s*(\d{3})\b|\bstatus(?:\s*code)?\s*[:=]\s*(\d{3})\b/.exec(lower);
62
+ const code = Number(status?.[1] ?? status?.[2] ?? 0);
63
+ if (code >= 500 && code <= 599 || /internal server error|bad gateway|service unavailable|gateway timeout/.test(lower)) {
64
+ return `模型供应商服务端出错了${code ? `(HTTP ${code})` : ""},通常稍后重试即可。`;
65
+ }
66
+ // IMAGE MISMATCH - explicit by the caller, no need to translate.
67
+ if (lower.includes("不支持图片输入")) {
68
+ return raw;
69
+ }
70
+ // GENERIC - the literal message is more useful than a paraphrase, but it has to be the LAST
71
+ // branch and the message has to be non-empty (a thrown `new Error()` is empty and renders
72
+ // nothing, which is exactly the silence this whole change exists to break).
73
+ return raw || "Pi 未能完成本轮回答,已标记为失败。可以重发任务继续。";
74
+ }
75
+ export function controllerAborted(error) {
76
+ if (!error)
77
+ return false;
78
+ if (error instanceof Error && error.name === "AbortError")
79
+ return true;
80
+ const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
81
+ return /abort|cancel|aborted/i.test(msg);
82
+ }
@@ -407,6 +407,14 @@ export type PublicProvider = {
407
407
  hasKey: boolean;
408
408
  models: PublicProviderModel[];
409
409
  };
410
+ export type DiscoveredModel = {
411
+ id: string;
412
+ name: string;
413
+ reasoning: boolean;
414
+ imageInput: boolean;
415
+ contextWindow: number;
416
+ maxTokens: number;
417
+ };
410
418
  export type ModelCatalog = {
411
419
  source: "pi" | "unavailable";
412
420
  models: ModelOption[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.117",
3
+ "version": "0.1.118",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -98,6 +98,7 @@
98
98
  "scripts/bg-task-zombie.test.mjs",
99
99
  "scripts/scoped-sidebar-tabs.test.mjs",
100
100
  "scripts/send-retry.test.mjs",
101
+ "scripts/error-messages.test.mjs",
101
102
  "scripts/git-diff-view.test.mjs",
102
103
  "scripts/fork-server.test.mjs",
103
104
  "scripts/styles-no-dangling.test.mjs",
@@ -120,6 +121,7 @@
120
121
  "scripts/check-runner.test.mjs",
121
122
  "scripts/skills-discovery.test.mjs",
122
123
  "scripts/session-state-icon.test.mjs",
124
+ "scripts/provider-fetch-models.test.mjs",
123
125
  "scripts/shared-chrome.test.mjs"
124
126
  ],
125
127
  "check:browser": [
@@ -166,7 +168,8 @@
166
168
  "scripts/workspace-picker-browser.test.mjs",
167
169
  "scripts/optimistic-browser.test.mjs",
168
170
  "scripts/active-memory-browser.test.mjs",
169
- "scripts/knowledge-base-browser.test.mjs"
171
+ "scripts/knowledge-base-browser.test.mjs",
172
+ "scripts/ux-polish-browser.test.mjs"
170
173
  ],
171
174
  "dependencies": {
172
175
  "@codemirror/commands": "^6.11.1",