@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.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +14 -13
- package/dist/client/assets/{CodeMirrorEditor-BH5J2GdQ.js → CodeMirrorEditor-D9UWxggU.js} +2 -2
- package/dist/client/assets/{dist-CxbavTFQ.js → dist-Adu9a_hi.js} +1 -1
- package/dist/client/assets/{dist-D-8cPfzK.js → dist-BIjlwIeL.js} +1 -1
- package/dist/client/assets/{dist-DPEoGzVX.js → dist-BSBqp9tS.js} +1 -1
- package/dist/client/assets/{dist-D5kvjJM0.js → dist-BUJwJ5eB.js} +1 -1
- package/dist/client/assets/{dist-CPSsQVsd.js → dist-BWxNjbza.js} +1 -1
- package/dist/client/assets/{dist-Cfl1oidr.js → dist-BewFlCBY.js} +1 -1
- package/dist/client/assets/{dist-CAggG58v.js → dist-Boep2KRH.js} +1 -1
- package/dist/client/assets/{dist-Brf99eWd.js → dist-C7waPnE-.js} +1 -1
- package/dist/client/assets/{dist-DL42CYQW.js → dist-CDI2Ko-l.js} +1 -1
- package/dist/client/assets/{dist-C_fMe2R1.js → dist-CMRuQ4se.js} +1 -1
- package/dist/client/assets/{dist-rVgYnzOf.js → dist-COGr0kGo.js} +1 -1
- package/dist/client/assets/{dist-DMyH52V_.js → dist-C_eAtLLb.js} +1 -1
- package/dist/client/assets/{dist-Yfb6CLpa.js → dist-ClX_xXLe.js} +1 -1
- package/dist/client/assets/{dist-L8KF4gPt.js → dist-D9571BVm.js} +1 -1
- package/dist/client/assets/{dist-85yGn29Y.js → dist-DncjdwQz.js} +1 -1
- package/dist/client/assets/{dist-C3jDn6Sh.js → dist-ce3zYUZG.js} +1 -1
- package/dist/client/assets/index-CF45rxGL.js +99 -0
- package/dist/client/assets/{index-zFkgUK3I.css → index-DGPYJzbU.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.js +4 -4
- package/dist/server/application/provider-service.d.ts +11 -1
- package/dist/server/application/provider-service.js +27 -6
- package/dist/server/application/run-service.js +2 -61
- package/dist/server/application/workspace-service.js +1 -1
- package/dist/server/http-server.js +46 -28
- package/dist/server/providers.d.ts +10 -1
- package/dist/server/providers.js +95 -0
- package/dist/server/store.js +18 -18
- package/dist/server/terminal.js +3 -1
- package/dist/server/tool-registry.js +16 -16
- package/dist/server/turn-failure.d.ts +22 -0
- package/dist/server/turn-failure.js +82 -0
- package/dist/server/types.d.ts +8 -0
- package/package.json +5 -2
- package/dist/client/assets/index-7IbxTlW9.js +0 -99
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
-
: (
|
|
69
|
-
|
|
70
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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: "
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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:
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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:
|
|
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:
|
|
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: "
|
|
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: "
|
|
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:
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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:
|
|
1377
|
+
json(404, { error: "找不到这个会话——它可能已经被删除。" });
|
|
1360
1378
|
return;
|
|
1361
1379
|
}
|
|
1362
1380
|
if (!body.mimeType || typeof body.mimeType !== "string") {
|
|
1363
|
-
json(400, { error: "
|
|
1381
|
+
json(400, { error: "缺少图片类型。" });
|
|
1364
1382
|
return;
|
|
1365
1383
|
}
|
|
1366
1384
|
if (!body.data || typeof body.data !== "string") {
|
|
1367
|
-
json(400, { error: "
|
|
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: "
|
|
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: "
|
|
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[]>;
|
package/dist/server/providers.js
CHANGED
|
@@ -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
|
+
}
|