@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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/bin/pi-web-ui.mjs +1818 -0
  4. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -0
  5. package/deploy/nginx-subpath.conf +88 -0
  6. package/deploy/pi-web-ui-task.xml +71 -0
  7. package/deploy/pi-web-ui.service +31 -0
  8. package/dist/server/agent-service.js +2975 -0
  9. package/dist/server/attachments.js +672 -0
  10. package/dist/server/bg-servers.js +150 -0
  11. package/dist/server/client-state.js +234 -0
  12. package/dist/server/control-socket.js +177 -0
  13. package/dist/server/ensure-bash.js +80 -0
  14. package/dist/server/files-service.js +709 -0
  15. package/dist/server/goal-service.js +869 -0
  16. package/dist/server/index.js +879 -0
  17. package/dist/server/mcp-bridge.js +268 -0
  18. package/dist/server/model-admin.js +821 -0
  19. package/dist/server/patch-node-pty.js +80 -0
  20. package/dist/server/plugin-facilities.js +299 -0
  21. package/dist/server/plugin-updater.js +226 -0
  22. package/dist/server/plugins.js +893 -0
  23. package/dist/server/process-utils.js +112 -0
  24. package/dist/server/protocol-version.js +11 -0
  25. package/dist/server/protocol.js +6 -0
  26. package/dist/server/scm.js +298 -0
  27. package/dist/server/serialize.js +172 -0
  28. package/dist/server/settings-service.js +395 -0
  29. package/dist/server/slash-commands.js +263 -0
  30. package/dist/server/terminals.js +1372 -0
  31. package/dist/server/text-sniff.js +268 -0
  32. package/dist/server/uploads.js +107 -0
  33. package/dist/server/vision-bridge.js +131 -0
  34. package/dist/server/webui-context.js +222 -0
  35. package/extensions/webui.ts +192 -0
  36. package/package.json +99 -0
  37. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  38. package/web/dist/assets/TerminalPanel-D4AktsEn.js +2 -0
  39. package/web/dist/assets/abnfDiagram-VCTEODGH-Ct5StJwt.js +1 -0
  40. package/web/dist/assets/arc-pkuxEN8A.js +1 -0
  41. package/web/dist/assets/architectureDiagram-5GKGNRK7-C6P6yn4y.js +36 -0
  42. package/web/dist/assets/blockDiagram-I7D4REHJ-BmgfH1jt.js +129 -0
  43. package/web/dist/assets/c4Diagram-7LVT6UL2-CgmwTFsK.js +38 -0
  44. package/web/dist/assets/channel-0Y__6sq2.js +1 -0
  45. package/web/dist/assets/chunk-2Q5K7J3B-DX_NqAiH.js +1 -0
  46. package/web/dist/assets/chunk-5VM5RSS4-gLeuQuYv.js +15 -0
  47. package/web/dist/assets/chunk-F27PBJKO-D4WX4mhI.js +1 -0
  48. package/web/dist/assets/chunk-IMKFNOWR-DBpDxGQD.js +231 -0
  49. package/web/dist/assets/chunk-JWPE2WC7-DIQ8iZN5.js +1 -0
  50. package/web/dist/assets/chunk-POPQ4Y6H-F_YTNoGz.js +1 -0
  51. package/web/dist/assets/chunk-SVP7TREG-CmDIvX-r.js +88 -0
  52. package/web/dist/assets/chunk-TICWLB2K-BQUzHQyS.js +206 -0
  53. package/web/dist/assets/chunk-XXDRQBXY-DqR9iffH.js +1 -0
  54. package/web/dist/assets/classDiagram-ZZMXUADV-BEfGIpzu.js +1 -0
  55. package/web/dist/assets/classDiagram-v2-VYDZK3BY-BEfGIpzu.js +1 -0
  56. package/web/dist/assets/cose-bilkent-JH36ORCC-Dl67W4Dx.js +1 -0
  57. package/web/dist/assets/cynefin-OW5HDTMX-fWdqaCpX.js +178 -0
  58. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-CSUAXbaP.js +62 -0
  59. package/web/dist/assets/cytoscape.esm-Bch-eiPH.js +331 -0
  60. package/web/dist/assets/dagre-GXQ25YYZ-CqUVwu1X.js +4 -0
  61. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  62. package/web/dist/assets/diagram-S7CK7UJ4-bRce-WYV.js +30 -0
  63. package/web/dist/assets/diagram-UQ7AKVKN-CiqPtPBE.js +41 -0
  64. package/web/dist/assets/diagram-VSXAHHWV-CbEA05LX.js +3 -0
  65. package/web/dist/assets/diagram-VX7I27RA-CRcY9MD-.js +24 -0
  66. package/web/dist/assets/diagram-Z3DM3KII-DmTwvZdS.js +24 -0
  67. package/web/dist/assets/ebnfDiagram-PWID7BFC-DbwmiGVj.js +1 -0
  68. package/web/dist/assets/erDiagram-RLTQ6QDP-Cxiaf87y.js +99 -0
  69. package/web/dist/assets/flowDiagram-HODETNUW-BQiecP1I.js +168 -0
  70. package/web/dist/assets/ganttDiagram-EL5Y4UJY-CSr8RRNw.js +292 -0
  71. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-C4Tdis3g.js +106 -0
  72. package/web/dist/assets/index-CniHcFxT.js +22 -0
  73. package/web/dist/assets/index-DYz2iP_B.css +10 -0
  74. package/web/dist/assets/infoDiagram-27XIBGKW-ij_5A0pY.js +2 -0
  75. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  76. package/web/dist/assets/ishikawaDiagram-5VMMS53U-C8-tm2LV.js +70 -0
  77. package/web/dist/assets/journeyDiagram-3NMN7TZE-ZJedkNel.js +139 -0
  78. package/web/dist/assets/kanban-definition-UXKFOSKX-BEuoM53n.js +89 -0
  79. package/web/dist/assets/katex-C5jXJg4s.js +257 -0
  80. package/web/dist/assets/layout-zq_9DigZ.js +1 -0
  81. package/web/dist/assets/linear-D-QXJ5Wc.js +1 -0
  82. package/web/dist/assets/markdown-CUPbftfV.js +51 -0
  83. package/web/dist/assets/mermaid.core-CCiG-bOm.js +312 -0
  84. package/web/dist/assets/mindmap-definition-YA3MSWOX-VaAisUIe.js +96 -0
  85. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  86. package/web/dist/assets/pegDiagram-XKGWAZYB-D5vmRtkn.js +1 -0
  87. package/web/dist/assets/pieDiagram-E7YTZNPT-loD6V8fn.js +39 -0
  88. package/web/dist/assets/quadrantDiagram-AXDQQJYC-B7E4mH1-.js +7 -0
  89. package/web/dist/assets/railroadDiagram-O6MQD6OU-DTALHvDa.js +1 -0
  90. package/web/dist/assets/react-ZkKJMDQQ.js +24 -0
  91. package/web/dist/assets/requirementDiagram-BXWQKSXE-ZjJKuEcR.js +84 -0
  92. package/web/dist/assets/sankeyDiagram-P5KCCOFB-DufTudDP.js +40 -0
  93. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-B-Zk4alI.js +162 -0
  94. package/web/dist/assets/sizeCapture-INFHLROL-B1pjlxXN.js +1 -0
  95. package/web/dist/assets/stateDiagram-D77RDMKH-BeWg6PIR.js +1 -0
  96. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DagWhIFJ.js +1 -0
  97. package/web/dist/assets/swimlanes-42K2YHIH-4J4jmMmO.js +1 -0
  98. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-Dsi9x540.js +8 -0
  99. package/web/dist/assets/timeline-definition-24CTP7MA-ByqLfxqe.js +120 -0
  100. package/web/dist/assets/vennDiagram-4TSXK5OY-B_EqVW4R.js +34 -0
  101. package/web/dist/assets/wardleyDiagram-VM6X3IG4-Bu7sA6F8.js +78 -0
  102. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  103. package/web/dist/assets/xychartDiagram-S5SC5T6Z-w1KPVGKk.js +7 -0
  104. package/web/dist/favicon.svg +8 -0
  105. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  106. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  107. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  108. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  109. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  110. package/web/dist/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  111. package/web/dist/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  112. package/web/dist/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  113. package/web/dist/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  114. package/web/dist/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  115. package/web/dist/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  116. package/web/dist/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  117. package/web/dist/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  118. package/web/dist/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  119. package/web/dist/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  120. package/web/dist/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  121. package/web/dist/icon.ico +0 -0
  122. package/web/dist/index.html +17 -0
  123. package/web/public/favicon.svg +8 -0
  124. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  125. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  126. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  127. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
  128. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
  129. package/web/public/fonts/ibm-plex-mono/ibm-plex-mono-latin-ext-600-normal.woff2 +0 -0
  130. package/web/public/fonts/inter/inter-latin-400-normal.woff2 +0 -0
  131. package/web/public/fonts/inter/inter-latin-500-normal.woff2 +0 -0
  132. package/web/public/fonts/inter/inter-latin-600-normal.woff2 +0 -0
  133. package/web/public/fonts/inter/inter-latin-700-normal.woff2 +0 -0
  134. package/web/public/fonts/inter/inter-latin-800-normal.woff2 +0 -0
  135. package/web/public/fonts/inter/inter-latin-ext-400-normal.woff2 +0 -0
  136. package/web/public/fonts/inter/inter-latin-ext-500-normal.woff2 +0 -0
  137. package/web/public/fonts/inter/inter-latin-ext-600-normal.woff2 +0 -0
  138. package/web/public/fonts/inter/inter-latin-ext-700-normal.woff2 +0 -0
  139. package/web/public/fonts/inter/inter-latin-ext-800-normal.woff2 +0 -0
  140. 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
+ }