@evandu/super-pi 0.1.103 → 0.1.105
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 +6 -6
- package/dist/client/assets/{index-DDkbPYLz.css → index-UiqTcZca.css} +1 -1
- package/dist/client/assets/index-tBfBUY_8.js +74 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/catalog-service.d.ts +9 -0
- package/dist/server/application/catalog-service.js +11 -0
- package/dist/server/application/conversation-service.d.ts +9 -0
- package/dist/server/application/conversation-service.js +13 -0
- package/dist/server/application/provider-service.d.ts +63 -0
- package/dist/server/application/provider-service.js +136 -0
- package/dist/server/command-handler.d.ts +16 -1
- package/dist/server/command-handler.js +47 -3
- package/dist/server/index.js +13 -1
- package/dist/server/pi-session-events.d.ts +34 -1
- package/dist/server/pi-session-events.js +78 -20
- package/dist/server/pi-session-pool.d.ts +19 -1
- package/dist/server/pi-session-pool.js +25 -3
- package/dist/server/pi-session.d.ts +39 -1
- package/dist/server/pi-session.js +37 -2
- package/dist/server/providers.d.ts +160 -0
- package/dist/server/providers.js +396 -0
- package/dist/server/runtime.d.ts +65 -7
- package/dist/server/runtime.js +76 -13
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/types.d.ts +72 -0
- package/package.json +6 -1
- package/dist/client/assets/index-mAu39Bra.js +0 -74
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-tBfBUY_8.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-UiqTcZca.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -37,6 +37,15 @@ export declare class CatalogService {
|
|
|
37
37
|
* Broadcasts the model catalog to connected clients.
|
|
38
38
|
*/
|
|
39
39
|
broadcastCatalog(selection?: ModelSelection, conversationId?: string): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Broadcasts the UNFILTERED catalog, for the settings window's model panel.
|
|
42
|
+
*
|
|
43
|
+
* A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
|
|
44
|
+
* `model.catalog.all` a separate event in the first place: a `model.catalog` without a
|
|
45
|
+
* conversation is the unfiltered list, and a client that received it as its conversation's list
|
|
46
|
+
* would be offered the models that workspace's rules forbid. The panel gets its own event.
|
|
47
|
+
*/
|
|
48
|
+
broadcastAllCatalog(): Promise<void>;
|
|
40
49
|
setModelRules(rules: import("../types.js").ModelFilterRule[]): Promise<void>;
|
|
41
50
|
/**
|
|
42
51
|
* Reads one catalog filtered by mode at source.
|
|
@@ -90,6 +90,17 @@ export class CatalogService {
|
|
|
90
90
|
const catalog = await this.getCatalog(selection, conversationId);
|
|
91
91
|
this.broadcaster.broadcast({ type: "model.catalog", catalog, conversationId });
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Broadcasts the UNFILTERED catalog, for the settings window's model panel.
|
|
95
|
+
*
|
|
96
|
+
* A SEPARATE EVENT FROM `model.catalog`, and the reason is the same one that made
|
|
97
|
+
* `model.catalog.all` a separate event in the first place: a `model.catalog` without a
|
|
98
|
+
* conversation is the unfiltered list, and a client that received it as its conversation's list
|
|
99
|
+
* would be offered the models that workspace's rules forbid. The panel gets its own event.
|
|
100
|
+
*/
|
|
101
|
+
async broadcastAllCatalog() {
|
|
102
|
+
this.broadcaster.broadcast({ type: "model.catalog.all", catalog: await this.getAllCatalog() });
|
|
103
|
+
}
|
|
93
104
|
async setModelRules(rules) {
|
|
94
105
|
if (!this.store)
|
|
95
106
|
throw new Error("Store unavailable");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Store } from "../store.js";
|
|
2
2
|
import type { AgentRuntime } from "../runtime.js";
|
|
3
3
|
import type { SubagentRunRecorder } from "../subagent-runs.js";
|
|
4
|
+
import type { ModelSelection } from "../types.js";
|
|
4
5
|
import type { EventBroadcaster } from "../server-context.js";
|
|
5
6
|
import type { CatalogService } from "./catalog-service.js";
|
|
6
7
|
import type { RunService } from "./run-service.js";
|
|
@@ -45,6 +46,14 @@ export declare class ConversationService {
|
|
|
45
46
|
* a log and in a retry.
|
|
46
47
|
*/
|
|
47
48
|
autoRenameConversation(conversationId: string): Promise<string>;
|
|
49
|
+
/**
|
|
50
|
+
* The model a conversation has selected, or undefined when it follows Pi's default.
|
|
51
|
+
*
|
|
52
|
+
* THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
|
|
53
|
+
* itself, so the levels of the model in force are the levels that conversation gets. A catalog read
|
|
54
|
+
* without this is a catalog of whatever Pi would default to.
|
|
55
|
+
*/
|
|
56
|
+
modelSelectionFor(conversationId: string | undefined): ModelSelection | undefined;
|
|
48
57
|
/**
|
|
49
58
|
* Selects a conversation and broadcasts the catalog for that conversation's model.
|
|
50
59
|
*/
|
|
@@ -107,6 +107,19 @@ export class ConversationService {
|
|
|
107
107
|
throw new Error("当前没有可用的 Pi 进程,无法生成标题");
|
|
108
108
|
return this.runtime.summarizeConversation(conversation, workspace.path);
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* The model a conversation has selected, or undefined when it follows Pi's default.
|
|
112
|
+
*
|
|
113
|
+
* THIS IS WHAT THE CATALOG'S THINKING LEVELS DEPEND ON. Pi computes the levels from the model
|
|
114
|
+
* itself, so the levels of the model in force are the levels that conversation gets. A catalog read
|
|
115
|
+
* without this is a catalog of whatever Pi would default to.
|
|
116
|
+
*/
|
|
117
|
+
modelSelectionFor(conversationId) {
|
|
118
|
+
if (!conversationId)
|
|
119
|
+
return undefined;
|
|
120
|
+
const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
|
|
121
|
+
return conversation?.model;
|
|
122
|
+
}
|
|
110
123
|
/**
|
|
111
124
|
* Selects a conversation and broadcasts the catalog for that conversation's model.
|
|
112
125
|
*/
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ProviderRegistry } from "../providers.js";
|
|
2
|
+
import type { AgentRuntime } from "../runtime.js";
|
|
3
|
+
import type { Store } from "../store.js";
|
|
4
|
+
import type { PublicProvider } from "../types.js";
|
|
5
|
+
import type { EventBroadcaster } from "../server-context.js";
|
|
6
|
+
import type { CatalogService } from "./catalog-service.js";
|
|
7
|
+
/**
|
|
8
|
+
* THE PROVIDERS A PERSON ADDED, as a service.
|
|
9
|
+
*
|
|
10
|
+
* The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
|
|
11
|
+
* when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
|
|
12
|
+
* the same four steps, in this order:
|
|
13
|
+
*
|
|
14
|
+
* 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
|
|
15
|
+
* `auth.json`, merged into whatever is already there.
|
|
16
|
+
* 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
|
|
17
|
+
* that a failure to write them leaves Super Pi claiming nothing it has not done.
|
|
18
|
+
* 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
|
|
19
|
+
* the process and the runtime's has a ten-minute TTL - so a provider added now would not
|
|
20
|
+
* appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
|
|
21
|
+
* model the person just added is exactly the "decorative control" this project refuses to ship.
|
|
22
|
+
* 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
|
|
23
|
+
* session was built, and that runtime does not know a provider added a minute ago. Pi's
|
|
24
|
+
* refusal to switch to an unknown model is swallowed by the session pool into a warning - the
|
|
25
|
+
* turn then runs on the OLD model while the picker says the new one. So the sessions are made
|
|
26
|
+
* stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
|
|
27
|
+
* session away costs nothing.
|
|
28
|
+
*/
|
|
29
|
+
export declare class ProviderService {
|
|
30
|
+
private readonly registry;
|
|
31
|
+
private readonly runtime;
|
|
32
|
+
private readonly broadcaster;
|
|
33
|
+
private readonly catalogService;
|
|
34
|
+
private readonly store;
|
|
35
|
+
constructor(registry: ProviderRegistry, runtime: AgentRuntime, broadcaster: EventBroadcaster, catalogService: CatalogService, store: Store);
|
|
36
|
+
/** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
|
|
37
|
+
list(): Promise<PublicProvider[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Adds a provider, or adds a model to one, or updates one.
|
|
40
|
+
*
|
|
41
|
+
* ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
|
|
42
|
+
* saving a second model under the same provider should not have to retype the API address. The
|
|
43
|
+
* merge is by id: the provider's fields are replaced by what the form said, and the model with
|
|
44
|
+
* the same id is replaced while the provider's other models stay.
|
|
45
|
+
*
|
|
46
|
+
* THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
|
|
47
|
+
* one", not "remove the key": the key is never shown again once it is stored, so an empty field
|
|
48
|
+
* is the normal state of a form for a provider that already has one. `clearKey` is how a person
|
|
49
|
+
* asks for it to go.
|
|
50
|
+
*/
|
|
51
|
+
set(input: unknown): Promise<PublicProvider[]>;
|
|
52
|
+
/** Removes a provider Super Pi manages, and only one it manages. */
|
|
53
|
+
remove(providerId: string): Promise<PublicProvider[]>;
|
|
54
|
+
/**
|
|
55
|
+
* What has to happen after the files are written, for the change to be REAL rather than pending.
|
|
56
|
+
*
|
|
57
|
+
* The catalog is re-read for every live conversation - the same loop the model rules use - rather
|
|
58
|
+
* than broadcast once without a conversation, because a catalog without a conversation is the
|
|
59
|
+
* UNFILTERED one, and handing that to a conversation that has model rules would offer it models
|
|
60
|
+
* those rules forbid.
|
|
61
|
+
*/
|
|
62
|
+
private afterChange;
|
|
63
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { KNOWN_APIS, publicProviders, validateProvider } from "../providers.js";
|
|
2
|
+
/**
|
|
3
|
+
* THE PROVIDERS A PERSON ADDED, as a service.
|
|
4
|
+
*
|
|
5
|
+
* The service owns the sequence, and the sequence is the whole point: a provider is not "saved"
|
|
6
|
+
* when it is in Super Pi's registry, it is saved when Pi can use it. So every change goes through
|
|
7
|
+
* the same four steps, in this order:
|
|
8
|
+
*
|
|
9
|
+
* 1. WRITE PI'S FILES. The definitions go into Pi's `models.json` and the key into Pi's
|
|
10
|
+
* `auth.json`, merged into whatever is already there.
|
|
11
|
+
* 2. WRITE SUPER PI'S REGISTRY. It is the truth source, and it is written AFTER Pi's files so
|
|
12
|
+
* that a failure to write them leaves Super Pi claiming nothing it has not done.
|
|
13
|
+
* 3. INVALIDATE THE CATALOG. Pi's model list is cached - the SDK's is remembered for the life of
|
|
14
|
+
* the process and the runtime's has a ten-minute TTL - so a provider added now would not
|
|
15
|
+
* appear in the picker until the cache expired. Ten minutes of a picker that cannot see the
|
|
16
|
+
* model the person just added is exactly the "decorative control" this project refuses to ship.
|
|
17
|
+
* 4. INVALIDATE THE LIVE SESSIONS. A session holds its own `ModelRuntime`, created when the
|
|
18
|
+
* session was built, and that runtime does not know a provider added a minute ago. Pi's
|
|
19
|
+
* refusal to switch to an unknown model is swallowed by the session pool into a warning - the
|
|
20
|
+
* turn then runs on the OLD model while the picker says the new one. So the sessions are made
|
|
21
|
+
* stale instead, and each one is rebuilt at its next turn, which is a moment when throwing the
|
|
22
|
+
* session away costs nothing.
|
|
23
|
+
*/
|
|
24
|
+
export class ProviderService {
|
|
25
|
+
registry;
|
|
26
|
+
runtime;
|
|
27
|
+
broadcaster;
|
|
28
|
+
catalogService;
|
|
29
|
+
store;
|
|
30
|
+
constructor(registry, runtime, broadcaster, catalogService, store) {
|
|
31
|
+
this.registry = registry;
|
|
32
|
+
this.runtime = runtime;
|
|
33
|
+
this.broadcaster = broadcaster;
|
|
34
|
+
this.catalogService = catalogService;
|
|
35
|
+
this.store = store;
|
|
36
|
+
}
|
|
37
|
+
/** The providers Super Pi manages, as the browser may see them: no keys, only the fact of one. */
|
|
38
|
+
async list() {
|
|
39
|
+
return publicProviders(await this.registry.load());
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Adds a provider, or adds a model to one, or updates one.
|
|
43
|
+
*
|
|
44
|
+
* ONE CALL, THREE THINGS, because the form collects a provider and a model together and a person
|
|
45
|
+
* saving a second model under the same provider should not have to retype the API address. The
|
|
46
|
+
* merge is by id: the provider's fields are replaced by what the form said, and the model with
|
|
47
|
+
* the same id is replaced while the provider's other models stay.
|
|
48
|
+
*
|
|
49
|
+
* THE KEY IS LEFT ALONE UNLESS THE FORM SAYS OTHERWISE. An empty key field means "I did not type
|
|
50
|
+
* one", not "remove the key": the key is never shown again once it is stored, so an empty field
|
|
51
|
+
* is the normal state of a form for a provider that already has one. `clearKey` is how a person
|
|
52
|
+
* asks for it to go.
|
|
53
|
+
*/
|
|
54
|
+
async set(input) {
|
|
55
|
+
const requested = validateProvider(input);
|
|
56
|
+
const raw = (input ?? {});
|
|
57
|
+
const previous = await this.registry.load();
|
|
58
|
+
const existing = previous.find((provider) => provider.id === requested.id);
|
|
59
|
+
const apiKey = requested.apiKey
|
|
60
|
+
? requested.apiKey
|
|
61
|
+
: (raw.clearKey === true ? undefined : existing?.apiKey);
|
|
62
|
+
// A MODEL WITH THE SAME ID IS REPLACED WHERE IT STANDS, not moved to the end: the list a person
|
|
63
|
+
// sees in the panel is the list they added, and re-ordering it because they edited its first
|
|
64
|
+
// entry would be a change nobody asked for. A model with a NEW id is appended.
|
|
65
|
+
const incoming = requested.models[0];
|
|
66
|
+
const models = !existing
|
|
67
|
+
? requested.models
|
|
68
|
+
: (existing.models.some((model) => model.id === incoming.id)
|
|
69
|
+
? existing.models.map((model) => (model.id === incoming.id ? incoming : model))
|
|
70
|
+
: [...existing.models, incoming]);
|
|
71
|
+
const merged = {
|
|
72
|
+
id: requested.id,
|
|
73
|
+
...(requested.name ? { name: requested.name } : {}),
|
|
74
|
+
baseUrl: requested.baseUrl,
|
|
75
|
+
api: requested.api,
|
|
76
|
+
...(apiKey ? { apiKey } : {}),
|
|
77
|
+
models,
|
|
78
|
+
};
|
|
79
|
+
const next = existing
|
|
80
|
+
? [...previous.filter((provider) => provider.id !== requested.id), merged]
|
|
81
|
+
: [...previous, merged];
|
|
82
|
+
await this.registry.applyToPi(previous, next);
|
|
83
|
+
try {
|
|
84
|
+
await this.registry.save(next);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// The registry is the truth source, so a failure to write it means Super Pi no longer knows
|
|
88
|
+
// what it put into Pi's files. Pi's files are put back the way they were, and the failure is
|
|
89
|
+
// reported rather than swallowed: a registry that disagrees with Pi's files is a state the
|
|
90
|
+
// next write would trip over.
|
|
91
|
+
await this.registry.applyToPi(next, previous).catch(() => undefined);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
await this.afterChange();
|
|
95
|
+
return publicProviders(next);
|
|
96
|
+
}
|
|
97
|
+
/** Removes a provider Super Pi manages, and only one it manages. */
|
|
98
|
+
async remove(providerId) {
|
|
99
|
+
const previous = await this.registry.load();
|
|
100
|
+
const existing = previous.find((provider) => provider.id === providerId);
|
|
101
|
+
if (!existing)
|
|
102
|
+
throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
|
|
103
|
+
const next = previous.filter((provider) => provider.id !== providerId);
|
|
104
|
+
await this.registry.applyToPi(previous, next);
|
|
105
|
+
await this.registry.save(next);
|
|
106
|
+
await this.afterChange();
|
|
107
|
+
return publicProviders(next);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* What has to happen after the files are written, for the change to be REAL rather than pending.
|
|
111
|
+
*
|
|
112
|
+
* The catalog is re-read for every live conversation - the same loop the model rules use - rather
|
|
113
|
+
* than broadcast once without a conversation, because a catalog without a conversation is the
|
|
114
|
+
* UNFILTERED one, and handing that to a conversation that has model rules would offer it models
|
|
115
|
+
* those rules forbid.
|
|
116
|
+
*/
|
|
117
|
+
async afterChange() {
|
|
118
|
+
this.runtime.invalidateCatalog?.();
|
|
119
|
+
this.runtime.bumpModelEpoch?.();
|
|
120
|
+
for (const conversation of this.store.snapshot().conversations) {
|
|
121
|
+
if (conversation.archivedAt)
|
|
122
|
+
continue;
|
|
123
|
+
try {
|
|
124
|
+
await this.catalogService.broadcastCatalog(conversation.model, conversation.id);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.warn(`供应商已保存,刷新会话 ${conversation.id} 的模型目录失败:${String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// The settings panel's OWN list, which is unfiltered and therefore has to be asked for
|
|
131
|
+
// separately. Without this the person who just added a provider would not see their own model
|
|
132
|
+
// in the default-model dropdown until they closed the window and opened it again.
|
|
133
|
+
await this.catalogService.broadcastAllCatalog?.();
|
|
134
|
+
this.broadcaster.broadcast({ type: "providers.list", providers: await this.list(), apis: [...KNOWN_APIS] });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { ConversationService } from "./application/conversation-service.js"
|
|
|
4
4
|
import type { RunService } from "./application/run-service.js";
|
|
5
5
|
import type { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
6
6
|
import type { CatalogService } from "./application/catalog-service.js";
|
|
7
|
+
import type { ProviderService } from "./application/provider-service.js";
|
|
7
8
|
import type { TerminalService } from "./terminal.js";
|
|
8
9
|
export type SocketSender = (event: ServerEvent) => void;
|
|
9
10
|
/**
|
|
@@ -17,6 +18,13 @@ export declare class CommandHandler {
|
|
|
17
18
|
private readonly catalogService;
|
|
18
19
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
19
20
|
private readonly rootDir?;
|
|
21
|
+
/**
|
|
22
|
+
* The providers a person added through the settings window.
|
|
23
|
+
*
|
|
24
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
25
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
26
|
+
*/
|
|
27
|
+
private readonly providerService?;
|
|
20
28
|
/**
|
|
21
29
|
* The built-in terminal service.
|
|
22
30
|
*
|
|
@@ -25,7 +33,14 @@ export declare class CommandHandler {
|
|
|
25
33
|
private readonly terminalService?;
|
|
26
34
|
constructor(workspaceService: WorkspaceService, conversationService: ConversationService, runService: RunService, extensionUiService: ExtensionUiService, catalogService: CatalogService, terminalService?: TerminalService,
|
|
27
35
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
28
|
-
rootDir?: string | undefined
|
|
36
|
+
rootDir?: string | undefined,
|
|
37
|
+
/**
|
|
38
|
+
* The providers a person added through the settings window.
|
|
39
|
+
*
|
|
40
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
41
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
42
|
+
*/
|
|
43
|
+
providerService?: ProviderService | undefined);
|
|
29
44
|
/**
|
|
30
45
|
* Processes a client command.
|
|
31
46
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { KNOWN_APIS } from "./providers.js";
|
|
3
4
|
import { packageUpdateArgs } from "./update.js";
|
|
4
5
|
/**
|
|
5
6
|
* Routes client commands to appropriate application services and converts results or errors.
|
|
@@ -11,6 +12,7 @@ export class CommandHandler {
|
|
|
11
12
|
extensionUiService;
|
|
12
13
|
catalogService;
|
|
13
14
|
rootDir;
|
|
15
|
+
providerService;
|
|
14
16
|
/**
|
|
15
17
|
* The built-in terminal service.
|
|
16
18
|
*
|
|
@@ -19,13 +21,21 @@ export class CommandHandler {
|
|
|
19
21
|
terminalService;
|
|
20
22
|
constructor(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService,
|
|
21
23
|
/** Path to the Super Pi repository root, used to spawn the restart script. */
|
|
22
|
-
rootDir
|
|
24
|
+
rootDir,
|
|
25
|
+
/**
|
|
26
|
+
* The providers a person added through the settings window.
|
|
27
|
+
*
|
|
28
|
+
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
29
|
+
* and a missing service has to say so rather than take the whole handler down.
|
|
30
|
+
*/
|
|
31
|
+
providerService) {
|
|
23
32
|
this.workspaceService = workspaceService;
|
|
24
33
|
this.conversationService = conversationService;
|
|
25
34
|
this.runService = runService;
|
|
26
35
|
this.extensionUiService = extensionUiService;
|
|
27
36
|
this.catalogService = catalogService;
|
|
28
37
|
this.rootDir = rootDir;
|
|
38
|
+
this.providerService = providerService;
|
|
29
39
|
this.terminalService = terminalService;
|
|
30
40
|
}
|
|
31
41
|
/**
|
|
@@ -69,6 +79,29 @@ export class CommandHandler {
|
|
|
69
79
|
await this.catalogService.setDefaultModel(command.model);
|
|
70
80
|
return;
|
|
71
81
|
}
|
|
82
|
+
// THE PROVIDERS A PERSON ADDED. The list is answered to the ASKING socket only - it is a panel's
|
|
83
|
+
// private list, and broadcasting it would put a provider's API address in every other tab's state
|
|
84
|
+
// for no gain. The set and the remove broadcast the new list themselves, because everybody who
|
|
85
|
+
// has the settings window open has to see the change.
|
|
86
|
+
if (command.type === "providers.set") {
|
|
87
|
+
if (!this.providerService)
|
|
88
|
+
throw new Error("供应商功能不可用");
|
|
89
|
+
await this.providerService.set(command.provider);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (command.type === "providers.remove") {
|
|
93
|
+
if (!this.providerService)
|
|
94
|
+
throw new Error("供应商功能不可用");
|
|
95
|
+
await this.providerService.remove(command.providerId);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (command.type === "providers.fetch") {
|
|
99
|
+
if (!this.providerService)
|
|
100
|
+
throw new Error("供应商功能不可用");
|
|
101
|
+
if (sendToSocket)
|
|
102
|
+
sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
72
105
|
if (command.type === "workspace.create") {
|
|
73
106
|
return await this.workspaceService.createWorkspace(command.name, command.path);
|
|
74
107
|
}
|
|
@@ -237,10 +270,21 @@ export class CommandHandler {
|
|
|
237
270
|
return;
|
|
238
271
|
}
|
|
239
272
|
if (command.type === "catalog.fetch") {
|
|
273
|
+
// THE CATALOG OF THE CONVERSATION BEING SHOWN, not of whatever Pi would default to.
|
|
274
|
+
//
|
|
275
|
+
// The THINKING LEVELS belong to the model in force: Pi computes them from the model itself, and a
|
|
276
|
+
// model that cannot reason offers only "off". This command used to ask for the catalog with NO
|
|
277
|
+
// selection, so the levels were always the DEFAULT model's - which means the picker offered the
|
|
278
|
+
// levels of a model the person was not using, and hid the ones they were. The model list is the
|
|
279
|
+
// same either way, so only the levels were wrong; the levels are the part a person reads.
|
|
280
|
+
//
|
|
281
|
+
// `conversation.select` already asked for it this way; this is the same answer, for the other
|
|
282
|
+
// way a client asks for it.
|
|
283
|
+
const selection = this.conversationService.modelSelectionFor(command.conversationId);
|
|
240
284
|
if (sendToSocket)
|
|
241
|
-
sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(
|
|
285
|
+
sendToSocket({ type: "model.catalog", conversationId: command.conversationId, catalog: await this.catalogService.getCatalog(selection, command.conversationId) });
|
|
242
286
|
else
|
|
243
|
-
await this.catalogService.broadcastCatalog(
|
|
287
|
+
await this.catalogService.broadcastCatalog(selection, command.conversationId);
|
|
244
288
|
return;
|
|
245
289
|
}
|
|
246
290
|
// The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
|
package/dist/server/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { SubagentRunRecorder } from "./subagent-runs.js";
|
|
|
16
16
|
import { WorkflowEngine } from "./workflow-engine.js";
|
|
17
17
|
import { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
18
18
|
import { CatalogService } from "./application/catalog-service.js";
|
|
19
|
+
import { ProviderService } from "./application/provider-service.js";
|
|
20
|
+
import { ProviderRegistry } from "./providers.js";
|
|
19
21
|
import { RunService } from "./application/run-service.js";
|
|
20
22
|
import { ConversationService } from "./application/conversation-service.js";
|
|
21
23
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
@@ -178,6 +180,15 @@ const onDirsChanged = (dirs) => {
|
|
|
178
180
|
workspaceService.setAgentsDir(dirs);
|
|
179
181
|
};
|
|
180
182
|
const catalogService = new CatalogService(runtime, broadcaster, initialAgentDirs, store, rootDir, onDirsChanged);
|
|
183
|
+
/**
|
|
184
|
+
* The providers a person added through the settings window.
|
|
185
|
+
*
|
|
186
|
+
* The registry lives in Super Pi's own data directory and holds the API keys; Pi's `models.json` and
|
|
187
|
+
* `auth.json` are written from it, so a provider added here is a provider Pi can use - in a session,
|
|
188
|
+
* in a spawned Pi, and in the terminal.
|
|
189
|
+
*/
|
|
190
|
+
const providerRegistry = new ProviderRegistry(dataDir);
|
|
191
|
+
const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
|
|
181
192
|
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
|
|
182
193
|
runService.setMemory(memory);
|
|
183
194
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
@@ -216,6 +227,7 @@ const serverContext = {
|
|
|
216
227
|
runService,
|
|
217
228
|
extensionUiService,
|
|
218
229
|
catalogService,
|
|
230
|
+
providerService,
|
|
219
231
|
execService,
|
|
220
232
|
toolRegistry,
|
|
221
233
|
toolRunner,
|
|
@@ -223,7 +235,7 @@ const serverContext = {
|
|
|
223
235
|
terminalEnabled: () => terminalService.enabled,
|
|
224
236
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
225
237
|
};
|
|
226
|
-
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir);
|
|
238
|
+
const commandHandler = new CommandHandler(workspaceService, conversationService, runService, extensionUiService, catalogService, terminalService, rootDir, providerService);
|
|
227
239
|
// ------------------------------------------------------------- Lifecycle & shutdown
|
|
228
240
|
let shutdownPromise;
|
|
229
241
|
async function closeHttpServer() {
|
|
@@ -73,6 +73,37 @@ export interface TurnEventHandlers {
|
|
|
73
73
|
followUp: string[];
|
|
74
74
|
}): void;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* The failure Pi reported in the message that ended the turn, if it reported one.
|
|
78
|
+
*
|
|
79
|
+
* WHY IT IS HELD AND NOT THROWN ON THE SPOT. Pi retries on its own: an assistant message that ends
|
|
80
|
+
* with `stopReason: "error"` may be followed by another assistant message, and only the LAST one
|
|
81
|
+
* decides whether the turn failed. So the failure is remembered when it arrives and raised when Pi
|
|
82
|
+
* SETTLES - which is the moment Pi has stopped working on its own and the answer is final. A failure
|
|
83
|
+
* raised at the moment it arrives would abort a turn Pi was about to retry, and the retry would then
|
|
84
|
+
* look like the user's problem.
|
|
85
|
+
*
|
|
86
|
+
* THE SAME RULES AS THE ONE-SHOT PATH, which already got this right (`runtime.ts`: the RPC drain loop
|
|
87
|
+
* reads the terminal outcome off `agent_end` and throws at `agent_settled`). A provider failure is a
|
|
88
|
+
* failure; a response that hit the length limit is an INCOMPLETE answer and is one too. An `"aborted"`
|
|
89
|
+
* is NOT a failure here: the person stopped the turn, and a stopped turn is a turn that ended - the
|
|
90
|
+
* one-shot path can tell the difference because it knows whether the signal was aborted, and the
|
|
91
|
+
* session path does not need to: an abort is the user's own action and the run service already
|
|
92
|
+
* records it as stopped.
|
|
93
|
+
*/
|
|
94
|
+
export interface TurnFailure {
|
|
95
|
+
/** The message Pi reported, in its own words. */
|
|
96
|
+
message: string;
|
|
97
|
+
/** The raw reason, kept so a caller can tell a provider failure from anything else. */
|
|
98
|
+
stopReason: string;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
|
|
102
|
+
*
|
|
103
|
+
* ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
|
|
104
|
+
* custom messages, and none of those is a turn failing.
|
|
105
|
+
*/
|
|
106
|
+
export declare function describeMessageFailure(record: unknown): TurnFailure | undefined;
|
|
76
107
|
/**
|
|
77
108
|
* A queue of turn items, with the waiters that are blocked on it.
|
|
78
109
|
*
|
|
@@ -108,4 +139,6 @@ export declare class TurnQueue {
|
|
|
108
139
|
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
109
140
|
* understands is worth saying out loud exactly once.
|
|
110
141
|
*/
|
|
111
|
-
export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void
|
|
142
|
+
export declare function handleSessionEvent(event: unknown, queue: TurnQueue, handlers: TurnEventHandlers, report: (key: string) => void, failure?: {
|
|
143
|
+
current: TurnFailure | undefined;
|
|
144
|
+
}): boolean;
|
|
@@ -62,6 +62,31 @@ export function describeDeliveredUserMessage(record) {
|
|
|
62
62
|
.filter((part) => part !== undefined);
|
|
63
63
|
return parts.length ? parts.join("\n") : undefined;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Reads the failure out of a `message_end` record, or undefined when it is not a failed one.
|
|
67
|
+
*
|
|
68
|
+
* ONLY THE ASSISTANT'S MESSAGE COUNTS. `message_end` also fires for user messages, tool results and
|
|
69
|
+
* custom messages, and none of those is a turn failing.
|
|
70
|
+
*/
|
|
71
|
+
export function describeMessageFailure(record) {
|
|
72
|
+
const event = record;
|
|
73
|
+
if (event?.type !== "message_end")
|
|
74
|
+
return undefined;
|
|
75
|
+
const message = event.message;
|
|
76
|
+
if (!message || typeof message !== "object" || message.role !== "assistant")
|
|
77
|
+
return undefined;
|
|
78
|
+
const text = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
79
|
+
if (message.stopReason === "error") {
|
|
80
|
+
return { message: text || "Pi 报告模型或服务提供方错误。", stopReason: "error" };
|
|
81
|
+
}
|
|
82
|
+
if (message.stopReason === "length") {
|
|
83
|
+
// A response that hit the length limit is an INCOMPLETE answer. Pi compacts and continues after a
|
|
84
|
+
// recoverable one, so a turn that SETTLES with this reason is one whose answer is missing its
|
|
85
|
+
// end - and saying so is better than a turn that looks finished.
|
|
86
|
+
return { message: text || "模型输出达到本轮长度上限,回答未完成。", stopReason: "length" };
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
65
90
|
/**
|
|
66
91
|
* A queue of turn items, with the waiters that are blocked on it.
|
|
67
92
|
*
|
|
@@ -149,11 +174,53 @@ export class TurnQueue {
|
|
|
149
174
|
* then counted - the same policy the RPC loop applied, and for the same reason: an event type nobody
|
|
150
175
|
* understands is worth saying out loud exactly once.
|
|
151
176
|
*/
|
|
152
|
-
export function handleSessionEvent(event, queue, handlers, report) {
|
|
177
|
+
export function handleSessionEvent(event, queue, handlers, report, failure) {
|
|
153
178
|
const record = event;
|
|
154
179
|
if (!record || typeof record.type !== "string")
|
|
155
180
|
return false;
|
|
156
181
|
switch (record.type) {
|
|
182
|
+
case "message_end": {
|
|
183
|
+
// THE AUTHORITATIVE END OF AN ASSISTANT MESSAGE. `message_end` is the completed message, and its
|
|
184
|
+
// `stopReason` is the only place Pi says whether the model actually answered. The bridge used to
|
|
185
|
+
// ignore this event, which meant a provider that failed was indistinguishable from one that
|
|
186
|
+
// finished: the turn settled, the status became "completed", and the reader saw a turn that
|
|
187
|
+
// looked fine and had no answer in it.
|
|
188
|
+
//
|
|
189
|
+
// A failure is HELD, not thrown, because Pi retries on its own - the message that failed may be
|
|
190
|
+
// followed by another one. The retry starts a new assistant message, and THAT clears the held
|
|
191
|
+
// failure, so only the last message of the turn decides.
|
|
192
|
+
const reported = describeMessageFailure(record);
|
|
193
|
+
if (reported && failure)
|
|
194
|
+
failure.current = reported;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
case "message_start": {
|
|
198
|
+
// A NEW ASSISTANT MESSAGE MEANS A NEW ATTEMPT. Pi's retry starts one, and the failure of the
|
|
199
|
+
// attempt before it is no longer the failure of this turn.
|
|
200
|
+
if (record.message?.role === "assistant" && failure)
|
|
201
|
+
failure.current = undefined;
|
|
202
|
+
// A STEER THIS TURN DELIVERED. Pi announces the moment a queued message stops waiting and starts
|
|
203
|
+
// being answered by emitting `message_start` with the user message it picked up, immediately after
|
|
204
|
+
// the `queue_update` above that shows the entry gone. The host uses it to split the turn so the
|
|
205
|
+
// answer lands under the message it answers.
|
|
206
|
+
//
|
|
207
|
+
// A NON-user `message_start` is not a delivery: it gets the same once-per-type report every other
|
|
208
|
+
// unrouted event gets, which is the same policy the RPC drain loop applies.
|
|
209
|
+
const delivered = describeDeliveredUserMessage(record);
|
|
210
|
+
if (delivered === undefined) {
|
|
211
|
+
if (record.message?.role === "assistant")
|
|
212
|
+
return true;
|
|
213
|
+
report(`type ${JSON.stringify(record.type)}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
handlers.onSteerDelivered?.(delivered);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
157
224
|
case "message_update": {
|
|
158
225
|
const assistant = record.assistantMessageEvent;
|
|
159
226
|
if (!assistant)
|
|
@@ -240,28 +307,19 @@ export function handleSessionEvent(event, queue, handlers, report) {
|
|
|
240
307
|
}
|
|
241
308
|
return true;
|
|
242
309
|
}
|
|
243
|
-
case "
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// the
|
|
247
|
-
// answer lands under the message it answers.
|
|
310
|
+
case "agent_settled": {
|
|
311
|
+
// THE TURN IS OVER, AND IT MAY HAVE FAILED. Pi settling is the end of the turn, and a turn that
|
|
312
|
+
// ended because the provider failed has to say so: a failure that is not raised is a failed turn
|
|
313
|
+
// that the reader sees as a completed one, with no answer in it and no banner above it.
|
|
248
314
|
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
315
|
+
// The failure is raised HERE and not when it arrived, because Pi retries on its own - a message
|
|
316
|
+
// that failed may be followed by another one, and only the last one decides.
|
|
317
|
+
const held = failure?.current;
|
|
318
|
+
if (held) {
|
|
319
|
+
failure.current = undefined;
|
|
320
|
+
queue.push({ kind: "error", error: new Error(held.message) });
|
|
254
321
|
return true;
|
|
255
322
|
}
|
|
256
|
-
try {
|
|
257
|
-
handlers.onSteerDelivered?.(delivered);
|
|
258
|
-
}
|
|
259
|
-
catch (error) {
|
|
260
|
-
console.warn(`PiRpcRuntime: the steer delivery handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
261
|
-
}
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
case "agent_settled": {
|
|
265
323
|
queue.push({ kind: "end" });
|
|
266
324
|
return true;
|
|
267
325
|
}
|