@youweichen/pi-web-ui 0.45.0
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/LICENSE +21 -0
- package/README.md +45 -0
- package/bin/pi-web-ui.mjs +1818 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
- package/deploy/nginx-subpath.conf +88 -0
- package/deploy/pi-web-ui-task.xml +71 -0
- package/deploy/pi-web-ui.service +31 -0
- package/dist/server/agent-service.js +2975 -0
- package/dist/server/attachments.js +672 -0
- package/dist/server/bg-servers.js +150 -0
- package/dist/server/client-state.js +234 -0
- package/dist/server/control-socket.js +177 -0
- package/dist/server/ensure-bash.js +80 -0
- package/dist/server/files-service.js +709 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +879 -0
- package/dist/server/mcp-bridge.js +268 -0
- package/dist/server/model-admin.js +821 -0
- package/dist/server/patch-node-pty.js +80 -0
- package/dist/server/plugin-facilities.js +299 -0
- package/dist/server/plugin-updater.js +226 -0
- package/dist/server/plugins.js +893 -0
- package/dist/server/process-utils.js +112 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/protocol.js +6 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/serialize.js +172 -0
- package/dist/server/settings-service.js +395 -0
- package/dist/server/slash-commands.js +263 -0
- package/dist/server/terminals.js +1372 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/vision-bridge.js +131 -0
- package/dist/server/webui-context.js +222 -0
- package/extensions/webui.ts +192 -0
- package/package.json +99 -0
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
- package/web/dist/assets/arc-pkuxEN8A.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
- package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
- package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
- package/web/dist/assets/channel-0Y__6sq2.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
- package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
- package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
- package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
- package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
- package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
- package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
- package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
- package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
- package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
- package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
- package/web/dist/assets/index-CniHcFxT.js +22 -0
- package/web/dist/assets/index-DYz2iP_B.css +10 -0
- package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
- package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
- package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
- package/web/dist/assets/katex-C5jXJg4s.js +257 -0
- package/web/dist/assets/layout-zq_9DigZ.js +1 -0
- package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
- package/web/dist/assets/markdown-CUPbftfV.js +51 -0
- package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
- package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
- package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
- package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
- package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
- package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
- package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
- package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
- package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
- package/web/dist/favicon.svg +8 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/dist/icon.ico +0 -0
- package/web/dist/index.html +17 -0
- package/web/public/favicon.svg +8 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
- package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
- package/web/public/icon.ico +0 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-admin — 模型/服务商配置管理,从 agent-service.ts 抽出。
|
|
3
|
+
*
|
|
4
|
+
* 职责:auth.json 的 provider api-key 存取(set/clear)、models.json 读写
|
|
5
|
+
* (listModelsConfig/saveModelConfig/deleteModelConfig)、自定义服务商「自动获取
|
|
6
|
+
* 模型列表」(fetch_models:服务端探测 OpenAI 兼容 /models 端点,绕开 CORS;
|
|
7
|
+
* anthropic/google 鉴权头各不同;裸 /models 404 回退 /v1/models)与已保存供应商
|
|
8
|
+
* 的一键刷新(refresh_provider_models,凭据不出浏览器)。改动后热更新 runtime
|
|
9
|
+
* (refresh/setRuntimeApiKey)并推 models/models_config。
|
|
10
|
+
*
|
|
11
|
+
* 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
|
|
12
|
+
* UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
/** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
|
|
17
|
+
function stripJsonComments(src) {
|
|
18
|
+
let out = "";
|
|
19
|
+
let inString = false;
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < src.length) {
|
|
22
|
+
const c = src[i];
|
|
23
|
+
const next = src[i + 1];
|
|
24
|
+
if (inString) {
|
|
25
|
+
out += c;
|
|
26
|
+
if (c === "\\") {
|
|
27
|
+
out += next ?? "";
|
|
28
|
+
i += 2;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === '"')
|
|
32
|
+
inString = false;
|
|
33
|
+
i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === '"') {
|
|
37
|
+
inString = true;
|
|
38
|
+
out += c;
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === "/" && next === "/") {
|
|
43
|
+
while (i < src.length && src[i] !== "\n")
|
|
44
|
+
i++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === "/" && next === "*") {
|
|
48
|
+
i += 2;
|
|
49
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/"))
|
|
50
|
+
i++;
|
|
51
|
+
i += 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out += c;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
60
|
+
function numMeta(v) {
|
|
61
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
62
|
+
}
|
|
63
|
+
function boolMeta(v) {
|
|
64
|
+
return typeof v === "boolean" ? v : undefined;
|
|
65
|
+
}
|
|
66
|
+
function strArrMeta(v) {
|
|
67
|
+
return Array.isArray(v)
|
|
68
|
+
? v.filter((x) => typeof x === "string")
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Best-effort extraction of model metadata from an OpenAI-compatible
|
|
72
|
+
* /models `data[]` item. Most endpoints only return `{ id }` — the extra
|
|
73
|
+
* fields (context_window / max_model_len / modalities / supports_vision /
|
|
74
|
+
* reasoning / display_name) come from vLLM and other extended
|
|
75
|
+
* implementations, and are filled into the form when present. */
|
|
76
|
+
function parseOpenAiModel(m) {
|
|
77
|
+
const r = (m ?? {});
|
|
78
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
79
|
+
const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
|
|
80
|
+
(typeof r.display_name === "string" && r.display_name.trim()
|
|
81
|
+
? r.display_name
|
|
82
|
+
: undefined);
|
|
83
|
+
const modalities = strArrMeta(r.modalities) ??
|
|
84
|
+
strArrMeta(r.input_modalities);
|
|
85
|
+
const vision = modalities?.includes("image") === true ||
|
|
86
|
+
boolMeta(r.supports_vision) === true ||
|
|
87
|
+
boolMeta(r.vision) === true ||
|
|
88
|
+
strArrMeta(r.input)?.includes("image") === true;
|
|
89
|
+
const reasoning = boolMeta(r.reasoning) === true ||
|
|
90
|
+
boolMeta(r.supports_reasoning) === true ||
|
|
91
|
+
modalities?.includes("reasoning") === true;
|
|
92
|
+
const contextWindow = numMeta(r.context_window) ??
|
|
93
|
+
numMeta(r.context_length) ??
|
|
94
|
+
numMeta(r.max_model_len) ??
|
|
95
|
+
numMeta(r.max_context_length);
|
|
96
|
+
const maxTokens = numMeta(r.max_tokens) ??
|
|
97
|
+
numMeta(r.max_output_tokens) ??
|
|
98
|
+
numMeta(r.max_completion_tokens);
|
|
99
|
+
return {
|
|
100
|
+
id,
|
|
101
|
+
...(name ? { name } : {}),
|
|
102
|
+
...(reasoning ? { reasoning: true } : {}),
|
|
103
|
+
...(vision ? { input: ["text", "image"] } : {}),
|
|
104
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
105
|
+
...(maxTokens ? { maxTokens } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** google-generative-ai /models shape:
|
|
109
|
+
* { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
|
|
110
|
+
* outputTokenLimit, supportedGenerationMethods }] } */
|
|
111
|
+
function parseGoogleModel(m) {
|
|
112
|
+
const r = (m ?? {});
|
|
113
|
+
const rawName = typeof r.name === "string" ? r.name : "";
|
|
114
|
+
const id = rawName.replace(/^models\//, "");
|
|
115
|
+
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
116
|
+
return {
|
|
117
|
+
id,
|
|
118
|
+
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
119
|
+
...(numMeta(r.inputTokenLimit)
|
|
120
|
+
? { contextWindow: numMeta(r.inputTokenLimit) }
|
|
121
|
+
: {}),
|
|
122
|
+
...(numMeta(r.outputTokenLimit)
|
|
123
|
+
? { maxTokens: numMeta(r.outputTokenLimit) }
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export class ModelAdminService {
|
|
128
|
+
host;
|
|
129
|
+
constructor(host) {
|
|
130
|
+
this.host = host;
|
|
131
|
+
}
|
|
132
|
+
/** Persist an api-key credential for a provider (auth.json) and apply it now. */
|
|
133
|
+
async setProviderApiKey(provider, apiKey) {
|
|
134
|
+
const key = apiKey.trim();
|
|
135
|
+
if (!provider.trim()) {
|
|
136
|
+
this.host.emit({ type: "notice", level: "error", text: "请填写服务商 ID" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!key) {
|
|
140
|
+
this.host.emit({ type: "notice", level: "error", text: "请填写 API 密钥" });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// Persist to auth.json (auth.json shape: { <provider>: { type: "api_key", key } }).
|
|
145
|
+
const authPath = join(this.host.agentDir, "auth.json");
|
|
146
|
+
mkdirSync(this.host.agentDir, { recursive: true });
|
|
147
|
+
let data = {};
|
|
148
|
+
try {
|
|
149
|
+
data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// no file yet / unparsable — start fresh
|
|
153
|
+
}
|
|
154
|
+
data[provider.trim()] = { type: "api_key", key };
|
|
155
|
+
writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
|
|
156
|
+
// Apply immediately for this session (runtime credentials are cached), then
|
|
157
|
+
// refresh models. allowNetwork downloads the provider's official model
|
|
158
|
+
// catalog (openai/anthropic/… are dynamic providers with no built-in list).
|
|
159
|
+
const mr = this.host.modelRuntime();
|
|
160
|
+
await mr.setRuntimeApiKey(provider.trim(), key);
|
|
161
|
+
await mr.refresh({ allowNetwork: true });
|
|
162
|
+
this.host.invalidatePiConfig();
|
|
163
|
+
this.host.emit({
|
|
164
|
+
type: "notice",
|
|
165
|
+
level: "info",
|
|
166
|
+
text: `✅ 已保存 ${provider.trim()} 的 API 密钥并刷新模型列表`,
|
|
167
|
+
});
|
|
168
|
+
await this.host.pushModels();
|
|
169
|
+
await this.listProviders();
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
this.host.emit({
|
|
173
|
+
type: "notice",
|
|
174
|
+
level: "error",
|
|
175
|
+
text: `保存 API 密钥失败:${err.message}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
this.host.flushSnapshot();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Clear a built-in provider's stored API key (auth.json entry + runtime
|
|
182
|
+
* override) so it returns to the unconfigured state — its models disappear
|
|
183
|
+
* from the picker until a key is set again. Only meaningful for keys that
|
|
184
|
+
* were stored via set_provider_api_key (source "stored"); env-var sourced
|
|
185
|
+
* credentials can't be cleared from here.
|
|
186
|
+
*/
|
|
187
|
+
async clearProviderApiKey(provider) {
|
|
188
|
+
const pid = provider.trim();
|
|
189
|
+
if (!pid) {
|
|
190
|
+
this.host.emit({ type: "notice", level: "error", text: "请填写服务商 ID" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
// Remove from auth.json ({ <provider>: { type: "api_key", key } }).
|
|
195
|
+
const authPath = join(this.host.agentDir, "auth.json");
|
|
196
|
+
let data = {};
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// no file yet / unparsable — nothing stored to clear
|
|
202
|
+
}
|
|
203
|
+
if (!(pid in data)) {
|
|
204
|
+
this.host.emit({
|
|
205
|
+
type: "notice",
|
|
206
|
+
level: "info",
|
|
207
|
+
text: `${pid} 没有已保存的密钥`,
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
delete data[pid];
|
|
212
|
+
writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
|
|
213
|
+
// Drop the runtime override too, then re-read credentials so the
|
|
214
|
+
// provider goes back to unconfigured and its models leave the list.
|
|
215
|
+
const mr = this.host.modelRuntime();
|
|
216
|
+
await mr.removeRuntimeApiKey(pid);
|
|
217
|
+
await mr.refresh();
|
|
218
|
+
this.host.invalidatePiConfig();
|
|
219
|
+
this.host.emit({
|
|
220
|
+
type: "notice",
|
|
221
|
+
level: "info",
|
|
222
|
+
text: `🗑 已清除 ${pid} 的密钥,该服务商回到未配置状态`,
|
|
223
|
+
});
|
|
224
|
+
await this.host.pushModels();
|
|
225
|
+
await this.listProviders();
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
this.host.emit({
|
|
229
|
+
type: "notice",
|
|
230
|
+
level: "error",
|
|
231
|
+
text: `清除密钥失败:${err.message}`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
this.host.flushSnapshot();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Copy a BUILT-IN provider (baseUrl + current model catalog) into an
|
|
238
|
+
* editable custom-provider draft and return it via clone_provider_result.
|
|
239
|
+
* Nothing is persisted — the user renames the draft, pastes a DIFFERENT
|
|
240
|
+
* API key in the form, then saves via save_model_config. Credentials are
|
|
241
|
+
* never copied: the whole point is running a second key alongside the
|
|
242
|
+
* built-in one without touching it.
|
|
243
|
+
*/
|
|
244
|
+
async cloneProvider(providerId, reqId) {
|
|
245
|
+
const pid = providerId.trim();
|
|
246
|
+
const fail = (error) => this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
|
|
247
|
+
try {
|
|
248
|
+
if (!pid) {
|
|
249
|
+
fail("请填写服务商 ID");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const mr = this.host.modelRuntime();
|
|
253
|
+
const p = mr.getProvider(pid);
|
|
254
|
+
if (!p) {
|
|
255
|
+
fail(`供应商 ${pid} 不存在`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!p.baseUrl) {
|
|
259
|
+
fail(`${pid} 没有 baseUrl(OAuth/环境变量型供应商),无法复制为自定义服务商`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Map runtime models → models.json rows; dynamic providers ship an
|
|
263
|
+
// empty catalog until refreshed over the network.
|
|
264
|
+
const readModels = () => {
|
|
265
|
+
try {
|
|
266
|
+
return mr.getModels(pid).map((m) => ({
|
|
267
|
+
api: m.api,
|
|
268
|
+
entry: {
|
|
269
|
+
id: m.id,
|
|
270
|
+
...(m.name && m.name !== m.id ? { name: m.name } : {}),
|
|
271
|
+
...(m.reasoning ? { reasoning: true } : {}),
|
|
272
|
+
...(m.input?.includes("image")
|
|
273
|
+
? { input: ["text", "image"] }
|
|
274
|
+
: {}),
|
|
275
|
+
...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
|
|
276
|
+
...(m.maxTokens ? { maxTokens: m.maxTokens } : {}),
|
|
277
|
+
},
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
let models = readModels();
|
|
285
|
+
if (models.length === 0) {
|
|
286
|
+
await mr.refresh({ allowNetwork: true });
|
|
287
|
+
models = readModels();
|
|
288
|
+
}
|
|
289
|
+
if (models.length === 0) {
|
|
290
|
+
fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// models.json 的 api 是 provider 级:取占比最高的 api,只复制该 api 的模型。
|
|
294
|
+
const counts = new Map();
|
|
295
|
+
for (const m of models)
|
|
296
|
+
counts.set(m.api, (counts.get(m.api) ?? 0) + 1);
|
|
297
|
+
let api = models[0].api;
|
|
298
|
+
for (const [k, v] of counts)
|
|
299
|
+
if (v > (counts.get(api) ?? 0))
|
|
300
|
+
api = k;
|
|
301
|
+
const kept = models.filter((m) => m.api === api).map((m) => m.entry);
|
|
302
|
+
// Suggest a free id (<pid>-2, -3, …) — save_model_config would silently
|
|
303
|
+
// overwrite an existing custom entry with the same id.
|
|
304
|
+
const taken = new Set([
|
|
305
|
+
...Object.keys(this.readModelsConfig().providers),
|
|
306
|
+
...mr.getRegisteredProviderIds(),
|
|
307
|
+
]);
|
|
308
|
+
let newId = `${pid}-2`;
|
|
309
|
+
for (let n = 2; taken.has(newId); n++)
|
|
310
|
+
newId = `${pid}-${n}`;
|
|
311
|
+
const config = {
|
|
312
|
+
providerId: newId,
|
|
313
|
+
name: p.name,
|
|
314
|
+
api,
|
|
315
|
+
baseUrl: p.baseUrl,
|
|
316
|
+
models: kept,
|
|
317
|
+
};
|
|
318
|
+
this.host.emit({
|
|
319
|
+
type: "notice",
|
|
320
|
+
level: "info",
|
|
321
|
+
text: `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
|
|
322
|
+
});
|
|
323
|
+
this.host.emit({ type: "clone_provider_result", reqId, ok: true, config });
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
fail(`复制服务商失败:${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
this.host.flushSnapshot();
|
|
329
|
+
}
|
|
330
|
+
/** Enumerate pi's built-in providers with auth status (key-only config). */
|
|
331
|
+
async listProviders() {
|
|
332
|
+
const mr = this.host.modelRuntime();
|
|
333
|
+
let providers;
|
|
334
|
+
try {
|
|
335
|
+
providers = mr.getProviders().map((p) => {
|
|
336
|
+
try {
|
|
337
|
+
const st = mr.getProviderAuthStatus(p.id);
|
|
338
|
+
return {
|
|
339
|
+
id: p.id,
|
|
340
|
+
name: p.name,
|
|
341
|
+
configured: st?.configured ?? false,
|
|
342
|
+
source: st?.source,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// One odd provider must not blank the whole list.
|
|
347
|
+
return { id: p.id, name: p.name, configured: false };
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
this.host.emit({
|
|
353
|
+
type: "notice",
|
|
354
|
+
level: "error",
|
|
355
|
+
text: `获取服务商列表失败:${err.message}`,
|
|
356
|
+
});
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (providers.length === 0) {
|
|
360
|
+
this.host.emit({
|
|
361
|
+
type: "notice",
|
|
362
|
+
level: "warning",
|
|
363
|
+
text: "服务商列表为空——pi 运行时未注册任何提供商",
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
this.host.emit({ type: "providers_status", providers });
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Custom model config (agentDir/models.json)
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
modelsConfigPath() {
|
|
372
|
+
return join(this.host.agentDir, "models.json");
|
|
373
|
+
}
|
|
374
|
+
/** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
|
|
375
|
+
static stripJsonComments(src) {
|
|
376
|
+
let out = "";
|
|
377
|
+
let inString = false;
|
|
378
|
+
let i = 0;
|
|
379
|
+
while (i < src.length) {
|
|
380
|
+
const c = src[i];
|
|
381
|
+
const next = src[i + 1];
|
|
382
|
+
if (inString) {
|
|
383
|
+
out += c;
|
|
384
|
+
if (c === "\\") {
|
|
385
|
+
out += next ?? "";
|
|
386
|
+
i += 2;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (c === '"')
|
|
390
|
+
inString = false;
|
|
391
|
+
i++;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (c === '"') {
|
|
395
|
+
inString = true;
|
|
396
|
+
out += c;
|
|
397
|
+
i++;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (c === "/" && next === "/") {
|
|
401
|
+
while (i < src.length && src[i] !== "\n")
|
|
402
|
+
i++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (c === "/" && next === "*") {
|
|
406
|
+
i += 2;
|
|
407
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/"))
|
|
408
|
+
i++;
|
|
409
|
+
i += 2;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
out += c;
|
|
413
|
+
i++;
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
/** Read + parse models.json (tolerating // and /* *\/ comments like the SDK). */
|
|
418
|
+
readModelsConfig() {
|
|
419
|
+
const path = this.modelsConfigPath();
|
|
420
|
+
try {
|
|
421
|
+
const raw = readFileSync(path, "utf8");
|
|
422
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
423
|
+
return { providers: parsed?.providers ?? {} };
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return { providers: {} };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/** Send the current models.json custom providers to the client. */
|
|
430
|
+
async listModelsConfig() {
|
|
431
|
+
const { providers } = this.readModelsConfig();
|
|
432
|
+
const list = Object.entries(providers).map(([providerId, p]) => {
|
|
433
|
+
const models = Array.isArray(p.models)
|
|
434
|
+
? p.models.map((m) => ({
|
|
435
|
+
id: String(m.id ?? ""),
|
|
436
|
+
name: m.name,
|
|
437
|
+
reasoning: m.reasoning,
|
|
438
|
+
input: Array.isArray(m.input) ? m.input : undefined,
|
|
439
|
+
contextWindow: m.contextWindow,
|
|
440
|
+
maxTokens: m.maxTokens,
|
|
441
|
+
}))
|
|
442
|
+
: [];
|
|
443
|
+
return {
|
|
444
|
+
providerId,
|
|
445
|
+
name: p.name,
|
|
446
|
+
api: p.api,
|
|
447
|
+
baseUrl: p.baseUrl,
|
|
448
|
+
apiKey: p.apiKey,
|
|
449
|
+
authHeader: p.authHeader,
|
|
450
|
+
// headers are intentionally NOT sent to the browser — they may
|
|
451
|
+
// contain Authorization / API-key values; kept server-side only.
|
|
452
|
+
models,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
this.host.emit({ type: "models_config", providers: list });
|
|
456
|
+
}
|
|
457
|
+
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
458
|
+
static numMeta(v) {
|
|
459
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
460
|
+
}
|
|
461
|
+
static boolMeta(v) {
|
|
462
|
+
return typeof v === "boolean" ? v : undefined;
|
|
463
|
+
}
|
|
464
|
+
static strArrMeta(v) {
|
|
465
|
+
return Array.isArray(v)
|
|
466
|
+
? v.filter((x) => typeof x === "string")
|
|
467
|
+
: undefined;
|
|
468
|
+
}
|
|
469
|
+
/** Best-effort extraction of model metadata from an OpenAI-compatible
|
|
470
|
+
* /models `data[]` item. Most endpoints only return `{ id }` — the extra
|
|
471
|
+
* fields (context_window / max_model_len / modalities / supports_vision /
|
|
472
|
+
* reasoning / display_name) come from vLLM and other extended
|
|
473
|
+
* implementations, and are filled into the form when present. */
|
|
474
|
+
static parseOpenAiModel(m) {
|
|
475
|
+
const r = (m ?? {});
|
|
476
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
477
|
+
const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
|
|
478
|
+
(typeof r.display_name === "string" && r.display_name.trim()
|
|
479
|
+
? r.display_name
|
|
480
|
+
: undefined);
|
|
481
|
+
const modalities = strArrMeta(r.modalities) ??
|
|
482
|
+
strArrMeta(r.input_modalities);
|
|
483
|
+
const vision = modalities?.includes("image") === true ||
|
|
484
|
+
boolMeta(r.supports_vision) === true ||
|
|
485
|
+
boolMeta(r.vision) === true ||
|
|
486
|
+
strArrMeta(r.input)?.includes("image") === true;
|
|
487
|
+
const reasoning = boolMeta(r.reasoning) === true ||
|
|
488
|
+
boolMeta(r.supports_reasoning) === true ||
|
|
489
|
+
modalities?.includes("reasoning") === true;
|
|
490
|
+
const contextWindow = numMeta(r.context_window) ??
|
|
491
|
+
numMeta(r.context_length) ??
|
|
492
|
+
numMeta(r.max_model_len) ??
|
|
493
|
+
numMeta(r.max_context_length);
|
|
494
|
+
const maxTokens = numMeta(r.max_tokens) ??
|
|
495
|
+
numMeta(r.max_output_tokens) ??
|
|
496
|
+
numMeta(r.max_completion_tokens);
|
|
497
|
+
return {
|
|
498
|
+
id,
|
|
499
|
+
...(name ? { name } : {}),
|
|
500
|
+
...(reasoning ? { reasoning: true } : {}),
|
|
501
|
+
...(vision ? { input: ["text", "image"] } : {}),
|
|
502
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
503
|
+
...(maxTokens ? { maxTokens } : {}),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** google-generative-ai /models shape:
|
|
507
|
+
* { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
|
|
508
|
+
* outputTokenLimit, supportedGenerationMethods }] } */
|
|
509
|
+
static parseGoogleModel(m) {
|
|
510
|
+
const r = (m ?? {});
|
|
511
|
+
const rawName = typeof r.name === "string" ? r.name : "";
|
|
512
|
+
const id = rawName.replace(/^models\//, "");
|
|
513
|
+
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
514
|
+
return {
|
|
515
|
+
id,
|
|
516
|
+
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
517
|
+
...(numMeta(r.inputTokenLimit)
|
|
518
|
+
? { contextWindow: numMeta(r.inputTokenLimit) }
|
|
519
|
+
: {}),
|
|
520
|
+
...(numMeta(r.outputTokenLimit)
|
|
521
|
+
? { maxTokens: numMeta(r.outputTokenLimit) }
|
|
522
|
+
: {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/** Probe a custom provider's OpenAI-compatible /models endpoint (server-side
|
|
526
|
+
* because the baseUrl is often a LAN/loopback host the browser can't reach
|
|
527
|
+
* cross-origin) and return the advertised models. reqId is echoed back
|
|
528
|
+
* in fetch_models_result so the UI can match concurrent requests. */
|
|
529
|
+
async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
530
|
+
const emitError = (error) => this.host.emit({ type: "fetch_models_result", reqId, ok: false, error });
|
|
531
|
+
try {
|
|
532
|
+
const models = await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api);
|
|
533
|
+
this.host.emit({ type: "fetch_models_result", reqId, ok: true, models });
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
emitError(err.message);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Probe a custom provider's model-list endpoint (OpenAI-compatible /models
|
|
541
|
+
* with a /v1 retry; Google {models:[…]} shape supported). Throws Error with
|
|
542
|
+
* a user-facing message on any failure; returns deduped+sorted entries.
|
|
543
|
+
* Shared by the edit-form "auto fetch" and the saved-provider refresh.
|
|
544
|
+
*/
|
|
545
|
+
static async probeModelsEndpoint(baseUrl, apiKey, authHeader, api, extraHeaders) {
|
|
546
|
+
const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
|
|
547
|
+
if (!base)
|
|
548
|
+
throw new Error("请先填写 baseUrl");
|
|
549
|
+
let url;
|
|
550
|
+
try {
|
|
551
|
+
url = new URL(base);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
throw new Error(`baseUrl 无效:${base}`);
|
|
555
|
+
}
|
|
556
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
557
|
+
throw new Error("baseUrl 仅支持 http/https");
|
|
558
|
+
}
|
|
559
|
+
const headers = {
|
|
560
|
+
...(extraHeaders ?? {}),
|
|
561
|
+
};
|
|
562
|
+
// Per-api auth conventions (mirror pi's built-in provider configs):
|
|
563
|
+
// openai-*: Authorization: Bearer <key>
|
|
564
|
+
// anthropic: x-api-key + anthropic-version
|
|
565
|
+
// google: x-goog-api-key
|
|
566
|
+
// authHeader=false → no auth header at all (custom gateways).
|
|
567
|
+
if (apiKey?.trim() && authHeader !== false) {
|
|
568
|
+
const key = apiKey.trim();
|
|
569
|
+
if (api === "anthropic-messages") {
|
|
570
|
+
headers["x-api-key"] = key;
|
|
571
|
+
headers["anthropic-version"] = "2023-06-01";
|
|
572
|
+
}
|
|
573
|
+
else if (api === "google-generative-ai") {
|
|
574
|
+
headers["x-goog-api-key"] = key;
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
headers["Authorization"] = `Bearer ${key}`;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const tryFetch = async (u) => {
|
|
581
|
+
const ac = new AbortController();
|
|
582
|
+
const timer = setTimeout(() => ac.abort(), 15000);
|
|
583
|
+
try {
|
|
584
|
+
return await fetch(u, { headers, signal: ac.signal });
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
if (err.name === "AbortError") {
|
|
588
|
+
throw new Error("请求超时(15 秒)");
|
|
589
|
+
}
|
|
590
|
+
throw new Error(`请求失败:${err.message}`);
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
let res = await tryFetch(`${base}/models`);
|
|
597
|
+
// BaseUrls that omit the /v1 prefix (e.g. https://api.openai.com) 404 on
|
|
598
|
+
// the bare path — retry under /v1.
|
|
599
|
+
if (res && res.status === 404 && !/\/v\d+[a-z-]*$/.test(base)) {
|
|
600
|
+
res = await tryFetch(`${base}/v1/models`);
|
|
601
|
+
}
|
|
602
|
+
if (!res)
|
|
603
|
+
throw new Error("请求失败");
|
|
604
|
+
if (!res.ok) {
|
|
605
|
+
let detail = "";
|
|
606
|
+
try {
|
|
607
|
+
detail = (await res.text()).slice(0, 200);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// response body already consumed / not text — ignore
|
|
611
|
+
}
|
|
612
|
+
throw new Error(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
|
|
613
|
+
}
|
|
614
|
+
let models = [];
|
|
615
|
+
try {
|
|
616
|
+
const json = (await res.json());
|
|
617
|
+
const data = Array.isArray(json.data) ? json.data : null;
|
|
618
|
+
if (data) {
|
|
619
|
+
// OpenAI-compatible: { data: [{ id, context_window, modalities, … }] }
|
|
620
|
+
models = data
|
|
621
|
+
.map((m) => parseOpenAiModel(m))
|
|
622
|
+
.filter((m) => m.id);
|
|
623
|
+
}
|
|
624
|
+
else if (Array.isArray(json.models)) {
|
|
625
|
+
// Google: { models: [{ name: "models/…", displayName, … }] }
|
|
626
|
+
models = json.models
|
|
627
|
+
.map((m) => parseGoogleModel(m))
|
|
628
|
+
.filter((m) => m.id);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
throw new Error("响应不是有效的 JSON");
|
|
633
|
+
}
|
|
634
|
+
// Dedupe by id (keep the first, most complete entry) and sort by id.
|
|
635
|
+
const seen = new Set();
|
|
636
|
+
models = models
|
|
637
|
+
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
|
|
638
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
639
|
+
if (models.length === 0)
|
|
640
|
+
throw new Error("接口未返回任何模型");
|
|
641
|
+
return models;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Re-probe a SAVED custom provider's model list and merge it into its
|
|
645
|
+
* models.json entry — credentials never leave the server (unlike the
|
|
646
|
+
* edit-form fetch, which sends whatever the browser typed). Merge rules:
|
|
647
|
+
* existing ids keep all manually-entered fields and only gain metadata
|
|
648
|
+
* they were missing; brand-new ids are appended. Hot-reloads the runtime.
|
|
649
|
+
*/
|
|
650
|
+
async refreshProviderModels(providerId, reqId) {
|
|
651
|
+
const done = (ok, extra = {}) => this.host.emit({ type: "refresh_provider_result", reqId, ok, ...extra });
|
|
652
|
+
try {
|
|
653
|
+
const pid = providerId.trim();
|
|
654
|
+
const { providers } = this.readModelsConfig();
|
|
655
|
+
// models.json 原始形状是 Record<string, unknown>——按已保存条目的结构断言
|
|
656
|
+
const saved = providers[pid];
|
|
657
|
+
if (!saved?.baseUrl?.trim()) {
|
|
658
|
+
this.host.emit({
|
|
659
|
+
type: "notice",
|
|
660
|
+
level: "warning",
|
|
661
|
+
text: `服务商 ${pid} 不存在或未配置 baseUrl,无法刷新`,
|
|
662
|
+
});
|
|
663
|
+
return done(false, { error: "provider missing or no baseUrl" });
|
|
664
|
+
}
|
|
665
|
+
const fetched = await ModelAdminService.probeModelsEndpoint(saved.baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers);
|
|
666
|
+
// Merge: manual values win; fetched fills blanks and appends new ids.
|
|
667
|
+
const prev = new Map((saved.models ?? []).map((m) => [m.id, m]));
|
|
668
|
+
let added = 0;
|
|
669
|
+
for (const f of fetched) {
|
|
670
|
+
const cur = prev.get(f.id);
|
|
671
|
+
if (!cur) {
|
|
672
|
+
prev.set(f.id, f);
|
|
673
|
+
added += 1;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
prev.set(f.id, {
|
|
677
|
+
...f,
|
|
678
|
+
...cur, // 手填字段优先:cur 覆盖 f 的同名字段
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
const merged = [...prev.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
682
|
+
await this.saveModelConfig(pid, {
|
|
683
|
+
providerId: pid,
|
|
684
|
+
name: saved.name,
|
|
685
|
+
api: saved.api,
|
|
686
|
+
baseUrl: saved.baseUrl,
|
|
687
|
+
// apiKey/headers 不回传浏览器——saveModelConfig 会保留旧值
|
|
688
|
+
authHeader: saved.authHeader === true ? true : undefined,
|
|
689
|
+
models: merged,
|
|
690
|
+
});
|
|
691
|
+
this.host.emit({
|
|
692
|
+
type: "notice",
|
|
693
|
+
level: "info",
|
|
694
|
+
text: added > 0
|
|
695
|
+
? `🔄 已刷新 ${pid}:新增 ${added} 个模型,共 ${merged.length} 个`
|
|
696
|
+
: `🔄 已刷新 ${pid}:无新增模型(共 ${merged.length} 个)`,
|
|
697
|
+
});
|
|
698
|
+
return done(true, { added, total: merged.length });
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
this.host.emit({
|
|
702
|
+
type: "notice",
|
|
703
|
+
level: "error",
|
|
704
|
+
text: `刷新模型列表失败:${err.message}`,
|
|
705
|
+
});
|
|
706
|
+
return done(false, { error: err.message });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Upsert one provider into models.json and hot-reload the model runtime. */
|
|
710
|
+
async saveModelConfig(providerId, config) {
|
|
711
|
+
const pid = providerId.trim();
|
|
712
|
+
if (!pid || !/^[\w.-]+$/.test(pid)) {
|
|
713
|
+
this.host.emit({
|
|
714
|
+
type: "notice",
|
|
715
|
+
level: "error",
|
|
716
|
+
text: "服务商 ID 无效(仅字母/数字/._-)",
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const models = (config.models ?? [])
|
|
721
|
+
.filter((m) => m.id && m.id.trim())
|
|
722
|
+
.map((m) => ({
|
|
723
|
+
id: m.id.trim(),
|
|
724
|
+
...(m.name?.trim() ? { name: m.name.trim() } : {}),
|
|
725
|
+
...(m.reasoning ? { reasoning: true } : {}),
|
|
726
|
+
...(m.input?.length ? { input: m.input } : {}),
|
|
727
|
+
...(m.contextWindow ? { contextWindow: Number(m.contextWindow) } : {}),
|
|
728
|
+
...(m.maxTokens ? { maxTokens: Number(m.maxTokens) } : {}),
|
|
729
|
+
}));
|
|
730
|
+
if (models.length === 0) {
|
|
731
|
+
this.host.emit({ type: "notice", level: "error", text: "至少需要一个模型" });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const { providers } = this.readModelsConfig();
|
|
736
|
+
// headers never reach the browser, so the incoming config can't carry
|
|
737
|
+
// them — preserve the previously stored values when they are absent.
|
|
738
|
+
const prevHeaders = providers[pid]?.headers;
|
|
739
|
+
providers[pid] = {
|
|
740
|
+
...(config.name?.trim() ? { name: config.name.trim() } : {}),
|
|
741
|
+
...(config.api?.trim() ? { api: config.api.trim() } : {}),
|
|
742
|
+
...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
|
|
743
|
+
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
|
|
744
|
+
...(config.authHeader ? { authHeader: true } : {}),
|
|
745
|
+
...(prevHeaders && Object.keys(prevHeaders).length > 0
|
|
746
|
+
? { headers: prevHeaders }
|
|
747
|
+
: {}),
|
|
748
|
+
models,
|
|
749
|
+
};
|
|
750
|
+
mkdirSync(this.host.agentDir, { recursive: true });
|
|
751
|
+
writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
|
|
752
|
+
// Allow a custom models.json entry to reuse the provider credential
|
|
753
|
+
// already stored in auth.json. Seed the shared runtime too, because
|
|
754
|
+
// older pi-ai versions did not always fall back to stored credentials
|
|
755
|
+
// for a newly-created custom provider. Never copy the secret into
|
|
756
|
+
// models.json.
|
|
757
|
+
try {
|
|
758
|
+
const auth = JSON.parse(readFileSync(join(this.host.agentDir, "auth.json"), "utf8"));
|
|
759
|
+
const credential = auth[pid];
|
|
760
|
+
if (credential &&
|
|
761
|
+
typeof credential === "object" &&
|
|
762
|
+
"key" in credential &&
|
|
763
|
+
typeof credential.key === "string" &&
|
|
764
|
+
credential.key.trim()) {
|
|
765
|
+
await this.host.modelRuntime().setRuntimeApiKey(pid, credential.key);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// auth.json is optional; models.json can still use its own apiKey.
|
|
770
|
+
}
|
|
771
|
+
await this.host.modelRuntime().refresh();
|
|
772
|
+
await this.listModelsConfig();
|
|
773
|
+
await this.host.pushModels();
|
|
774
|
+
this.host.emit({
|
|
775
|
+
type: "notice",
|
|
776
|
+
level: "info",
|
|
777
|
+
text: `✅ 已保存服务商 ${pid}(${models.length} 个模型)并刷新模型列表`,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
this.host.emit({
|
|
782
|
+
type: "notice",
|
|
783
|
+
level: "error",
|
|
784
|
+
text: `保存模型配置失败:${err.message}`,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
this.host.flushSnapshot();
|
|
788
|
+
}
|
|
789
|
+
/** Remove a provider from models.json and hot-reload. */
|
|
790
|
+
async deleteModelConfig(providerId) {
|
|
791
|
+
try {
|
|
792
|
+
const { providers } = this.readModelsConfig();
|
|
793
|
+
if (!(providerId in providers)) {
|
|
794
|
+
this.host.emit({
|
|
795
|
+
type: "notice",
|
|
796
|
+
level: "info",
|
|
797
|
+
text: `服务商 ${providerId} 不存在`,
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
delete providers[providerId];
|
|
802
|
+
writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
|
|
803
|
+
await this.host.modelRuntime().refresh();
|
|
804
|
+
await this.listModelsConfig();
|
|
805
|
+
await this.host.pushModels();
|
|
806
|
+
this.host.emit({
|
|
807
|
+
type: "notice",
|
|
808
|
+
level: "info",
|
|
809
|
+
text: `🗑 已删除服务商 ${providerId}`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
catch (err) {
|
|
813
|
+
this.host.emit({
|
|
814
|
+
type: "notice",
|
|
815
|
+
level: "error",
|
|
816
|
+
text: `删除模型配置失败:${err.message}`,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
this.host.flushSnapshot();
|
|
820
|
+
}
|
|
821
|
+
}
|