@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
@@ -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-7IbxTlW9.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-zFkgUK3I.css">
11
+ <script type="module" crossorigin src="/assets/index-CF45rxGL.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DGPYJzbU.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -36,7 +36,7 @@ export class ConversationService {
36
36
  async createConversation(workspaceId) {
37
37
  const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
38
38
  if (!workspace)
39
- throw new Error("Workspace not found");
39
+ throw new Error("找不到这个工作区。");
40
40
  // Workspace defaults are copied at birth, but Pi's model list can change after the
41
41
  // setting was saved (for example when a model is removed from Pi's config). Do not
42
42
  // stamp a dead selection onto every new conversation: the scoped catalog probe would
@@ -128,7 +128,7 @@ export class ConversationService {
128
128
  async getConversationCatalog(conversationId) {
129
129
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
130
130
  if (!conversation)
131
- throw new Error("Conversation not found");
131
+ throw new Error("找不到这个会话。");
132
132
  const state = this.store.snapshot();
133
133
  const catalog = await this.runtime.catalog();
134
134
  assertModelAllowed(state.modelFilterRules, conversationWorkspace(state, conversationId), conversation.model ?? catalog.defaultModel);
@@ -200,7 +200,7 @@ export class ConversationService {
200
200
  async setAgent(conversationId, agentId) {
201
201
  const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
202
202
  if (!conversation)
203
- throw new Error("Conversation not found");
203
+ throw new Error("找不到这个会话。");
204
204
  if (agentId === null) {
205
205
  await this.store.setConversationAgent(conversationId, undefined);
206
206
  this.broadcaster.broadcastState();
@@ -247,7 +247,7 @@ export class ConversationService {
247
247
  ? state.workspaces.find(item => item.id === workspaceId)
248
248
  : conversationWorkspace(state, conversation.id);
249
249
  if (!workspace)
250
- throw new Error("Workspace not found");
250
+ throw new Error("找不到这个工作区。");
251
251
  assertModelAllowed(state.modelFilterRules, workspace, selection ?? (await this.runtime.catalog()).defaultModel);
252
252
  return { agentId: agent.id, model: selection, thinkingLevel: agent.thinkingLevel ?? conversation.thinkingLevel };
253
253
  }
@@ -1,7 +1,7 @@
1
1
  import { ProviderRegistry } from "../providers.js";
2
2
  import type { AgentRuntime } from "../runtime.js";
3
3
  import type { Store } from "../store.js";
4
- import type { PublicProvider } from "../types.js";
4
+ import type { DiscoveredModel, PublicProvider } from "../types.js";
5
5
  import type { EventBroadcaster } from "../server-context.js";
6
6
  import type { CatalogService } from "./catalog-service.js";
7
7
  /**
@@ -74,4 +74,14 @@ export declare class ProviderService {
74
74
  * those rules forbid.
75
75
  */
76
76
  private afterChange;
77
+ /**
78
+ * Discovers available models from the remote provider endpoint.
79
+ * If apiKey is omitted but providerId is given, uses the stored key.
80
+ */
81
+ fetchModels(input: {
82
+ baseUrl: string;
83
+ apiKey?: string;
84
+ api?: string;
85
+ providerId?: string;
86
+ }): Promise<DiscoveredModel[]>;
77
87
  }
@@ -1,4 +1,4 @@
1
- import { KNOWN_APIS, publicProviders, validateProvider } from "../providers.js";
1
+ import { KNOWN_APIS, publicProviders, validateProvider, fetchRemoteModels } from "../providers.js";
2
2
  /**
3
3
  * THE PROVIDERS A PERSON ADDED, as a service.
4
4
  *
@@ -61,13 +61,20 @@ export class ProviderService {
61
61
  : (raw.clearKey === true ? undefined : existing?.apiKey);
62
62
  // A MODEL WITH THE SAME ID IS REPLACED WHERE IT STANDS, not moved to the end: the list a person
63
63
  // sees in the panel is the list they added, and re-ordering it because they edited its first
64
- // entry would be a change nobody asked for. A model with a NEW id is appended.
65
- const incoming = requested.models[0];
64
+ // entry would be a change nobody asked for. Models with NEW ids are appended.
66
65
  const models = !existing
67
66
  ? requested.models
68
- : (existing.models.some((model) => model.id === incoming.id)
69
- ? existing.models.map((model) => (model.id === incoming.id ? incoming : model))
70
- : [...existing.models, incoming]);
67
+ : (() => {
68
+ let list = [...existing.models];
69
+ for (const inc of requested.models) {
70
+ const idx = list.findIndex((m) => m.id === inc.id);
71
+ if (idx >= 0)
72
+ list[idx] = inc;
73
+ else
74
+ list.push(inc);
75
+ }
76
+ return list;
77
+ })();
71
78
  const merged = {
72
79
  id: requested.id,
73
80
  ...(requested.name ? { name: requested.name } : {}),
@@ -162,4 +169,18 @@ export class ProviderService {
162
169
  await this.catalogService.broadcastAllCatalog?.();
163
170
  this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
164
171
  }
172
+ /**
173
+ * Discovers available models from the remote provider endpoint.
174
+ * If apiKey is omitted but providerId is given, uses the stored key.
175
+ */
176
+ async fetchModels(input) {
177
+ let key = input.apiKey?.trim();
178
+ if (!key && input.providerId) {
179
+ const all = await this.registry.load();
180
+ const match = all.find((p) => p.id === input.providerId);
181
+ if (match?.apiKey)
182
+ key = match.apiKey;
183
+ }
184
+ return fetchRemoteModels({ baseUrl: input.baseUrl, apiKey: key, api: input.api });
185
+ }
165
186
  }
@@ -5,69 +5,10 @@ import { readAgents } from "../agents.js";
5
5
  import { verifyCapability } from "../capability.js";
6
6
  import { validateWorkflowPlan, workflowSummary } from "../workflow.js";
7
7
  import { ensureNameIndex } from "../file-name-index.js";
8
+ import { formatTurnFailure } from "../turn-failure.js";
8
9
  import { execFile } from "node:child_process";
9
10
  import { promisify } from "node:util";
10
11
  const execFileAsync = promisify(execFile);
11
- /**
12
- * Translates a thrown error from the turn loop into a sentence a person reads.
13
- *
14
- * The turn loop throws for very different reasons: a model-provider rate limit, a 5xx that Pi's RPC
15
- * layer chose to surface as a child exit, an OOM that took the whole process down, a path that
16
- * resolved to a directory the workspace contract forbids, an `imageInput: false` mismatch. The
17
- * throw message carries the technical fact; what the reader needs is the action it implies - retry,
18
- * change model, check the key, change workspace - and that is the level this function writes at.
19
- *
20
- * The reason the shape is match-on-substring rather than match-on-error-class is that the upstream
21
- * error is whatever Pi wrote to its own log: a string, not an exception class, and the host has
22
- * nothing to pattern-match against other than the message itself. The literals below are
23
- * deliberately permissive on the LLM side ("quota", "rate limit", "usage limit", "too many requests",
24
- * "5-hour", "5h") because the underlying model APIs name this limit in several ways, and the
25
- * reader cares about the bucket, not the exact wording.
26
- */
27
- function formatTurnFailure(error) {
28
- if (controllerAborted(error))
29
- return "本轮任务已停止。";
30
- const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
31
- const lower = raw.toLowerCase();
32
- // PROVIDER QUOTA / RATE LIMIT - the only reason the user is in this branch is because Pi was
33
- // answering them a moment ago and now the model will not let it. The action is "wait or change
34
- // model"; the wording has to make that obvious from the banner alone, without a tooltip.
35
- 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)) {
36
- return "模型额度已用完(5 小时限额或类似上限),Pi 已停止回答。可以等待重置后重试,或在会话里换一个模型再发。";
37
- }
38
- // RPC CHANNEL DEAD - Pi died without producing a `settled` event. Anything in the turn is now
39
- // unanswerable, but the deltas the user already saw are still on screen, so the message has to
40
- // say "we noticed and stopped" rather than "your input was lost".
41
- if (lower.includes("pi rpc channel closed") || lower.includes("agent rpc") || lower.includes("exited before settling")) {
42
- return "Pi 进程异常退出,未完成本轮回答。已停止运行,可以直接重发任务继续。";
43
- }
44
- // NO PI - the launch path failed (binary missing, executable bit, Windows shim). The reader's
45
- // best move is a `which pi` / reinstall, and the banner should point there.
46
- if (lower.includes("could not find") && lower.includes("pi") || lower.includes("pi command") || lower.startsWith("pi ")) {
47
- return `无法启动 Pi:${raw || "未知原因"}。请确认 Pi 已正确安装并可在命令行运行。`;
48
- }
49
- // WORKSPACE PATH - turn refused on directory the contract forbids. Refusal happens before
50
- // anything is on screen, so the banner is also the only place the reason lives.
51
- if (lower.includes("workspace directory") || lower.includes("does not exist or is not a directory")) {
52
- return `工作区目录无效:${raw}`;
53
- }
54
- // IMAGE MISMATCH - explicit by the caller, no need to translate.
55
- if (lower.includes("不支持图片输入")) {
56
- return raw;
57
- }
58
- // GENERIC - the literal message is more useful than a paraphrase, but it has to be the LAST
59
- // branch and the message has to be non-empty (a thrown `new Error()` is empty and renders
60
- // nothing, which is exactly the silence this whole change exists to break).
61
- return raw || "Pi 未能完成本轮回答,已标记为失败。可以重发任务继续。";
62
- }
63
- function controllerAborted(error) {
64
- if (!error)
65
- return false;
66
- if (error instanceof Error && error.name === "AbortError")
67
- return true;
68
- const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "";
69
- return /abort|cancel|aborted/i.test(msg);
70
- }
71
12
  /** Expands composer references into explicit context for the model. */
72
13
  async function expandAtReferences(store, workspace, content) {
73
14
  const refs = [...content.matchAll(/@(worktree|session):([^\s]+)/gi)];
@@ -753,7 +694,7 @@ export class RunService {
753
694
  async openTurn(conversationId, messageId) {
754
695
  const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
755
696
  if (!conversation)
756
- throw new Error("Conversation not found");
697
+ throw new Error("找不到这个会话。");
757
698
  const agent = await this.runtime.agentFor?.(conversation);
758
699
  const identity = agent ? { id: agent.id, name: agent.name } : { name: "Pi" };
759
700
  const opened = await this.store.openTurnOnMessage(conversationId, messageId, identity);
@@ -64,7 +64,7 @@ export class WorkspaceService {
64
64
  async updateSettings(workspaceId, settings) {
65
65
  const workspace = this.store.snapshot().workspaces.find((item) => item.id === workspaceId);
66
66
  if (!workspace)
67
- throw new Error("Workspace not found");
67
+ throw new Error("找不到这个工作区。");
68
68
  let selection;
69
69
  const requested = settings.model;
70
70
  if (requested) {
@@ -253,12 +253,12 @@ export function createHttpServer(options) {
253
253
  try {
254
254
  const id = url.searchParams.get("id");
255
255
  if (!id) {
256
- json(400, { error: "id required" });
256
+ json(400, { error: "缺少工具 id。" });
257
257
  return;
258
258
  }
259
259
  const exported = await toolRegistry.exportBundle(id);
260
260
  if (!exported) {
261
- json(404, { error: "tool not found" });
261
+ json(404, { error: "找不到这个工具。" });
262
262
  return;
263
263
  }
264
264
  json(200, exported);
@@ -281,7 +281,7 @@ export function createHttpServer(options) {
281
281
  if (request.method === "DELETE") {
282
282
  const id = url.searchParams.get("id");
283
283
  if (!id) {
284
- json(400, { error: "id required" });
284
+ json(400, { error: "缺少工具 id。" });
285
285
  return;
286
286
  }
287
287
  const removed = await toolRegistry.remove(id);
@@ -296,7 +296,7 @@ export function createHttpServer(options) {
296
296
  return;
297
297
  }
298
298
  if (request.method !== "POST") {
299
- json(405, { error: "method" });
299
+ json(405, { error: "这个接口只接受 POST 请求。" });
300
300
  return;
301
301
  }
302
302
  // BYTES, NOT CHARACTERS, AND FROM THE REGISTRY'S OWN CONSTANT. The old cap counted UTF-16 code
@@ -307,7 +307,7 @@ export function createHttpServer(options) {
307
307
  const piece = chunk;
308
308
  bytes += piece.length;
309
309
  if (bytes > MAX_TOOL_BODY_BYTES) {
310
- json(413, { error: "request too large" });
310
+ json(413, { error: "导入的内容太大,超过了大小上限。" });
311
311
  return;
312
312
  }
313
313
  raw += piece.toString("utf8");
@@ -315,7 +315,7 @@ export function createHttpServer(options) {
315
315
  const body = JSON.parse(raw);
316
316
  if (!isAuthorized(request, accessToken)) {
317
317
  if (!body.conversationId) {
318
- json(401, { error: "authentication or agent capability required" });
318
+ json(401, { error: "需要访问凭据,或正在运行的会话凭据。" });
319
319
  return;
320
320
  }
321
321
  const verdict = runService.verifyPrimaryCapability(body.conversationId, body.capability ?? request.headers["x-super-pi-capability"]);
@@ -325,7 +325,7 @@ export function createHttpServer(options) {
325
325
  }
326
326
  }
327
327
  if (!body.slug || !body.name || !Array.isArray(body.files)) {
328
- json(400, { error: "slug, name and files are required" });
328
+ json(400, { error: "导入需要 slug、名称和文件列表。" });
329
329
  return;
330
330
  }
331
331
  // AN IMPORTED BUNDLE KEEPS THE EXPORTER'S FINGERPRINT. It describes where the tool was written
@@ -649,6 +649,24 @@ export function createHttpServer(options) {
649
649
  }
650
650
  return;
651
651
  }
652
+ if (url.pathname === "/api/providers/fetch-models") {
653
+ if (request.method !== "POST") {
654
+ response.writeHead(405).end();
655
+ return;
656
+ }
657
+ try {
658
+ let raw = "";
659
+ for await (const chunk of request)
660
+ raw += chunk;
661
+ const body = raw ? JSON.parse(raw) : {};
662
+ const models = await context.providerService.fetchModels(body);
663
+ json(200, { ok: true, models });
664
+ }
665
+ catch (error) {
666
+ json(400, { ok: false, error: error instanceof Error ? error.message : String(error) });
667
+ }
668
+ return;
669
+ }
652
670
  if (url.pathname === "/api/knowledge/tool" || url.pathname === "/api/knowledge") {
653
671
  if (!context.knowledge) {
654
672
  response.writeHead(503).end();
@@ -849,7 +867,7 @@ export function createHttpServer(options) {
849
867
  return;
850
868
  }
851
869
  if (!execService) {
852
- response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "exec service not available", code: "unavailable" }));
870
+ response.writeHead(503, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "后台命令服务没有启用。", code: "unavailable" }));
853
871
  return;
854
872
  }
855
873
  let raw = "";
@@ -871,12 +889,12 @@ export function createHttpServer(options) {
871
889
  body = JSON.parse(raw);
872
890
  }
873
891
  catch {
874
- json(400, { error: "invalid JSON" });
892
+ json(400, { error: "请求体不是合法的 JSON。" });
875
893
  return;
876
894
  }
877
895
  const { cmd, cwd, conversationId, capability, toolCallId } = body;
878
896
  if (!cmd || !cwd || !conversationId) {
879
- json(400, { error: "cmd, cwd and conversationId are required" });
897
+ json(400, { error: "需要 cmd、cwd 和 conversationId。" });
880
898
  return;
881
899
  }
882
900
  // Verify both the credential and RunService's authoritative active-turn state.
@@ -1029,7 +1047,7 @@ export function createHttpServer(options) {
1029
1047
  const workspaceId = url.searchParams.get("workspaceId")?.trim();
1030
1048
  const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1031
1049
  if (!workspaceId || !workspace) {
1032
- json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
1050
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1033
1051
  return;
1034
1052
  }
1035
1053
  const path = url.searchParams.get("path")?.trim() ?? "";
@@ -1060,19 +1078,19 @@ export function createHttpServer(options) {
1060
1078
  const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
1061
1079
  try {
1062
1080
  if (request.method !== "GET") {
1063
- json(405, { error: "Content search requires GET", code: "method" });
1081
+ json(405, { error: "内容搜索需要 GET 请求。", code: "method" });
1064
1082
  return;
1065
1083
  }
1066
1084
  const workspaceId = url.searchParams.get("workspaceId")?.trim();
1067
1085
  const query = url.searchParams.get("q")?.trim() ?? "";
1068
1086
  const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1069
1087
  if (!workspaceId || !workspace) {
1070
- json(404, { error: "workspace not found", code: "no_workspace" });
1088
+ json(404, { error: "找不到这个工作区——它可能已经被删除。", code: "no_workspace" });
1071
1089
  return;
1072
1090
  }
1073
1091
  await resolveInsideRoot(workspace.path, "");
1074
1092
  if (query.length > 256 || /[\r\n]/.test(query)) {
1075
- json(400, { error: "Search query must be one line, at most 256 characters", code: "bad_query" });
1093
+ json(400, { error: "搜索词只能是一行,且不超过 256 个字符。", code: "bad_query" });
1076
1094
  return;
1077
1095
  }
1078
1096
  if (!query) {
@@ -1105,13 +1123,13 @@ export function createHttpServer(options) {
1105
1123
  const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
1106
1124
  try {
1107
1125
  if (request.method !== "GET") {
1108
- json(405, { error: "File index requires GET", code: "method" });
1126
+ json(405, { error: "文件索引需要 GET 请求。", code: "method" });
1109
1127
  return;
1110
1128
  }
1111
1129
  const workspaceId = url.searchParams.get("workspaceId")?.trim();
1112
1130
  const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1113
1131
  if (!workspaceId || !workspace) {
1114
- json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
1132
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1115
1133
  return;
1116
1134
  }
1117
1135
  const indexes = context.fileIndexes ?? new Map();
@@ -1146,13 +1164,13 @@ export function createHttpServer(options) {
1146
1164
  const workspaceId = url.searchParams.get("workspaceId")?.trim();
1147
1165
  const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
1148
1166
  if (!workspaceId || !workspace) {
1149
- json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
1167
+ json(404, { error: `找不到工作区「${workspaceId ?? "未指定"}」——它可能已经被删除。`, code: "no_workspace" });
1150
1168
  return;
1151
1169
  }
1152
1170
  const root = workspace.path;
1153
1171
  if (url.pathname === "/api/files") {
1154
1172
  if (request.method !== "GET") {
1155
- json(405, { error: "Directory listing requires GET", code: "method" });
1173
+ json(405, { error: "目录列表需要 GET 请求。", code: "method" });
1156
1174
  return;
1157
1175
  }
1158
1176
  const query = url.searchParams.get("q");
@@ -1217,7 +1235,7 @@ export function createHttpServer(options) {
1217
1235
  if (request.method === "PUT") {
1218
1236
  const remoteAddr = request.socket.remoteAddress ?? "";
1219
1237
  if (!isLocalOrPrivateAddress(remoteAddr)) {
1220
- json(403, { error: "File writes require a loopback or private network connection", code: "not_loopback" });
1238
+ json(403, { error: "保存文件需要从本机或局域网发起。", code: "not_loopback" });
1221
1239
  return;
1222
1240
  }
1223
1241
  let raw = "";
@@ -1250,7 +1268,7 @@ export function createHttpServer(options) {
1250
1268
  json(200, outcome);
1251
1269
  return;
1252
1270
  }
1253
- json(405, { error: `${request.method} is not supported on /api/file`, code: "method" });
1271
+ json(405, { error: `/api/file 不支持 ${request.method} 请求。`, code: "method" });
1254
1272
  }
1255
1273
  catch (error) {
1256
1274
  const described = describeFilesError(error);
@@ -1329,7 +1347,7 @@ export function createHttpServer(options) {
1329
1347
  }
1330
1348
  if (url.pathname === "/api/upload") {
1331
1349
  if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1332
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload requires a loopback or private network POST" }));
1350
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传需要从本机或局域网发起。" }));
1333
1351
  return;
1334
1352
  }
1335
1353
  const json = (status, body) => {
@@ -1341,7 +1359,7 @@ export function createHttpServer(options) {
1341
1359
  for await (const chunk of request) {
1342
1360
  totalBytes += chunk.length;
1343
1361
  if (totalBytes > MAX_UPLOAD_BODY_BYTES) {
1344
- json(413, { error: "Upload payload exceeds size limit" });
1362
+ json(413, { error: "图片超过了大小上限。" });
1345
1363
  request.destroy();
1346
1364
  return;
1347
1365
  }
@@ -1351,20 +1369,20 @@ export function createHttpServer(options) {
1351
1369
  const body = JSON.parse(raw);
1352
1370
  const conversationId = body.conversationId?.trim();
1353
1371
  if (!conversationId) {
1354
- json(400, { error: "conversationId is required" });
1372
+ json(400, { error: "缺少会话 ID。" });
1355
1373
  return;
1356
1374
  }
1357
1375
  const conversation = store.snapshot().conversations.find((c) => c.id === conversationId);
1358
1376
  if (!conversation) {
1359
- json(404, { error: `Conversation not found: ${conversationId}` });
1377
+ json(404, { error: "找不到这个会话——它可能已经被删除。" });
1360
1378
  return;
1361
1379
  }
1362
1380
  if (!body.mimeType || typeof body.mimeType !== "string") {
1363
- json(400, { error: "mimeType is required" });
1381
+ json(400, { error: "缺少图片类型。" });
1364
1382
  return;
1365
1383
  }
1366
1384
  if (!body.data || typeof body.data !== "string") {
1367
- json(400, { error: "data (base64) is required" });
1385
+ json(400, { error: "缺少图片数据。" });
1368
1386
  return;
1369
1387
  }
1370
1388
  const catalog = await catalogService.getCatalog(conversation.model);
@@ -1384,12 +1402,12 @@ export function createHttpServer(options) {
1384
1402
  }
1385
1403
  if (url.pathname.startsWith("/api/upload/")) {
1386
1404
  if (request.method !== "GET" || !isLocalOrPrivateAddress(remote)) {
1387
- response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Upload access requires a loopback or private network GET" }));
1405
+ response.writeHead(403, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "读取上传的图片需要从本机或局域网发起。" }));
1388
1406
  return;
1389
1407
  }
1390
1408
  const pathParts = url.pathname.slice("/api/upload/".length).split("/").filter(Boolean);
1391
1409
  if (!pathParts.length || pathParts.length > 2) {
1392
- response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "Invalid upload path" }));
1410
+ response.writeHead(400, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "上传路径不合法。" }));
1393
1411
  return;
1394
1412
  }
1395
1413
  const convId = pathParts.length === 2 ? pathParts[0] : undefined;
@@ -1,4 +1,4 @@
1
- import type { PublicProvider } from "./types.js";
1
+ import type { DiscoveredModel, PublicProvider } from "./types.js";
2
2
  /**
3
3
  * THE PROVIDERS A PERSON ADDED, and the two files that make Pi know about them.
4
4
  *
@@ -158,3 +158,12 @@ export declare function publicProviders(providers: ProviderDefinition[]): Public
158
158
  * server cannot ask a follow-up question, so a refusal has to name the field and the reason.
159
159
  */
160
160
  export declare function validateProvider(input: unknown): ProviderDefinition;
161
+ /**
162
+ * Probes the provider's remote endpoint (OpenAI /models or Ollama /api/tags)
163
+ * and returns the list of available models with inferred metadata.
164
+ */
165
+ export declare function fetchRemoteModels(input: {
166
+ baseUrl: string;
167
+ apiKey?: string;
168
+ api?: string;
169
+ }): Promise<DiscoveredModel[]>;
@@ -394,3 +394,98 @@ export function validateProvider(input) {
394
394
  models: checked,
395
395
  };
396
396
  }
397
+ /**
398
+ * Probes the provider's remote endpoint (OpenAI /models or Ollama /api/tags)
399
+ * and returns the list of available models with inferred metadata.
400
+ */
401
+ export async function fetchRemoteModels(input) {
402
+ const rawBase = (input.baseUrl || "").trim();
403
+ if (!rawBase)
404
+ throw new Error("API 地址不能为空");
405
+ let parsed;
406
+ try {
407
+ parsed = new URL(rawBase);
408
+ }
409
+ catch {
410
+ throw new Error(`API 地址格式不正确:${rawBase}`);
411
+ }
412
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
413
+ throw new Error("API 地址必须以 http:// 或 https:// 开头");
414
+ }
415
+ const cleanBase = rawBase.replace(/\/+$/, "");
416
+ const headers = {
417
+ accept: "application/json",
418
+ };
419
+ if (input.apiKey && input.apiKey.trim()) {
420
+ headers.authorization = `Bearer ${input.apiKey.trim()}`;
421
+ }
422
+ const isOllama = input.api === "ollama" || cleanBase.includes(":11434");
423
+ const candidates = [];
424
+ if (isOllama) {
425
+ candidates.push(`${cleanBase}/api/tags`, `${cleanBase}/v1/models`, `${cleanBase}/models`);
426
+ }
427
+ else if (cleanBase.endsWith("/v1")) {
428
+ candidates.push(`${cleanBase}/models`);
429
+ }
430
+ else {
431
+ candidates.push(`${cleanBase}/v1/models`, `${cleanBase}/models`);
432
+ }
433
+ let lastError = "";
434
+ let payload = null;
435
+ for (const candidate of candidates) {
436
+ try {
437
+ const response = await fetch(candidate, {
438
+ headers,
439
+ signal: AbortSignal.timeout(10000),
440
+ });
441
+ if (!response.ok) {
442
+ lastError = `HTTP ${response.status} ${response.statusText}`;
443
+ continue;
444
+ }
445
+ payload = await response.json();
446
+ if (payload)
447
+ break;
448
+ }
449
+ catch (error) {
450
+ lastError = error instanceof Error ? error.message : String(error);
451
+ }
452
+ }
453
+ if (!payload) {
454
+ throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"})`);
455
+ }
456
+ const rawList = Array.isArray(payload)
457
+ ? payload
458
+ : Array.isArray(payload.data)
459
+ ? payload.data
460
+ : Array.isArray(payload.models)
461
+ ? payload.models
462
+ : [];
463
+ if (rawList.length === 0) {
464
+ throw new Error("远程接口返回的模型列表为空");
465
+ }
466
+ const seen = new Set();
467
+ const discovered = [];
468
+ for (const item of rawList) {
469
+ if (!item || typeof item !== "object")
470
+ continue;
471
+ const rec = item;
472
+ const id = typeof rec.id === "string" ? rec.id.trim() : typeof rec.name === "string" ? rec.name.trim() : typeof rec.model === "string" ? rec.model.trim() : "";
473
+ if (!id || seen.has(id))
474
+ continue;
475
+ seen.add(id);
476
+ const lower = id.toLowerCase();
477
+ const reasoning = /r1|reason|o1|o3|thinking|qwq|deepseek-r1/i.test(lower);
478
+ const imageInput = /vision|vl|4o|gemini|claude-3|flash|pixtral/i.test(lower);
479
+ const contextWindow = typeof rec.context_window === "number" ? rec.context_window : DEFAULT_CONTEXT_WINDOW;
480
+ const maxTokens = reasoning ? 32768 : DEFAULT_MAX_TOKENS;
481
+ discovered.push({
482
+ id,
483
+ name: id,
484
+ reasoning,
485
+ imageInput,
486
+ contextWindow,
487
+ maxTokens,
488
+ });
489
+ }
490
+ return discovered.sort((a, b) => a.id.localeCompare(b.id));
491
+ }