pi-web-ui 0.60.0 → 0.61.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/dist/server/agent-service.js +142 -1
- package/dist/server/client-state.js +52 -0
- package/dist/server/dsh/dsh-agent-service.js +15 -1
- package/dist/server/index.js +12 -0
- package/dist/server/model-admin.js +360 -35
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-CSzqnWV2.js → TerminalPanel-CYq-pKeS.js} +1 -1
- package/web/dist/assets/index-BVuOleif.js +321 -0
- package/web/dist/assets/index-CxB1f4vG.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D9iD9G_p.css +0 -10
- package/web/dist/assets/index-TZDcJFdi.js +0 -321
|
@@ -129,10 +129,144 @@ export class ModelAdminService {
|
|
|
129
129
|
constructor(host) {
|
|
130
130
|
this.host = host;
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Built-in provider multiple key store (one provider, several API keys).
|
|
134
|
+
// Persisted as <agentDir>/provider-keys.json:
|
|
135
|
+
// { "<providerId>": { activeKeyName: string|null, keys: [{name,apiKey}] } }
|
|
136
|
+
// The frontend only ever sees NAMES (no value, no masked fragment). The key
|
|
137
|
+
// value travels to the server ONCE on add and is stored (like auth.json); the
|
|
138
|
+
// server resolves + switches the active key by NAME.
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
providerKeysPath() {
|
|
141
|
+
return join(this.host.agentDir, "provider-keys.json");
|
|
142
|
+
}
|
|
143
|
+
/** Read + parse provider-keys.json. */
|
|
144
|
+
readProviderKeys() {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(readFileSync(this.providerKeysPath(), "utf8"));
|
|
147
|
+
const out = {};
|
|
148
|
+
for (const [pid, entry] of Object.entries(parsed)) {
|
|
149
|
+
const keys = Array.isArray(entry?.keys)
|
|
150
|
+
? entry.keys.filter((k) => k?.name && k?.apiKey)
|
|
151
|
+
: [];
|
|
152
|
+
if (!pid || keys.length === 0)
|
|
153
|
+
continue;
|
|
154
|
+
const activeKeyName = entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
|
|
155
|
+
? entry.activeKeyName
|
|
156
|
+
: keys[0].name;
|
|
157
|
+
out[pid] = { activeKeyName, keys };
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
writeProviderKeys(data) {
|
|
166
|
+
mkdirSync(this.host.agentDir, { recursive: true });
|
|
167
|
+
writeFileSync(this.providerKeysPath(), JSON.stringify(data, null, 2) + "\n");
|
|
168
|
+
}
|
|
169
|
+
/** Default name "密钥 N" for a provider's Nth key. */
|
|
170
|
+
defaultKeyName(keys) {
|
|
171
|
+
return `密钥 ${keys.length + 1}`;
|
|
172
|
+
}
|
|
173
|
+
/** Resolve a user-supplied (or default) name into a UNIQUE one (append
|
|
174
|
+
* " (2)", " (3)", … on collision) so a name is a reliable switch key. */
|
|
175
|
+
uniqueKeyName(entry, wanted) {
|
|
176
|
+
const base = (wanted?.trim() || this.defaultKeyName(entry.keys)).trim() || this.defaultKeyName(entry.keys);
|
|
177
|
+
const taken = new Set(entry.keys.map((k) => k.name));
|
|
178
|
+
let name = base;
|
|
179
|
+
let n = 2;
|
|
180
|
+
while (taken.has(name))
|
|
181
|
+
name = `${base} (${n++})`;
|
|
182
|
+
return name;
|
|
183
|
+
}
|
|
184
|
+
/** Build the name-only ProviderKeyInfo list for a provider (no value/mask). */
|
|
185
|
+
providerKeysInfo(data) {
|
|
186
|
+
const keys = {};
|
|
187
|
+
for (const [pid, entry] of Object.entries(data)) {
|
|
188
|
+
keys[pid] = entry.keys.map((k) => ({ name: k.name, active: entry.activeKeyName === k.name }));
|
|
189
|
+
}
|
|
190
|
+
return { keys };
|
|
191
|
+
}
|
|
192
|
+
/** Get the currently active key name for a provider, or null. */
|
|
193
|
+
getActiveKeyName(provider) {
|
|
194
|
+
const data = this.readProviderKeys();
|
|
195
|
+
return data[provider]?.activeKeyName ?? null;
|
|
196
|
+
}
|
|
197
|
+
/** Seed a provider's key list from an EXISTING auth.json credential (legacy
|
|
198
|
+
* configs written before the multi-key store existed) so the store stays
|
|
199
|
+
* authoritative and the UI shows the current active key immediately even
|
|
200
|
+
* before the user adds a second key. Idempotent — does nothing if the
|
|
201
|
+
* provider already has a store entry. */
|
|
202
|
+
seedProviderKeysFromAuth(pid, data) {
|
|
203
|
+
if (data[pid])
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
const auth = JSON.parse(readFileSync(join(this.host.agentDir, "auth.json"), "utf8"));
|
|
207
|
+
const cred = auth[pid];
|
|
208
|
+
if (cred && typeof cred.key === "string" && cred.key.trim()) {
|
|
209
|
+
data[pid] = {
|
|
210
|
+
activeKeyName: "密钥 1",
|
|
211
|
+
keys: [{ name: "密钥 1", apiKey: cred.key.trim() }],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// no auth.json / unparsable — nothing to seed
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Push the masked provider-keys map to the client. Seeds the store from any
|
|
220
|
+
* auth.json credentials so legacy single-key setups show up immediately. */
|
|
221
|
+
listProviderKeys() {
|
|
222
|
+
const data = this.readProviderKeys();
|
|
223
|
+
for (const pid of this.builtinProviderIds())
|
|
224
|
+
this.seedProviderKeysFromAuth(pid, data);
|
|
225
|
+
this.writeProviderKeys(data);
|
|
226
|
+
this.host.emit({ type: "provider_keys", ...this.providerKeysInfo(data) });
|
|
227
|
+
this.host.flushSnapshot();
|
|
228
|
+
}
|
|
229
|
+
/** Candidate built-in provider ids whose keys we track: those with a store
|
|
230
|
+
* entry plus every provider actually registered in the runtime (seed reads
|
|
231
|
+
* auth.json per id, so only real providers with a credential get seeded —
|
|
232
|
+
* unrelated auth.json entries like "main" are ignored). */
|
|
233
|
+
builtinProviderIds() {
|
|
234
|
+
const data = this.readProviderKeys();
|
|
235
|
+
const ids = new Set(Object.keys(data));
|
|
236
|
+
try {
|
|
237
|
+
for (const p of this.host.modelRuntime().getProviders())
|
|
238
|
+
ids.add(p.id);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// runtime not ready
|
|
242
|
+
}
|
|
243
|
+
return [...ids];
|
|
244
|
+
}
|
|
245
|
+
/** Persist the ACTIVE key's apiKey into auth.json + runtime override + refresh. */
|
|
246
|
+
async applyActiveKey(pid, apiKey) {
|
|
247
|
+
const authPath = join(this.host.agentDir, "auth.json");
|
|
248
|
+
mkdirSync(this.host.agentDir, { recursive: true });
|
|
249
|
+
let data = {};
|
|
250
|
+
try {
|
|
251
|
+
data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// no file yet / unparsable — start fresh
|
|
255
|
+
}
|
|
256
|
+
data[pid] = { type: "api_key", key: apiKey };
|
|
257
|
+
writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
|
|
258
|
+
const mr = this.host.modelRuntime();
|
|
259
|
+
await mr.setRuntimeApiKey(pid, apiKey);
|
|
260
|
+
await mr.refresh({ allowNetwork: true, providers: [pid] });
|
|
261
|
+
this.host.invalidatePiConfig();
|
|
262
|
+
}
|
|
263
|
+
/** Persist an api-key credential for a provider (auth.json) and apply it now.
|
|
264
|
+
* Also records the key in provider-keys.json (as the active key), so it shows
|
|
265
|
+
* in the multi-key list too. */
|
|
133
266
|
async setProviderApiKey(provider, apiKey) {
|
|
267
|
+
const pid = provider.trim();
|
|
134
268
|
const key = apiKey.trim();
|
|
135
|
-
if (!
|
|
269
|
+
if (!pid) {
|
|
136
270
|
this.host.emit({ type: "notice", level: "error", text: "请填写服务商 ID" });
|
|
137
271
|
return;
|
|
138
272
|
}
|
|
@@ -141,32 +275,36 @@ export class ModelAdminService {
|
|
|
141
275
|
return;
|
|
142
276
|
}
|
|
143
277
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
278
|
+
const data = this.readProviderKeys();
|
|
279
|
+
// Preserve a legacy auth.json key as the first (active) entry so adding
|
|
280
|
+
// a new key stacks alongside it instead of clobbering it.
|
|
281
|
+
if (!data[pid])
|
|
282
|
+
this.seedProviderKeysFromAuth(pid, data);
|
|
283
|
+
let entry = data[pid];
|
|
284
|
+
if (!entry)
|
|
285
|
+
entry = data[pid] = { activeKeyName: null, keys: [] };
|
|
286
|
+
const existing = entry.keys.find((k) => k.apiKey === key);
|
|
287
|
+
let name;
|
|
288
|
+
if (existing) {
|
|
289
|
+
// Same key value already in the list → just make it active.
|
|
290
|
+
entry.activeKeyName = existing.name;
|
|
291
|
+
name = existing.name;
|
|
150
292
|
}
|
|
151
|
-
|
|
152
|
-
|
|
293
|
+
else {
|
|
294
|
+
name = this.uniqueKeyName(entry, undefined);
|
|
295
|
+
entry.keys.push({ name, apiKey: key });
|
|
296
|
+
entry.activeKeyName = name;
|
|
153
297
|
}
|
|
154
|
-
|
|
155
|
-
|
|
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();
|
|
298
|
+
this.writeProviderKeys(data);
|
|
299
|
+
await this.applyActiveKey(pid, key);
|
|
163
300
|
this.host.emit({
|
|
164
301
|
type: "notice",
|
|
165
302
|
level: "info",
|
|
166
|
-
text: `✅ 已保存 ${
|
|
303
|
+
text: `✅ 已保存 ${pid} 的密钥「${name}」并刷新模型列表`,
|
|
167
304
|
});
|
|
168
305
|
await this.host.pushModels();
|
|
169
306
|
await this.listProviders();
|
|
307
|
+
this.listProviderKeys();
|
|
170
308
|
}
|
|
171
309
|
catch (err) {
|
|
172
310
|
this.host.emit({
|
|
@@ -177,6 +315,181 @@ export class ModelAdminService {
|
|
|
177
315
|
}
|
|
178
316
|
this.host.flushSnapshot();
|
|
179
317
|
}
|
|
318
|
+
/** Add a SECONDARY API key to a built-in provider's key list. `name` is the
|
|
319
|
+
* only thing the frontend ever sees (auto-generated when blank, deduped on
|
|
320
|
+
* collision). The added key stays INACTIVE unless it is the provider's first
|
|
321
|
+
* key; the user switches to it by name or by clicking a model under it. */
|
|
322
|
+
async addProviderKey(provider, apiKey, name) {
|
|
323
|
+
const pid = provider.trim();
|
|
324
|
+
const key = apiKey.trim();
|
|
325
|
+
if (!pid) {
|
|
326
|
+
this.host.emit({ type: "notice", level: "error", text: "请填写服务商 ID" });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!key) {
|
|
330
|
+
this.host.emit({ type: "notice", level: "error", text: "请填写 API 密钥" });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
const data = this.readProviderKeys();
|
|
335
|
+
// Preserve a legacy auth.json key (active) so the new key stacks as a
|
|
336
|
+
// SECONDARY inactive key rather than replacing the current one.
|
|
337
|
+
if (!data[pid])
|
|
338
|
+
this.seedProviderKeysFromAuth(pid, data);
|
|
339
|
+
let entry = data[pid];
|
|
340
|
+
if (!entry)
|
|
341
|
+
entry = data[pid] = { activeKeyName: null, keys: [] };
|
|
342
|
+
const dup = entry.keys.find((k) => k.apiKey === key);
|
|
343
|
+
if (dup) {
|
|
344
|
+
this.host.emit({
|
|
345
|
+
type: "notice",
|
|
346
|
+
level: "info",
|
|
347
|
+
text: `${pid} 已存在该密钥`,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const keyName = this.uniqueKeyName(entry, name);
|
|
352
|
+
entry.keys.push({ name: keyName, apiKey: key });
|
|
353
|
+
// First key becomes active (provider had none usable yet).
|
|
354
|
+
if (!entry.activeKeyName)
|
|
355
|
+
entry.activeKeyName = keyName;
|
|
356
|
+
this.writeProviderKeys(data);
|
|
357
|
+
const isActive = entry.activeKeyName === keyName;
|
|
358
|
+
if (isActive) {
|
|
359
|
+
await this.applyActiveKey(pid, key);
|
|
360
|
+
this.host.emit({
|
|
361
|
+
type: "notice",
|
|
362
|
+
level: "info",
|
|
363
|
+
text: `🔑 已添加 ${pid} 的密钥「${keyName}」并设为当前`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
this.host.emit({
|
|
368
|
+
type: "notice",
|
|
369
|
+
level: "info",
|
|
370
|
+
text: `🔑 已添加 ${pid} 的密钥「${keyName}」,点击模型时可切换使用`,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
await this.host.pushModels();
|
|
374
|
+
await this.listProviders();
|
|
375
|
+
this.listProviderKeys();
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
this.host.emit({
|
|
379
|
+
type: "notice",
|
|
380
|
+
level: "error",
|
|
381
|
+
text: `添加密钥失败:${err.message}`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
this.host.flushSnapshot();
|
|
385
|
+
}
|
|
386
|
+
/** Make a stored API key the ACTIVE one for a built-in provider by NAME (the
|
|
387
|
+
* server resolves the stored value from the name). */
|
|
388
|
+
async activateProviderKey(provider, keyName) {
|
|
389
|
+
const pid = provider.trim();
|
|
390
|
+
const targetName = keyName.trim();
|
|
391
|
+
try {
|
|
392
|
+
const data = this.readProviderKeys();
|
|
393
|
+
const entry = data[pid];
|
|
394
|
+
const target = entry?.keys.find((k) => k.name === targetName);
|
|
395
|
+
if (!target) {
|
|
396
|
+
this.host.emit({ type: "notice", level: "error", text: `${pid} 的密钥「${targetName}」不存在` });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (entry.activeKeyName === targetName) {
|
|
400
|
+
this.host.emit({ type: "notice", level: "info", text: `「${targetName}」已是当前密钥` });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
entry.activeKeyName = targetName;
|
|
404
|
+
this.writeProviderKeys(data);
|
|
405
|
+
await this.applyActiveKey(pid, target.apiKey);
|
|
406
|
+
this.host.emit({
|
|
407
|
+
type: "notice",
|
|
408
|
+
level: "info",
|
|
409
|
+
text: `⚡ 已切换到 ${pid} 的「${targetName}」`,
|
|
410
|
+
});
|
|
411
|
+
await this.host.pushModels();
|
|
412
|
+
await this.listProviders();
|
|
413
|
+
this.listProviderKeys();
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
this.host.emit({
|
|
417
|
+
type: "notice",
|
|
418
|
+
level: "error",
|
|
419
|
+
text: `切换密钥失败:${err.message}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
this.host.flushSnapshot();
|
|
423
|
+
}
|
|
424
|
+
/** Remove a stored API key by NAME. If it was active, the first remaining key
|
|
425
|
+
* becomes active (or the provider returns to unconfigured when no key is left). */
|
|
426
|
+
async removeProviderKey(provider, keyName) {
|
|
427
|
+
const pid = provider.trim();
|
|
428
|
+
const targetName = keyName.trim();
|
|
429
|
+
try {
|
|
430
|
+
const data = this.readProviderKeys();
|
|
431
|
+
const entry = data[pid];
|
|
432
|
+
if (!entry || !entry.keys.some((k) => k.name === targetName)) {
|
|
433
|
+
this.host.emit({ type: "notice", level: "error", text: `${pid} 的密钥「${targetName}」不存在` });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const wasActive = entry.activeKeyName === targetName;
|
|
437
|
+
entry.keys = entry.keys.filter((k) => k.name !== targetName);
|
|
438
|
+
if (entry.keys.length === 0) {
|
|
439
|
+
delete data[pid];
|
|
440
|
+
this.writeProviderKeys(data);
|
|
441
|
+
// Drop auth.json entry + runtime override so the provider returns
|
|
442
|
+
// to unconfigured (its stored keys are gone too).
|
|
443
|
+
const authPath = join(this.host.agentDir, "auth.json");
|
|
444
|
+
let auth = {};
|
|
445
|
+
try {
|
|
446
|
+
auth = JSON.parse(readFileSync(authPath, "utf8"));
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// no file yet — nothing to clean
|
|
450
|
+
}
|
|
451
|
+
delete auth[pid];
|
|
452
|
+
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n");
|
|
453
|
+
const mr = this.host.modelRuntime();
|
|
454
|
+
await mr.removeRuntimeApiKey(pid);
|
|
455
|
+
await mr.refresh({ providers: [pid] });
|
|
456
|
+
this.host.invalidatePiConfig();
|
|
457
|
+
this.host.emit({
|
|
458
|
+
type: "notice",
|
|
459
|
+
level: "info",
|
|
460
|
+
text: `🗑 已移除 ${pid} 的密钥「${targetName}」,该服务商回到未配置状态`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
if (wasActive) {
|
|
465
|
+
entry.activeKeyName = entry.keys[0].name;
|
|
466
|
+
this.writeProviderKeys(data);
|
|
467
|
+
await this.applyActiveKey(pid, entry.keys[0].apiKey);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
this.writeProviderKeys(data);
|
|
471
|
+
}
|
|
472
|
+
this.host.emit({
|
|
473
|
+
type: "notice",
|
|
474
|
+
level: "info",
|
|
475
|
+
text: wasActive
|
|
476
|
+
? `🗑 已移除「${targetName}」,已切换到 ${entry.keys[0].name}`
|
|
477
|
+
: `🗑 已移除 ${pid} 的密钥「${targetName}」`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
await this.host.pushModels();
|
|
481
|
+
await this.listProviders();
|
|
482
|
+
this.listProviderKeys();
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
this.host.emit({
|
|
486
|
+
type: "notice",
|
|
487
|
+
level: "error",
|
|
488
|
+
text: `移除密钥失败:${err.message}`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
this.host.flushSnapshot();
|
|
492
|
+
}
|
|
180
493
|
/**
|
|
181
494
|
* Clear a built-in provider's stored API key (auth.json entry + runtime
|
|
182
495
|
* override) so it returns to the unconfigured state — its models disappear
|
|
@@ -200,7 +513,9 @@ export class ModelAdminService {
|
|
|
200
513
|
catch {
|
|
201
514
|
// no file yet / unparsable — nothing stored to clear
|
|
202
515
|
}
|
|
203
|
-
|
|
516
|
+
const keyData = this.readProviderKeys();
|
|
517
|
+
const hasStoredKeys = (keyData[pid]?.keys.length ?? 0) > 0;
|
|
518
|
+
if (!(pid in data) && !hasStoredKeys) {
|
|
204
519
|
this.host.emit({
|
|
205
520
|
type: "notice",
|
|
206
521
|
level: "info",
|
|
@@ -210,11 +525,14 @@ export class ModelAdminService {
|
|
|
210
525
|
}
|
|
211
526
|
delete data[pid];
|
|
212
527
|
writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
|
|
528
|
+
// Clear every stored key so the provider returns to unconfigured.
|
|
529
|
+
delete keyData[pid];
|
|
530
|
+
this.writeProviderKeys(keyData);
|
|
213
531
|
// Drop the runtime override too, then re-read credentials so the
|
|
214
532
|
// provider goes back to unconfigured and its models leave the list.
|
|
215
533
|
const mr = this.host.modelRuntime();
|
|
216
534
|
await mr.removeRuntimeApiKey(pid);
|
|
217
|
-
await mr.refresh();
|
|
535
|
+
await mr.refresh({ providers: [pid] });
|
|
218
536
|
this.host.invalidatePiConfig();
|
|
219
537
|
this.host.emit({
|
|
220
538
|
type: "notice",
|
|
@@ -223,6 +541,7 @@ export class ModelAdminService {
|
|
|
223
541
|
});
|
|
224
542
|
await this.host.pushModels();
|
|
225
543
|
await this.listProviders();
|
|
544
|
+
this.listProviderKeys();
|
|
226
545
|
}
|
|
227
546
|
catch (err) {
|
|
228
547
|
this.host.emit({
|
|
@@ -243,7 +562,10 @@ export class ModelAdminService {
|
|
|
243
562
|
*/
|
|
244
563
|
async cloneProvider(providerId, reqId) {
|
|
245
564
|
const pid = providerId.trim();
|
|
246
|
-
const fail = (error) =>
|
|
565
|
+
const fail = (error) => {
|
|
566
|
+
this.host.emit({ type: "notice", level: "error", text: error });
|
|
567
|
+
this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
|
|
568
|
+
};
|
|
247
569
|
try {
|
|
248
570
|
if (!pid) {
|
|
249
571
|
fail("请填写服务商 ID");
|
|
@@ -255,10 +577,7 @@ export class ModelAdminService {
|
|
|
255
577
|
fail(`供应商 ${pid} 不存在`);
|
|
256
578
|
return;
|
|
257
579
|
}
|
|
258
|
-
|
|
259
|
-
fail(`${pid} 没有 baseUrl(OAuth/环境变量型供应商),无法复制为自定义服务商`);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
580
|
+
const noBaseUrl = !p.baseUrl;
|
|
262
581
|
// Map runtime models → models.json rows; dynamic providers ship an
|
|
263
582
|
// empty catalog until refreshed over the network.
|
|
264
583
|
const readModels = () => {
|
|
@@ -290,7 +609,8 @@ export class ModelAdminService {
|
|
|
290
609
|
fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`);
|
|
291
610
|
return;
|
|
292
611
|
}
|
|
293
|
-
//
|
|
612
|
+
// 供应商级 api 取占比最高,模型保留全量去重(避免 muse-spark 被过滤)
|
|
613
|
+
// 多 key 场景:复制一次即得到 opencode1/opencode2 两组,界面按供应商分组,选模型即切 key
|
|
294
614
|
const counts = new Map();
|
|
295
615
|
for (const m of models)
|
|
296
616
|
counts.set(m.api, (counts.get(m.api) ?? 0) + 1);
|
|
@@ -298,9 +618,11 @@ export class ModelAdminService {
|
|
|
298
618
|
for (const [k, v] of counts)
|
|
299
619
|
if (v > (counts.get(api) ?? 0))
|
|
300
620
|
api = k;
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
621
|
+
const keptMap = new Map();
|
|
622
|
+
for (const m of models)
|
|
623
|
+
if (!keptMap.has(m.entry.id))
|
|
624
|
+
keptMap.set(m.entry.id, m.entry);
|
|
625
|
+
const kept = [...keptMap.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
304
626
|
const taken = new Set([
|
|
305
627
|
...Object.keys(this.readModelsConfig().providers),
|
|
306
628
|
...mr.getRegisteredProviderIds(),
|
|
@@ -308,19 +630,22 @@ export class ModelAdminService {
|
|
|
308
630
|
let newId = `${pid}-2`;
|
|
309
631
|
for (let n = 2; taken.has(newId); n++)
|
|
310
632
|
newId = `${pid}-${n}`;
|
|
633
|
+
const defaultBaseUrl = noBaseUrl && (pid === "opencode-go" || pid === "opencode") ? "http://127.0.0.1:4096" : undefined;
|
|
311
634
|
const config = {
|
|
312
635
|
providerId: newId,
|
|
313
636
|
name: p.name,
|
|
314
637
|
api,
|
|
315
|
-
baseUrl: p.baseUrl,
|
|
638
|
+
...(p.baseUrl ? { baseUrl: p.baseUrl } : defaultBaseUrl ? { baseUrl: defaultBaseUrl } : {}),
|
|
316
639
|
models: kept,
|
|
317
640
|
};
|
|
318
641
|
this.host.emit({
|
|
319
642
|
type: "notice",
|
|
320
|
-
level: "info",
|
|
321
|
-
text:
|
|
643
|
+
level: noBaseUrl ? "warning" : "info",
|
|
644
|
+
text: noBaseUrl
|
|
645
|
+
? `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),该供应商无远程 baseUrl,已生成模板请手动填写 baseUrl 和新的 API 密钥后保存`
|
|
646
|
+
: `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
|
|
322
647
|
});
|
|
323
|
-
this.host.emit({ type: "clone_provider_result", reqId, ok: true, config });
|
|
648
|
+
this.host.emit({ type: "clone_provider_result", reqId, ok: true, config, configs: [config] });
|
|
324
649
|
}
|
|
325
650
|
catch (err) {
|
|
326
651
|
fail(`复制服务商失败:${err.message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{a as c,j as s}from"./markdown-DRBrS2Nf.js";import{b as R,T as I,u as L,F as J,a as K,c as X,d as Q,e as U,f as V,g as W,h as Y,r as Z}from"./index-
|
|
1
|
+
import{a as c,j as s}from"./markdown-DRBrS2Nf.js";import{b as R,T as I,u as L,F as J,a as K,c as X,d as Q,e as U,f as V,g as W,h as Y,r as Z}from"./index-BVuOleif.js";import{D as ee,o as se}from"./xterm-D1D2FVe3.js";import"./react-C9ovnpIm.js";function ne({conversationId:n,terminalId:o,command:x,cwd:t,active:f,send:m,register:b}){const j=c.useRef(null),w=c.useRef(null),g=x?JSON.stringify(x):"";return c.useEffect(()=>{const p=j.current;if(!p)return;const a=new ee({theme:R(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),r=new se;a.loadAddon(r),a.open(p),w.current={term:a,fit:r},f&&a.focus();const y=()=>{a.options.theme=R()};window.addEventListener(I,y),a.attachCustomKeyEventHandler(d=>{var E;if(d.type!=="keydown")return!0;const v=(E=d.key)==null?void 0:E.toLowerCase();if((d.ctrlKey||d.metaKey)&&v==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&v==="c"&&a.hasSelection()){const F=a.textarea;return F&&(F.value=a.getSelection(),F.select()),!1}return!0});const C=b(n,o,{write:d=>a.write(d),dispose:()=>a.dispose()}),T=()=>{try{r.fit(),m({type:"terminal_resize",terminalId:o,conversationId:n,cols:a.cols,rows:a.rows})}catch{}},h=requestAnimationFrame(()=>{try{r.fit()}catch{}m(x?{type:"run_command",terminalId:o,conversationId:n,command:x,cols:a.cols,rows:a.rows}:{type:"terminal_create",terminalId:o,conversationId:n,cwd:t,cols:a.cols,rows:a.rows})}),k=a.onData(d=>{m({type:"terminal_input",terminalId:o,conversationId:n,data:d})});let N=null;return typeof ResizeObserver<"u"&&(N=new ResizeObserver(()=>{p.offsetWidth>0&&p.offsetHeight>0&&T()}),N.observe(p)),()=>{cancelAnimationFrame(h),k.dispose(),window.removeEventListener(I,y),N==null||N.disconnect(),C(),a.dispose(),w.current=null}},[n,o,g,m,b]),c.useEffect(()=>{if(!f)return;const p=requestAnimationFrame(()=>{const a=w.current;if(a){try{a.fit.fit(),m({type:"terminal_resize",terminalId:o,conversationId:n,cols:a.term.cols,rows:a.term.rows})}catch{}a.term.focus()}});return()=>cancelAnimationFrame(p)},[f]),s.jsx("div",{ref:j,className:`term-xterm ${f?"":"hidden"}`})}const B={name:"",command:"",cwd:"${pwd}"};function re({chat:n,send:o,terminal:x}){const t=L(),[f,m]=c.useState(null),[b,j]=c.useState(!1),[w,g]=c.useState(!1),[p,a]=c.useState(null),[r,y]=c.useState(B),[C,T]=c.useState(null),h=c.useRef(null),[k,N]=c.useState(!0);c.useEffect(()=>{n.terminals.length===0?m(null):n.terminals.some(e=>e.id===f)||m(n.terminals[n.terminals.length-1].id)},[n.terminals,f]),c.useEffect(()=>{n.terminalActiveId&&(m(n.terminalActiveId),j(!1))},[n.terminalActiveId]),c.useEffect(()=>()=>{h.current&&clearTimeout(h.current)},[]);const d=n.terminals.filter(e=>!e.agentBash),v=n.terminals.filter(e=>e.agentBash),E=e=>{var u;if(!n.ready)return;const i=Z(),l=n.activeConversationId||((u=n.state)==null?void 0:u.conversationId)||"";x.create({...e,id:i,conversationId:l,cols:e.cols??80,rows:e.rows??24,running:!0,exitCode:null}),m(i),j(!1)},F=()=>{var e;return E({title:t("terminalTitle",{n:d.length+1}),cwd:((e=n.state)==null?void 0:e.cwd)??""})},$=e=>{var u;const i=e.name||e.command,l=n.terminals.find(_=>_.title===i);if(l){x.restart(l.id),m(l.id),o({type:"run_command",terminalId:l.id,conversationId:l.conversationId,command:e,cols:80,rows:24});return}E({title:i,cwd:((u=n.state)==null?void 0:u.cwd)??"",command:e})},O=e=>{const i=n.terminals.find(l=>l.id===e);if(i&&o({type:"terminal_kill",terminalId:e,conversationId:i.conversationId}),x.close(e),f===e){const l=n.terminals.filter(u=>u.id!==e);m(l.length>0?l[l.length-1].id:null)}},D=e=>s.jsxs("div",{className:`term-tab ${e.id===f?"active":""}`,children:[s.jsxs("button",{type:"button",className:"term-tab-main",title:`${e.cwd}${e.command?`
|
|
2
2
|
> ${e.command.command}`:""}`,onClick:()=>{m(e.id),j(!1)},children:[s.jsx("span",{className:`term-tab-dot ${e.running?"run":"exit"}`}),s.jsxs("span",{className:"term-tab-title",children:[e.title,!e.running&&s.jsx("span",{className:"term-tab-exit",children:t("exited",{code:e.exitCode===null?"":` ${e.exitCode}`})})]})]}),s.jsx("button",{type:"button",className:"term-tab-close",title:t("closeTerminal"),onClick:()=>O(e.id),children:s.jsx(Y,{})})]},e.id),M=()=>{g(!0),a(null),y(B)},z=e=>{const i=n.commands[e];i&&(g(!1),a(e),y({name:i.name,command:i.command,cwd:i.cwd??""}))},A=()=>{g(!1),a(null)},S=()=>{const e=r.name.trim(),i=r.command.trim();if(!e||!i)return;const l=r.cwd.trim(),u={name:e,command:i,cwd:l||void 0},_=w?[...n.commands,u]:p!==null?n.commands.map((q,G)=>G===p?u:q):n.commands;o({type:"save_commands",commands:_}),A()},H=e=>{if(C===e){const i=n.commands.filter((l,u)=>u!==e);o({type:"save_commands",commands:i}),T(null),h.current&&clearTimeout(h.current)}else T(e),h.current&&clearTimeout(h.current),h.current=setTimeout(()=>T(null),2500)},P=w||p!==null;return s.jsxs("div",{className:"terminal-view",children:[s.jsxs("aside",{className:`term-side term-commands ${b?"open":""}`,children:[s.jsxs("div",{className:"panel-header",children:[s.jsx("span",{className:"panel-title",children:t("commands")}),s.jsxs("div",{className:"panel-header-actions",children:[s.jsx("button",{type:"button",className:"panel-refresh",title:t("rerun"),onClick:()=>o({type:"list_commands"}),children:s.jsx(J,{})}),s.jsx("button",{type:"button",className:"panel-new",title:t("newCommand"),onClick:M,children:s.jsx(K,{})})]})]}),s.jsx("div",{className:"panel-body",children:P?s.jsxs("div",{className:"cmd-form",children:[s.jsx("label",{htmlFor:"cmd-name",children:t("name")}),s.jsx("input",{id:"cmd-name",className:"cmd-input",value:r.name,placeholder:t("exampleName"),autoFocus:!0,onChange:e=>y({...r,name:e.target.value})}),s.jsx("label",{htmlFor:"cmd-command",children:t("command")}),s.jsx("input",{id:"cmd-command",className:"cmd-input",value:r.command,placeholder:t("exampleCommand"),onChange:e=>y({...r,command:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),s.jsxs("label",{htmlFor:"cmd-cwd",children:[t("directory")," ",s.jsx("span",{className:"cmd-hint",children:t("cwdHint")})]}),s.jsx("input",{id:"cmd-cwd",className:"cmd-input",value:r.cwd,placeholder:"${pwd}",onChange:e=>y({...r,cwd:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),s.jsxs("div",{className:"cmd-form-actions",children:[s.jsx("button",{type:"button",className:"btn",onClick:A,children:t("cancel")}),s.jsx("button",{type:"button",className:"btn primary",disabled:!r.name.trim()||!r.command.trim(),onClick:S,children:t("save")})]})]}):s.jsxs(s.Fragment,{children:[n.commands.length===0&&s.jsx("div",{className:"panel-empty",children:t("noCommands")}),n.commands.map((e,i)=>s.jsxs("div",{className:"cmd-item",children:[s.jsx("button",{type:"button",className:"cmd-run",title:t("clickToRun"),onClick:()=>$(e),children:s.jsx(X,{})}),s.jsxs("button",{type:"button",className:"cmd-main",title:t("clickToRun"),onClick:()=>$(e),children:[s.jsx("span",{className:"cmd-name",children:e.name}),s.jsx("span",{className:"cmd-command",children:e.command}),e.cwd&&s.jsx("span",{className:"cmd-cwd",children:e.cwd})]}),s.jsx("button",{type:"button",className:"cmd-act",title:t("edit"),onClick:()=>z(i),children:s.jsx(Q,{})}),s.jsx("button",{type:"button",className:`cmd-act del ${C===i?"confirm":""}`,title:t("delete"),onClick:()=>H(i),children:C===i?t("confirmQ"):s.jsx(U,{})})]},i))]})}),s.jsxs("div",{className:"term-tabs-block",children:[s.jsxs("div",{className:"panel-header",children:[s.jsx("span",{className:"panel-title",children:t("terminal")}),s.jsx("button",{type:"button",className:"panel-new",title:t("newTerminal"),onClick:F,children:s.jsx(K,{})})]}),s.jsxs("div",{className:"panel-body",children:[n.terminals.length===0&&s.jsx("div",{className:"panel-empty",children:t("noTerminal")}),d.map(D),v.length>0&&s.jsxs("div",{className:"term-folder",children:[s.jsxs("button",{type:"button",className:`term-folder-header ${k?"open":""}`,title:t("aiBashGroup"),onClick:()=>N(e=>!e),children:[s.jsx("span",{className:"term-folder-caret",children:k?"▾":"▸"}),s.jsx("span",{className:"term-folder-title",children:t("aiBashGroup")}),s.jsx("span",{className:"term-folder-count",children:v.length})]}),k&&s.jsx("div",{className:"term-folder-body",children:v.map(D)})]})]})]})]}),s.jsxs("div",{className:"term-main",children:[b&&s.jsx("div",{className:"drawer-backdrop",onClick:()=>j(!1)}),s.jsx("button",{type:"button",className:"term-side-toggle",title:t("commands"),onClick:()=>j(e=>!e),children:s.jsx(V,{})}),n.terminals.length===0?s.jsxs("div",{className:"term-empty",children:[s.jsx(W,{className:"term-empty-icon"}),s.jsx("div",{className:"term-empty-title",children:t("builtinTerminal")}),s.jsx("div",{className:"term-empty-sub",children:t("termEmptySub")})]}):n.terminals.map(e=>s.jsx(ne,{conversationId:e.conversationId,terminalId:e.id,command:e.command,cwd:e.cwd,active:e.id===f,send:o,register:x.register},`${e.conversationId}:${e.id}`))]})]})}export{re as TerminalPanel};
|