@evandu/super-pi 0.1.123 → 0.1.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -7
- package/dist/client/assets/{CodeMirrorEditor-B2AXs2YB.js → CodeMirrorEditor-bNGSS1rQ.js} +2 -2
- package/dist/client/assets/{dist-CjyJqaHj.js → dist-BNFxxYvs.js} +1 -1
- package/dist/client/assets/{dist-DOQGcum7.js → dist-BZ-HTc7j.js} +1 -1
- package/dist/client/assets/{dist-DOuFuHYr.js → dist-BZB9Z8C-.js} +1 -1
- package/dist/client/assets/{dist-CWcEOVNV.js → dist-BdNKDCbA.js} +1 -1
- package/dist/client/assets/{dist-D11ifrNu.js → dist-Bea-jKEE.js} +1 -1
- package/dist/client/assets/{dist-CRpnXCSn.js → dist-Bf28S0tQ.js} +1 -1
- package/dist/client/assets/{dist-CrYrGCRr.js → dist-BkzoxIX4.js} +1 -1
- package/dist/client/assets/{dist-D899ixlt.js → dist-Boojk5ek.js} +1 -1
- package/dist/client/assets/{dist-GQ99vtKx.js → dist-BuO7_0WE.js} +1 -1
- package/dist/client/assets/{dist-Wq0CGa1P.js → dist-BwJvT1Pb.js} +1 -1
- package/dist/client/assets/{dist-4hqZVn6B.js → dist-C1MR_bG_.js} +1 -1
- package/dist/client/assets/{dist-CvUxw4HU.js → dist-C1W6z7MQ.js} +1 -1
- package/dist/client/assets/{dist-CAFUK38E.js → dist-CIxJmM-_.js} +1 -1
- package/dist/client/assets/{dist-Dp9eqvWE.js → dist-CROgPs2p.js} +1 -1
- package/dist/client/assets/{dist-CcAEnfzB.js → dist-CwC6b2Cl.js} +1 -1
- package/dist/client/assets/{dist-BqlvAjyT.js → dist-D-1R5iHi.js} +1 -1
- package/dist/client/assets/{dist-CoqrRbfm.js → dist-D8wvXcz3.js} +1 -1
- package/dist/client/assets/{dist-Ip0mRDF1.js → dist-DAs0bmNx.js} +1 -1
- package/dist/client/assets/{dist-B0G3TOfE.js → dist-DXsLC1J6.js} +1 -1
- package/dist/client/assets/{dist-DC4KdKrw.js → dist-D_E1E9X4.js} +1 -1
- package/dist/client/assets/{dist-Cxo9Qf8Y.js → dist-RFhXNQyY.js} +1 -1
- package/dist/client/assets/{dist--hp7cqEH.js → dist-U-IA_cdZ.js} +1 -1
- package/dist/client/assets/{index-CCnIvjP6.css → index-4kjGwKOr.css} +1 -1
- package/dist/client/assets/index-LL00oFAG.js +99 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/provider-service.d.ts +51 -2
- package/dist/server/application/provider-service.js +166 -11
- package/dist/server/application/run-service.js +14 -7
- package/dist/server/command-handler.js +21 -0
- package/dist/server/http-server.js +8 -0
- package/dist/server/index.js +2 -0
- package/dist/server/pi-session-events.d.ts +6 -1
- package/dist/server/providers.d.ts +101 -0
- package/dist/server/providers.js +241 -35
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/runtime.js +42 -20
- package/dist/server/store.d.ts +15 -0
- package/dist/server/store.js +34 -0
- package/dist/server/terminal.d.ts +51 -0
- package/dist/server/terminal.js +81 -12
- package/dist/server/types.d.ts +38 -0
- package/package.json +3 -1
- package/dist/client/assets/index-B0TMoUBp.js +0 -99
package/dist/server/providers.js
CHANGED
|
@@ -168,35 +168,54 @@ async function writeJsonAtomic(path, value) {
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
/**
|
|
171
|
-
* The provider entry Super Pi writes into Pi's `models.json
|
|
171
|
+
* The provider entry Super Pi writes into Pi's `models.json`, MERGED into what was already there.
|
|
172
172
|
*
|
|
173
|
-
* NO `apiKey` HERE, and that is a decision with a reason behind it: a value in
|
|
174
|
-
* through Pi's config-value syntax, where `$NAME` means "read this environment
|
|
175
|
-
* `!command` means "run this and take its output". A key that happens to start with a
|
|
176
|
-
* would be silently turned into something else. `auth.json` stores the key verbatim, and
|
|
177
|
-
* where Pi's own `/login` puts it, so that is where this goes.
|
|
173
|
+
* NO `apiKey` IS WRITTEN HERE, and that is a decision with a reason behind it: a value in
|
|
174
|
+
* `models.json` goes through Pi's config-value syntax, where `$NAME` means "read this environment
|
|
175
|
+
* variable" and `!command` means "run this and take its output". A key that happens to start with a
|
|
176
|
+
* dollar sign would be silently turned into something else. `auth.json` stores the key verbatim, and
|
|
177
|
+
* it is where Pi's own `/login` puts it, so that is where this goes. A key the PERSON wrote there is
|
|
178
|
+
* left alone, because this function never touches fields it does not own.
|
|
179
|
+
*
|
|
180
|
+
* WHY IT MERGES RATHER THAN REPLACES. Pi's provider schema holds more than this form collects -
|
|
181
|
+
* `cost`, `headers`, `samplingParams`, `thinkingLevelMap`, `modelOverrides`, `oauth`, `authHeader`,
|
|
182
|
+
* an inline `apiKey` - and a settings panel that calls itself a view of Pi's configuration cannot
|
|
183
|
+
* delete the parts of it it has no field for. An earlier version replaced the whole entry, so
|
|
184
|
+
* editing ONE provider silently rewrote every OTHER entry in the file in this app's shape.
|
|
178
185
|
*/
|
|
179
|
-
function toPiProvider(provider) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
186
|
+
function toPiProvider(provider, existing) {
|
|
187
|
+
const base = existing && typeof existing === "object" ? { ...existing } : {};
|
|
188
|
+
const previousModels = Array.isArray(base.models) ? base.models : [];
|
|
189
|
+
const models = provider.models.map(model => {
|
|
190
|
+
const prior = previousModels.find((entry) => entry?.id === model.id);
|
|
191
|
+
const priorModel = prior && typeof prior === "object" ? prior : {};
|
|
192
|
+
const priorCompat = priorModel.compat && typeof priorModel.compat === "object"
|
|
193
|
+
? priorModel.compat
|
|
194
|
+
: {};
|
|
195
|
+
const compat = {
|
|
196
|
+
...priorCompat,
|
|
197
|
+
...(model.supportsDeveloperRole === undefined ? {} : { supportsDeveloperRole: model.supportsDeveloperRole }),
|
|
198
|
+
...(model.supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort: model.supportsReasoningEffort }),
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
...priorModel,
|
|
202
|
+
id: model.id,
|
|
203
|
+
name: model.name ?? model.id,
|
|
204
|
+
reasoning: model.reasoning ?? false,
|
|
205
|
+
input: model.imageInput ? ["text", "image"] : ["text"],
|
|
206
|
+
contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
207
|
+
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
208
|
+
...(Object.keys(compat).length ? { compat } : { compat: undefined }),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
196
211
|
return {
|
|
212
|
+
...base,
|
|
197
213
|
...(provider.name ? { name: provider.name } : {}),
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
// AN EMPTY ADDRESS IS OMITTED, not written as "". Pi's schema refuses a baseUrl shorter than one
|
|
215
|
+
// character, so writing "" for an entry that had none - a built-in override, for instance -
|
|
216
|
+
// would make Pi reject the whole file at its next start.
|
|
217
|
+
...(provider.baseUrl ? { baseUrl: provider.baseUrl } : {}),
|
|
218
|
+
...(provider.api ? { api: provider.api } : {}),
|
|
200
219
|
models,
|
|
201
220
|
};
|
|
202
221
|
}
|
|
@@ -223,25 +242,53 @@ export class ProviderRegistry {
|
|
|
223
242
|
get registryPath() {
|
|
224
243
|
return join(this.dataDir, "providers.json");
|
|
225
244
|
}
|
|
245
|
+
/** The registry file as one object: the managed providers AND the ones turned off. */
|
|
246
|
+
async readRegistry() {
|
|
247
|
+
const raw = await readJsonFile(this.registryPath);
|
|
248
|
+
if (!raw || typeof raw !== "object")
|
|
249
|
+
return { providers: [], disabled: [] };
|
|
250
|
+
const record = raw;
|
|
251
|
+
const providerList = Array.isArray(record.providers)
|
|
252
|
+
? record.providers.filter((p) => Boolean(p) && typeof p === "object")
|
|
253
|
+
: [];
|
|
254
|
+
const disabledList = Array.isArray(record.disabled)
|
|
255
|
+
? record.disabled.filter((p) => Boolean(p) && typeof p === "object" && typeof p.id === "string")
|
|
256
|
+
: [];
|
|
257
|
+
return { providers: providerList, disabled: disabledList };
|
|
258
|
+
}
|
|
259
|
+
/** Writes one half of the registry file without losing the other. */
|
|
260
|
+
async writeRegistry(value) {
|
|
261
|
+
const current = await this.readRegistry();
|
|
262
|
+
await writeJsonAtomic(this.registryPath, {
|
|
263
|
+
providers: value.providers ?? current.providers,
|
|
264
|
+
disabled: value.disabled ?? current.disabled,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
226
267
|
/**
|
|
227
268
|
* Reads all configured providers directly from Pi's models.json and auth.json.
|
|
228
269
|
* Super Pi acts as the direct visual management plane for Pi's model configurations.
|
|
270
|
+
*
|
|
271
|
+
* PROVIDERS THAT ARE TURNED OFF ARE NOT IN HERE. They have been taken out of Pi's files - that is
|
|
272
|
+
* what "off" means - so this list is exactly what Pi would offer, which is what the write path
|
|
273
|
+
* (`set`, `remove`, `applyToPi`) has to reason about. `loadDisabled()` is the other half, for the
|
|
274
|
+
* panel that has to be able to switch them back on.
|
|
229
275
|
*/
|
|
230
276
|
async load() {
|
|
231
277
|
const modelsPath = piModelsPath(this.piDir);
|
|
232
278
|
const authPath = piAuthPath(this.piDir);
|
|
233
279
|
const piModels = (await readJsonFile(modelsPath))?.providers ?? {};
|
|
234
280
|
const piAuth = (await readJsonFile(authPath)) ?? {};
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
const backupList = rawBackup && typeof rawBackup === "object" && Array.isArray(rawBackup.providers)
|
|
238
|
-
? rawBackup.providers.filter((p) => Boolean(p) && typeof p === "object")
|
|
239
|
-
: [];
|
|
281
|
+
const registry = await this.readRegistry();
|
|
282
|
+
const backupList = registry.providers;
|
|
240
283
|
const backupMap = new Map(backupList.map((p) => [p.id, p]));
|
|
284
|
+
// A provider that is OFF must not come back through the backup: the backup is where our keys
|
|
285
|
+
// live, and a key for a provider the person switched off would silently switch it back on at
|
|
286
|
+
// the next `load()` - before any UI could show the switch.
|
|
287
|
+
const offIds = new Set(registry.disabled.map((entry) => entry.id));
|
|
241
288
|
const providers = [];
|
|
242
289
|
const seen = new Set();
|
|
243
290
|
for (const [pId, pData] of Object.entries(piModels)) {
|
|
244
|
-
if (!pData || typeof pData !== "object")
|
|
291
|
+
if (!pData || typeof pData !== "object" || offIds.has(pId))
|
|
245
292
|
continue;
|
|
246
293
|
seen.add(pId);
|
|
247
294
|
const rec = pData;
|
|
@@ -257,6 +304,10 @@ export class ProviderRegistry {
|
|
|
257
304
|
imageInput: Array.isArray(m?.input) ? m.input.includes("image") : false,
|
|
258
305
|
contextWindow: typeof m?.contextWindow === "number" ? m.contextWindow : undefined,
|
|
259
306
|
maxTokens: typeof m?.maxTokens === "number" ? m.maxTokens : undefined,
|
|
307
|
+
// THE TWO COMPATIBILITY SWITCHES ARE READ BACK, or the form would show its own defaults for
|
|
308
|
+
// a setting the file explicitly turned off, and saving the form would silently turn it on.
|
|
309
|
+
...(typeof m?.compat?.supportsDeveloperRole === "boolean" ? { supportsDeveloperRole: m.compat.supportsDeveloperRole } : {}),
|
|
310
|
+
...(typeof m?.compat?.supportsReasoningEffort === "boolean" ? { supportsReasoningEffort: m.compat.supportsReasoningEffort } : {}),
|
|
260
311
|
}));
|
|
261
312
|
const apiKey = backup?.apiKey;
|
|
262
313
|
providers.push({
|
|
@@ -265,20 +316,173 @@ export class ProviderRegistry {
|
|
|
265
316
|
baseUrl,
|
|
266
317
|
api,
|
|
267
318
|
...(apiKey ? { apiKey } : {}),
|
|
268
|
-
|
|
319
|
+
// ANY credential counts, not only a `key`. An OAuth provider keeps `access`/`refresh` in
|
|
320
|
+
// this file, and reporting "no key" for a provider Pi is happily using is a lie the panel
|
|
321
|
+
// would then show next to a working model.
|
|
322
|
+
hasAuthKey: Boolean(piAuth[pId] || backup?.apiKey),
|
|
269
323
|
models,
|
|
270
324
|
});
|
|
271
325
|
}
|
|
272
326
|
for (const b of backupList) {
|
|
273
|
-
if (!seen.has(b.id)) {
|
|
327
|
+
if (!seen.has(b.id) && !offIds.has(b.id)) {
|
|
274
328
|
providers.push(b);
|
|
275
329
|
}
|
|
276
330
|
}
|
|
277
331
|
return providers;
|
|
278
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Pi's OWN providers: the ones that exist because they have a credential in `auth.json` and no
|
|
335
|
+
* entry in `models.json`.
|
|
336
|
+
*
|
|
337
|
+
* WHY THIS IS A SEPARATE READ. A built-in such as `kimi-coding` is defined inside Pi, not in a
|
|
338
|
+
* file - the only thing a person has configured for it is the credential. Those providers are
|
|
339
|
+
* invisible to `load()`, which reads `models.json`, and that is correct for the WRITE path:
|
|
340
|
+
* writing an empty `models.json` entry for a built-in would shadow Pi's own definition with a
|
|
341
|
+
* broken one. But they are exactly what a person wants to switch off ("I stopped paying for this
|
|
342
|
+
* plan"), so the panel has to see them. This returns the ids and the fact of a credential; the
|
|
343
|
+
* models come from Pi's catalogue, which this file layer cannot see.
|
|
344
|
+
*/
|
|
345
|
+
async loadCredentialOnly() {
|
|
346
|
+
const modelsPath = piModelsPath(this.piDir);
|
|
347
|
+
const authPath = piAuthPath(this.piDir);
|
|
348
|
+
const piModels = (await readJsonFile(modelsPath))?.providers ?? {};
|
|
349
|
+
const piAuth = (await readJsonFile(authPath)) ?? {};
|
|
350
|
+
const registry = await this.readRegistry();
|
|
351
|
+
const offIds = new Set(registry.disabled.map((entry) => entry.id));
|
|
352
|
+
return Object.keys(piAuth)
|
|
353
|
+
.filter((id) => !piModels[id] && !offIds.has(id))
|
|
354
|
+
.map((id) => ({ id, name: id, baseUrl: "", api: "", hasAuthKey: true, builtin: true, models: [] }));
|
|
355
|
+
}
|
|
356
|
+
/** The providers that are switched off, as the panel needs to see them. */
|
|
357
|
+
async loadDisabled() {
|
|
358
|
+
return (await this.readRegistry()).disabled;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Takes a provider out of Pi's configuration and remembers exactly what was taken out.
|
|
362
|
+
*
|
|
363
|
+
* TWO FILES, and both are needed. `models.json` is where a custom provider's models are defined;
|
|
364
|
+
* `auth.json` is where its credential is. Pi offers a provider's models only when its auth is
|
|
365
|
+
* configured (`ModelRuntime.getAvailable()` filters on exactly that), so removing the credential
|
|
366
|
+
* is what makes a BUILT-IN stop being offered - it has no `models.json` entry to remove - while
|
|
367
|
+
* removing both is what makes a custom one disappear completely.
|
|
368
|
+
*
|
|
369
|
+
* NOTHING IS THROWN AWAY. Both pieces are written into Super Pi's own registry, which is how
|
|
370
|
+
* `enable` can put them back byte for byte. A person switching off a lapsed subscription provider
|
|
371
|
+
* is not asking to forget their endpoint and their models.
|
|
372
|
+
*/
|
|
373
|
+
async disable(providerId, models = []) {
|
|
374
|
+
const modelsPath = piModelsPath(this.piDir);
|
|
375
|
+
const authPath = piAuthPath(this.piDir);
|
|
376
|
+
const piModels = (await readJsonFile(modelsPath));
|
|
377
|
+
const piAuth = (await readJsonFile(authPath)) ?? {};
|
|
378
|
+
const currentProviders = { ...(piModels?.providers ?? {}) };
|
|
379
|
+
const modelsEntry = currentProviders[providerId];
|
|
380
|
+
const credential = piAuth[providerId];
|
|
381
|
+
if (!modelsEntry && !credential) {
|
|
382
|
+
throw new Error(`Pi 的配置里没有供应商「${providerId}」,无法停用`);
|
|
383
|
+
}
|
|
384
|
+
// Write Pi's files FIRST, and only then remember the switch. A registry that says "off" while
|
|
385
|
+
// Pi still offers the provider is the one state this must never leave behind: the panel would
|
|
386
|
+
// report a provider as gone while it is still in the picker.
|
|
387
|
+
if (modelsEntry) {
|
|
388
|
+
delete currentProviders[providerId];
|
|
389
|
+
await writeJsonAtomic(modelsPath, { ...(piModels ?? {}), providers: currentProviders });
|
|
390
|
+
}
|
|
391
|
+
if (credential) {
|
|
392
|
+
const nextAuth = { ...piAuth };
|
|
393
|
+
delete nextAuth[providerId];
|
|
394
|
+
await writeJsonAtomic(authPath, nextAuth);
|
|
395
|
+
}
|
|
396
|
+
const registry = await this.readRegistry();
|
|
397
|
+
const record = {
|
|
398
|
+
id: providerId,
|
|
399
|
+
...(typeof modelsEntry === "object" && modelsEntry && typeof modelsEntry.name === "string"
|
|
400
|
+
? { name: modelsEntry.name }
|
|
401
|
+
: {}),
|
|
402
|
+
...(modelsEntry ? { modelsEntry: modelsEntry } : {}),
|
|
403
|
+
...(credential ? { credential: credential } : {}),
|
|
404
|
+
models,
|
|
405
|
+
};
|
|
406
|
+
try {
|
|
407
|
+
await this.writeRegistry({
|
|
408
|
+
providers: registry.providers.filter((provider) => provider.id !== providerId),
|
|
409
|
+
disabled: [...registry.disabled.filter((entry) => entry.id !== providerId), record],
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
// The record is what makes the switch reversible, so a failure to write it means the provider
|
|
414
|
+
// has been taken out of Pi's files with nothing left to restore it from. Pi's files are put
|
|
415
|
+
// back before the error is reported - the same rule `set()` follows, for the same reason.
|
|
416
|
+
if (modelsEntry) {
|
|
417
|
+
await writeJsonAtomic(modelsPath, { ...(piModels ?? {}), providers: { ...currentProviders, [providerId]: modelsEntry } }).catch(() => undefined);
|
|
418
|
+
}
|
|
419
|
+
if (credential) {
|
|
420
|
+
await writeJsonAtomic(authPath, { ...piAuth, [providerId]: credential }).catch(() => undefined);
|
|
421
|
+
}
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Puts a provider back exactly where `disable` took it from.
|
|
427
|
+
*
|
|
428
|
+
* AN EXISTING ENTRY IS NOT OVERWRITTEN. The person may have re-created the provider (or written a
|
|
429
|
+
* new `models.json` entry for the same id) while it was off; the newer entry is theirs and wins,
|
|
430
|
+
* and only the missing piece is restored. Refusing would be worse: the switch says "back on", and
|
|
431
|
+
* the credential is what makes it usable.
|
|
432
|
+
*/
|
|
433
|
+
async enable(providerId) {
|
|
434
|
+
const registry = await this.readRegistry();
|
|
435
|
+
const record = registry.disabled.find((entry) => entry.id === providerId);
|
|
436
|
+
if (!record)
|
|
437
|
+
throw new Error(`供应商「${providerId}」没有被停用`);
|
|
438
|
+
if (record.modelsEntry) {
|
|
439
|
+
const modelsPath = piModelsPath(this.piDir);
|
|
440
|
+
const piModels = (await readJsonFile(modelsPath)) ?? {};
|
|
441
|
+
const providers = { ...(piModels.providers ?? {}) };
|
|
442
|
+
if (!providers[providerId]) {
|
|
443
|
+
providers[providerId] = record.modelsEntry;
|
|
444
|
+
await writeJsonAtomic(modelsPath, { ...piModels, providers });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (record.credential) {
|
|
448
|
+
const authPath = piAuthPath(this.piDir);
|
|
449
|
+
const auth = (await readJsonFile(authPath)) ?? {};
|
|
450
|
+
if (!auth[providerId]) {
|
|
451
|
+
await writeJsonAtomic(authPath, { ...auth, [providerId]: record.credential });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
await this.writeRegistry({
|
|
455
|
+
disabled: registry.disabled.filter((entry) => entry.id !== providerId),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Deletes a BUILT-IN provider's credential from Pi's `auth.json`.
|
|
460
|
+
*
|
|
461
|
+
* THE ONLY THING THERE IS TO DELETE. A built-in's models live in Pi itself; what a person has
|
|
462
|
+
* configured for it is the credential, and removing that is what makes Pi stop offering it. This
|
|
463
|
+
* is the PERMANENT version: unlike `disable`, nothing is remembered, so switching it back on means
|
|
464
|
+
* logging in to that provider again (which is what re-subscribing to the plan involves anyway).
|
|
465
|
+
*/
|
|
466
|
+
async removeCredential(providerId) {
|
|
467
|
+
const authPath = piAuthPath(this.piDir);
|
|
468
|
+
const auth = (await readJsonFile(authPath));
|
|
469
|
+
if (!auth || !auth[providerId])
|
|
470
|
+
return;
|
|
471
|
+
const nextAuth = { ...auth };
|
|
472
|
+
delete nextAuth[providerId];
|
|
473
|
+
await writeJsonAtomic(authPath, nextAuth);
|
|
474
|
+
}
|
|
475
|
+
/** Forgets a disabled provider without restoring anything: the permanent form of "off". */
|
|
476
|
+
async forgetDisabled(providerId) {
|
|
477
|
+
const registry = await this.readRegistry();
|
|
478
|
+
const next = registry.disabled.filter((entry) => entry.id !== providerId);
|
|
479
|
+
if (next.length === registry.disabled.length)
|
|
480
|
+
throw new Error(`供应商「${providerId}」没有被停用`);
|
|
481
|
+
await this.writeRegistry({ disabled: next });
|
|
482
|
+
}
|
|
279
483
|
/** Writes the registry. The key lives here, so the file is 0600. */
|
|
280
484
|
async save(providers) {
|
|
281
|
-
await
|
|
485
|
+
await this.writeRegistry({ providers });
|
|
282
486
|
}
|
|
283
487
|
/**
|
|
284
488
|
* Directly updates Pi's models.json and auth.json to match the new set of providers.
|
|
@@ -292,7 +496,7 @@ export class ProviderRegistry {
|
|
|
292
496
|
const existingProviders = existing.providers ?? {};
|
|
293
497
|
const mergedProviders = { ...existingProviders };
|
|
294
498
|
for (const provider of next)
|
|
295
|
-
mergedProviders[provider.id] = toPiProvider(provider);
|
|
499
|
+
mergedProviders[provider.id] = toPiProvider(provider, existingProviders[provider.id]);
|
|
296
500
|
for (const id of previousIds)
|
|
297
501
|
if (!nextIds.has(id))
|
|
298
502
|
delete mergedProviders[id];
|
|
@@ -331,6 +535,8 @@ export function publicProviders(providers) {
|
|
|
331
535
|
baseUrl: provider.baseUrl,
|
|
332
536
|
api: provider.api,
|
|
333
537
|
hasKey: Boolean(provider.apiKey || provider.hasAuthKey),
|
|
538
|
+
...(provider.builtin ? { builtin: true } : {}),
|
|
539
|
+
...(provider.disabled ? { disabled: true } : {}),
|
|
334
540
|
models: provider.models.map(model => ({
|
|
335
541
|
id: model.id,
|
|
336
542
|
name: model.name ?? model.id,
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -77,7 +77,7 @@ export interface AnswerInput {
|
|
|
77
77
|
* Only fires for a steer this turn delivered, never for the prompt that started the turn: the prompt
|
|
78
78
|
* is not re-announced, and a caller's own state decides whether there was anything waiting at all.
|
|
79
79
|
*/
|
|
80
|
-
onSteerDelivered?(text: string): void
|
|
80
|
+
onSteerDelivered?(text: string): void | Promise<void>;
|
|
81
81
|
/**
|
|
82
82
|
* Called ONCE per turn with the Pi entry id of the user message that started it, or never.
|
|
83
83
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -729,15 +729,17 @@ export class PiRpcRuntime {
|
|
|
729
729
|
}
|
|
730
730
|
}
|
|
731
731
|
const queue = new TurnQueue();
|
|
732
|
+
const deferred = (handler) => (...args) => { if (handler)
|
|
733
|
+
queue.push({ kind: "callback", run: () => handler(...args) }); };
|
|
732
734
|
const handlers = {
|
|
733
|
-
onToolCall: input.onToolCall,
|
|
735
|
+
onToolCall: deferred(input.onToolCall),
|
|
734
736
|
onFileWrite: input.onFileWrite,
|
|
735
|
-
onToolUpdate: input.onToolUpdate,
|
|
736
|
-
onToolPreparing: input.onToolPreparing,
|
|
737
|
-
onToolEnd: input.onToolEnd,
|
|
738
|
-
onThinking: input.onThinking,
|
|
739
|
-
onThinkingEnd: input.onThinkingEnd,
|
|
740
|
-
onSteerDelivered: input.onSteerDelivered,
|
|
737
|
+
onToolUpdate: deferred(input.onToolUpdate),
|
|
738
|
+
onToolPreparing: deferred(input.onToolPreparing),
|
|
739
|
+
onToolEnd: deferred(input.onToolEnd),
|
|
740
|
+
onThinking: deferred(input.onThinking),
|
|
741
|
+
onThinkingEnd: deferred(input.onThinkingEnd),
|
|
742
|
+
onSteerDelivered: deferred(input.onSteerDelivered),
|
|
741
743
|
onPiQueue: input.onPiQueue,
|
|
742
744
|
};
|
|
743
745
|
const announced = new Set();
|
|
@@ -853,6 +855,10 @@ export class PiRpcRuntime {
|
|
|
853
855
|
yield item.delta;
|
|
854
856
|
continue;
|
|
855
857
|
}
|
|
858
|
+
if (item.kind === "callback") {
|
|
859
|
+
await item.run();
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
856
862
|
if (item.kind === "error")
|
|
857
863
|
throw item.error;
|
|
858
864
|
break;
|
|
@@ -1329,7 +1335,7 @@ export class PiRpcRuntime {
|
|
|
1329
1335
|
const steeredText = describeDeliveredUserMessage(record);
|
|
1330
1336
|
if (steeredText !== undefined) {
|
|
1331
1337
|
try {
|
|
1332
|
-
onSteerDelivered?.(steeredText);
|
|
1338
|
+
await onSteerDelivered?.(steeredText);
|
|
1333
1339
|
}
|
|
1334
1340
|
catch (error) {
|
|
1335
1341
|
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
@@ -2186,9 +2192,10 @@ export class PiRpcRuntime {
|
|
|
2186
2192
|
*/
|
|
2187
2193
|
async clearQueues(conversationId) {
|
|
2188
2194
|
const empty = { steering: [], followUp: [] };
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2195
|
+
// A session and its queues outlive the active turn, including the abort hand-off window.
|
|
2196
|
+
const session = this.sessionPool.get(conversationId);
|
|
2197
|
+
if (session) {
|
|
2198
|
+
if (!session.clearQueue)
|
|
2192
2199
|
return empty;
|
|
2193
2200
|
try {
|
|
2194
2201
|
const cleared = session.clearQueue();
|
|
@@ -2417,14 +2424,22 @@ export class PiRpcRuntime {
|
|
|
2417
2424
|
// The levels belong to the SELECTED model, so they are asked of the model in question rather
|
|
2418
2425
|
// than of whatever Pi would have defaulted to.
|
|
2419
2426
|
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
// the picker
|
|
2427
|
+
// A MODEL THIS PI DOES NOT KNOW IS NOT A REASON TO THROW THE WHOLE READ AWAY. The MODEL LIST
|
|
2428
|
+
// does not depend on the selection at all - it is the same list for every conversation - so
|
|
2429
|
+
// abandoning it because one model's levels are unknown turns "the model this session used was
|
|
2430
|
+
// removed" into "the picker is empty", which is the exact opposite of what the person needs
|
|
2431
|
+
// at that moment. Measured before this change: removing the model in use produced a catalog
|
|
2432
|
+
// with `source: "unavailable"` and ZERO models, and the spawned fallback that produced it had
|
|
2433
|
+
// only reached its own `set_model` rejection ("Model not found").
|
|
2434
|
+
//
|
|
2435
|
+
// The levels then come from Pi's default model, because that is the model a turn on a
|
|
2436
|
+
// nonexistent model actually runs on (the session cannot resolve it and falls back), so they
|
|
2437
|
+
// describe the model in force rather than a different one.
|
|
2424
2438
|
const forModel = selection ?? reader.defaultModel;
|
|
2425
|
-
|
|
2426
|
-
if (forModel && !levels)
|
|
2427
|
-
|
|
2439
|
+
let levels = forModel ? await reader.thinkingLevelsFor(forModel) : undefined;
|
|
2440
|
+
if (forModel && !levels && reader.defaultModel) {
|
|
2441
|
+
levels = await reader.thinkingLevelsFor(reader.defaultModel);
|
|
2442
|
+
}
|
|
2428
2443
|
const levelNames = levels ?? [];
|
|
2429
2444
|
const value = {
|
|
2430
2445
|
source: "pi",
|
|
@@ -2476,8 +2491,15 @@ export class PiRpcRuntime {
|
|
|
2476
2491
|
const channel = new RpcChannel(child);
|
|
2477
2492
|
if (selection?.provider && selection.modelId) {
|
|
2478
2493
|
const applied = await channel.request({ id: "catalog_set_model", type: "set_model", provider: selection.provider, modelId: selection.modelId }, CATALOG_COMMAND_TIMEOUT_MS);
|
|
2479
|
-
|
|
2480
|
-
|
|
2494
|
+
// A MODEL PI NO LONGER OFFERS IS NOT A REASON TO FAIL THE READ. `set_model` only scopes the
|
|
2495
|
+
// level list to the model in force; the MODEL LIST itself is what the picker needs, and it
|
|
2496
|
+
// does not depend on the selection. Throwing here made a removed model - the model a person
|
|
2497
|
+
// is most likely to have just deleted - produce an empty picker with "Model not found" as
|
|
2498
|
+
// the only explanation. Pi keeps its current model when `set_model` is rejected, so the
|
|
2499
|
+
// levels below are the levels of the model that actually runs.
|
|
2500
|
+
if (applied.success === false) {
|
|
2501
|
+
console.warn(`PiRpcRuntime: catalog for ${key} could not select ${selection.provider}/${selection.modelId} (${applied.error ?? "unknown error"}); listing the models without it`);
|
|
2502
|
+
}
|
|
2481
2503
|
}
|
|
2482
2504
|
channel.write({ id: "catalog_models", type: "get_available_models" });
|
|
2483
2505
|
channel.write({ id: "catalog_levels", type: "get_available_thinking_levels" });
|
package/dist/server/store.d.ts
CHANGED
|
@@ -90,6 +90,7 @@ export declare class Store {
|
|
|
90
90
|
* fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
|
|
91
91
|
*/
|
|
92
92
|
setDefaultModel(model?: ModelSelection): Promise<void>;
|
|
93
|
+
setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
|
|
93
94
|
snapshot(): AppState;
|
|
94
95
|
/**
|
|
95
96
|
* Whether a conversation already holds a message with this id.
|
|
@@ -278,6 +279,20 @@ export declare class Store {
|
|
|
278
279
|
* no longer change until it settles.
|
|
279
280
|
*/
|
|
280
281
|
setConversationModel(conversationId: string, model: ModelSelection): Promise<Conversation>;
|
|
282
|
+
/**
|
|
283
|
+
* Forgets the model a conversation runs on, so it follows Pi's own default again.
|
|
284
|
+
*
|
|
285
|
+
* FOR A MODEL THAT NO LONGER EXISTS. A conversation's model is copied at birth and never re-read
|
|
286
|
+
* from the workspace - but the copied model can be removed from Pi, and leaving the field pointing
|
|
287
|
+
* at it is worse than clearing it: Pi does not fail such a turn, it runs it on its own default, so
|
|
288
|
+
* the picker names one model while another one answers. `undefined` DELETES the key rather than
|
|
289
|
+
* storing "Pi's default" as this app's state, the same as `setConversationThinking` above.
|
|
290
|
+
*
|
|
291
|
+
* THE THINKING LEVEL GOES WITH IT. A level is a property of a model (`max` on a model with one
|
|
292
|
+
* level means something different from `max` on another), so a level left behind by a model that
|
|
293
|
+
* is gone describes nothing.
|
|
294
|
+
*/
|
|
295
|
+
clearConversationModel(conversationId: string): Promise<Conversation>;
|
|
281
296
|
/** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
|
|
282
297
|
setConversationThinking(conversationId: string, level: string | undefined): Promise<Conversation>;
|
|
283
298
|
/**
|
package/dist/server/store.js
CHANGED
|
@@ -683,6 +683,20 @@ export class Store {
|
|
|
683
683
|
throw error;
|
|
684
684
|
}
|
|
685
685
|
}
|
|
686
|
+
async setTerminalShell(shell) {
|
|
687
|
+
const previous = this.state.terminalShell;
|
|
688
|
+
if (shell === undefined)
|
|
689
|
+
delete this.state.terminalShell;
|
|
690
|
+
else
|
|
691
|
+
this.state.terminalShell = { file: shell.file, args: [...shell.args] };
|
|
692
|
+
try {
|
|
693
|
+
await this.persist();
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
this.state.terminalShell = previous;
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
686
700
|
snapshot() { return structuredClone(this.state); }
|
|
687
701
|
/**
|
|
688
702
|
* Whether a conversation already holds a message with this id.
|
|
@@ -1104,6 +1118,26 @@ export class Store {
|
|
|
1104
1118
|
await this.persistMetadata(conversationId);
|
|
1105
1119
|
return structuredClone(conversation);
|
|
1106
1120
|
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Forgets the model a conversation runs on, so it follows Pi's own default again.
|
|
1123
|
+
*
|
|
1124
|
+
* FOR A MODEL THAT NO LONGER EXISTS. A conversation's model is copied at birth and never re-read
|
|
1125
|
+
* from the workspace - but the copied model can be removed from Pi, and leaving the field pointing
|
|
1126
|
+
* at it is worse than clearing it: Pi does not fail such a turn, it runs it on its own default, so
|
|
1127
|
+
* the picker names one model while another one answers. `undefined` DELETES the key rather than
|
|
1128
|
+
* storing "Pi's default" as this app's state, the same as `setConversationThinking` above.
|
|
1129
|
+
*
|
|
1130
|
+
* THE THINKING LEVEL GOES WITH IT. A level is a property of a model (`max` on a model with one
|
|
1131
|
+
* level means something different from `max` on another), so a level left behind by a model that
|
|
1132
|
+
* is gone describes nothing.
|
|
1133
|
+
*/
|
|
1134
|
+
async clearConversationModel(conversationId) {
|
|
1135
|
+
const conversation = this.requireConversation(conversationId);
|
|
1136
|
+
delete conversation.model;
|
|
1137
|
+
delete conversation.thinkingLevel;
|
|
1138
|
+
await this.persistMetadata(conversationId);
|
|
1139
|
+
return structuredClone(conversation);
|
|
1140
|
+
}
|
|
1107
1141
|
/** Restoring Pi's own default deletes the key rather than storing Pi's default as this app's state. */
|
|
1108
1142
|
async setConversationThinking(conversationId, level) {
|
|
1109
1143
|
const conversation = this.requireConversation(conversationId);
|
|
@@ -49,6 +49,46 @@
|
|
|
49
49
|
* only way to get them. The cost is a native module, and it is paid only by operators who set
|
|
50
50
|
*/
|
|
51
51
|
import type { ServerEvent, Workspace } from "./types.js";
|
|
52
|
+
/**
|
|
53
|
+
* WHICH SHELL, and why it is not `COMSPEC`.
|
|
54
|
+
*
|
|
55
|
+
* `process.env.COMSPEC` on Windows IS `cmd.exe`, which is what this function used to spawn - while the
|
|
56
|
+
* comment beside it claimed PowerShell. The mismatch was visible in practice: `cmd.exe` is a DOS-era
|
|
57
|
+
* console with a legacy code page, so a Chinese filename printed by `dir` arrives as mojibake and the
|
|
58
|
+
* prompt echoes the active code page. Super Pi's whole interface is Chinese, so a terminal that cannot
|
|
59
|
+
* print Chinese is not a working terminal for the people who would use it.
|
|
60
|
+
*
|
|
61
|
+
* PowerShell is therefore the default, chosen over `pwsh` by name because `powershell.exe` ships with
|
|
62
|
+
* every supported Windows version while `pwsh` is a separate install. `SUPER_PI_TERMINAL_SHELL` overrides
|
|
63
|
+
* it for anyone who wants `cmd.exe`, `pwsh`, or Git Bash.
|
|
64
|
+
*
|
|
65
|
+
* On Unix the user's own `$SHELL` is right and a login shell is requested, because the terminal should
|
|
66
|
+
* see the same PATH and aliases as the terminal they use elsewhere.
|
|
67
|
+
*
|
|
68
|
+
* The ARGS differ by family and are not an arbitrary detail: `-l` is a bash/sh login flag, and passing it
|
|
69
|
+
* to PowerShell would make the shell refuse to start.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseShellCommand(command: string): {
|
|
72
|
+
file: string;
|
|
73
|
+
args: string[];
|
|
74
|
+
} | undefined;
|
|
75
|
+
export declare function defaultShell(env?: NodeJS.ProcessEnv, configured?: {
|
|
76
|
+
file: string;
|
|
77
|
+
args: string[];
|
|
78
|
+
}): {
|
|
79
|
+
file: string;
|
|
80
|
+
args: string[];
|
|
81
|
+
};
|
|
82
|
+
export declare function shellSource(env?: NodeJS.ProcessEnv, configured?: {
|
|
83
|
+
file: string;
|
|
84
|
+
args: string[];
|
|
85
|
+
}): "设置" | "环境变量" | "平台默认";
|
|
86
|
+
export declare function validateShellCommand(command: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
87
|
+
export declare function terminalShellCandidates(env?: NodeJS.ProcessEnv): {
|
|
88
|
+
file: string;
|
|
89
|
+
args: string[];
|
|
90
|
+
label: string;
|
|
91
|
+
}[];
|
|
52
92
|
/** One shell per browser tab, and a hard ceiling so reconnects cannot accumulate processes. */
|
|
53
93
|
export declare const MAX_SESSIONS = 8;
|
|
54
94
|
/**
|
|
@@ -107,12 +147,23 @@ export declare class TerminalService {
|
|
|
107
147
|
private readonly workspaceFor;
|
|
108
148
|
/** Whether a command from this peer is allowed to touch a shell at all. */
|
|
109
149
|
private readonly isTrustedPeer;
|
|
150
|
+
private readonly configuredShell;
|
|
151
|
+
private readonly saveConfiguredShell;
|
|
110
152
|
constructor(options: {
|
|
111
153
|
workspaceFor: (workspaceId: string) => Workspace | undefined;
|
|
112
154
|
isTrustedPeer: (remoteAddress: string | undefined) => boolean;
|
|
155
|
+
configuredShell?: () => {
|
|
156
|
+
file: string;
|
|
157
|
+
args: string[];
|
|
158
|
+
} | undefined;
|
|
159
|
+
saveConfiguredShell?: (shell: {
|
|
160
|
+
file: string;
|
|
161
|
+
args: string[];
|
|
162
|
+
} | undefined) => Promise<void>;
|
|
113
163
|
enabled?: boolean;
|
|
114
164
|
});
|
|
115
165
|
readonly enabled: boolean;
|
|
166
|
+
setShell(command: string | null, remoteAddress: string | undefined): Promise<boolean>;
|
|
116
167
|
/**
|
|
117
168
|
* WHY THE PEER CHECK LIVES ON EVERY COMMAND AND NOT ONLY ON `open`.
|
|
118
169
|
*
|