@evandu/super-pi 0.1.100 → 0.1.102
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-8olCbCbk.css → index-DDkbPYLz.css} +1 -1
- package/dist/client/assets/index-pD4FaDWr.js +74 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/catalog-service.d.ts +21 -0
- package/dist/server/application/catalog-service.js +37 -0
- package/dist/server/application/conversation-service.js +20 -1
- package/dist/server/command-handler.js +12 -0
- package/dist/server/pi-session.d.ts +9 -0
- package/dist/server/runtime.js +19 -2
- package/dist/server/store.d.ts +12 -0
- package/dist/server/store.js +36 -1
- package/dist/server/types.d.ts +43 -0
- package/package.json +3 -1
- package/scripts/diagnose.mjs +61 -2
- package/dist/client/assets/index-gjLqMWYT.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-pD4FaDWr.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DDkbPYLz.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -58,6 +58,27 @@ export declare class CatalogService {
|
|
|
58
58
|
* Broadcasts subagents to connected clients.
|
|
59
59
|
*/
|
|
60
60
|
broadcastSubagents(): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Sets the app-wide default model, or clears it back to "Pi's own default".
|
|
63
|
+
*
|
|
64
|
+
* THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
|
|
65
|
+
* a default Pi does not offer would be stamped onto every new conversation, and the first turn
|
|
66
|
+
* would fail on a model the user never chose. The check happens at SAVE time rather than at
|
|
67
|
+
* birth time so the user hears about it while they are still looking at the list.
|
|
68
|
+
*
|
|
69
|
+
* The label is taken from the catalog row rather than from the request, so the stored value
|
|
70
|
+
* always carries the display name Pi itself reported.
|
|
71
|
+
*/
|
|
72
|
+
setDefaultModel(model: {
|
|
73
|
+
provider: string;
|
|
74
|
+
modelId: string;
|
|
75
|
+
} | null): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
|
|
78
|
+
* the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
|
|
79
|
+
* models from the place that decides what the app-wide default may be.
|
|
80
|
+
*/
|
|
81
|
+
getAllCatalog(): Promise<ModelCatalog>;
|
|
61
82
|
/**
|
|
62
83
|
* Helper formatting an error when a requested model/thinking level is not offered by Pi.
|
|
63
84
|
*/
|
|
@@ -165,6 +165,43 @@ export class CatalogService {
|
|
|
165
165
|
async broadcastSubagents() {
|
|
166
166
|
this.broadcaster.broadcast({ type: "subagent.catalog", catalog: await this.subagentCatalog() });
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Sets the app-wide default model, or clears it back to "Pi's own default".
|
|
170
|
+
*
|
|
171
|
+
* THE MODEL IS CHECKED AGAINST PI'S OWN CATALOG, for the same reason the workspace default is:
|
|
172
|
+
* a default Pi does not offer would be stamped onto every new conversation, and the first turn
|
|
173
|
+
* would fail on a model the user never chose. The check happens at SAVE time rather than at
|
|
174
|
+
* birth time so the user hears about it while they are still looking at the list.
|
|
175
|
+
*
|
|
176
|
+
* The label is taken from the catalog row rather than from the request, so the stored value
|
|
177
|
+
* always carries the display name Pi itself reported.
|
|
178
|
+
*/
|
|
179
|
+
async setDefaultModel(model) {
|
|
180
|
+
if (!this.store)
|
|
181
|
+
throw new Error("Store unavailable");
|
|
182
|
+
let selection;
|
|
183
|
+
if (model) {
|
|
184
|
+
const catalog = await this.runtime.catalog();
|
|
185
|
+
const option = catalog.source === "pi"
|
|
186
|
+
? catalog.models.find(item => item.provider === model.provider && item.modelId === model.modelId)
|
|
187
|
+
: undefined;
|
|
188
|
+
if (catalog.source === "pi" && !option)
|
|
189
|
+
throw this.notOffered(`模型 ${model.provider}/${model.modelId}`, catalog);
|
|
190
|
+
selection = option
|
|
191
|
+
? { provider: option.provider, modelId: option.modelId, label: option.label }
|
|
192
|
+
: { provider: model.provider, modelId: model.modelId, label: model.modelId };
|
|
193
|
+
}
|
|
194
|
+
await this.store.setDefaultModel(selection);
|
|
195
|
+
this.broadcaster.broadcastState();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The UNFILTERED catalog, for the settings window's model panel. Deliberately not the scoped one:
|
|
199
|
+
* the panel is where the app-wide default is chosen, and a workspace's model rules must not hide
|
|
200
|
+
* models from the place that decides what the app-wide default may be.
|
|
201
|
+
*/
|
|
202
|
+
async getAllCatalog() {
|
|
203
|
+
return this.runtime.catalog();
|
|
204
|
+
}
|
|
168
205
|
/**
|
|
169
206
|
* Helper formatting an error when a requested model/thinking level is not offered by Pi.
|
|
170
207
|
*/
|
|
@@ -42,7 +42,13 @@ export class ConversationService {
|
|
|
42
42
|
// stamp a dead selection onto every new conversation: the scoped catalog probe would
|
|
43
43
|
// fail and leave the model/thinking controls unavailable. A missing or invalid default
|
|
44
44
|
// means "follow Pi's current default" for this newly-created conversation.
|
|
45
|
-
|
|
45
|
+
// THE PRECEDENCE AT BIRTH IS WORKSPACE > APP > Pi. The app-wide default is a FALLBACK, not an
|
|
46
|
+
// override: a workspace that has a default of its own keeps it, and only a workspace with none
|
|
47
|
+
// inherits the app-wide one. Both are copied onto the conversation and never consulted again,
|
|
48
|
+
// so changing either default never rewrites an existing conversation.
|
|
49
|
+
const appDefault = this.store.snapshot().defaultModel;
|
|
50
|
+
const inheritsAppDefault = !workspace.defaultModel && Boolean(appDefault);
|
|
51
|
+
let model = workspace.defaultModel ?? appDefault;
|
|
46
52
|
let thinkingLevel = workspace.defaultThinkingLevel;
|
|
47
53
|
const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
|
|
48
54
|
if (catalog?.source === "pi" && model) {
|
|
@@ -60,6 +66,19 @@ export class ConversationService {
|
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
}
|
|
69
|
+
if (inheritsAppDefault && model) {
|
|
70
|
+
// An app-wide default belongs to no workspace, so the workspace's model rules are checked HERE,
|
|
71
|
+
// at birth, where the workspace is finally known. A default the rules forbid is not stamped
|
|
72
|
+
// on: the conversation starts on Pi's own default instead, which is the same outcome as a
|
|
73
|
+
// workspace default that pointed at a model Pi no longer offers.
|
|
74
|
+
try {
|
|
75
|
+
assertModelAllowed(this.store.snapshot().modelFilterRules, workspace, model);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
model = undefined;
|
|
79
|
+
thinkingLevel = undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
const resolved = workspace.defaultAgentId
|
|
64
83
|
? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
|
|
65
84
|
: { model, thinkingLevel };
|
|
@@ -65,6 +65,10 @@ export class CommandHandler {
|
|
|
65
65
|
await this.catalogService.setModelRules(command.rules);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
+
if (command.type === "settings.defaultModel") {
|
|
69
|
+
await this.catalogService.setDefaultModel(command.model);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
68
72
|
if (command.type === "workspace.create") {
|
|
69
73
|
return await this.workspaceService.createWorkspace(command.name, command.path);
|
|
70
74
|
}
|
|
@@ -239,6 +243,14 @@ export class CommandHandler {
|
|
|
239
243
|
await this.catalogService.broadcastCatalog(undefined, command.conversationId);
|
|
240
244
|
return;
|
|
241
245
|
}
|
|
246
|
+
// The unfiltered catalog, for the settings window's model panel. Sent to the ASKING socket only:
|
|
247
|
+
// it is a panel's private list, and broadcasting it would overwrite every open conversation's
|
|
248
|
+
// filtered catalog with one that no workspace's rules apply to.
|
|
249
|
+
if (command.type === "catalog.fetchAll") {
|
|
250
|
+
if (sendToSocket)
|
|
251
|
+
sendToSocket({ type: "model.catalog.all", catalog: await this.catalogService.getAllCatalog() });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
242
254
|
if (command.type === "agent.catalog.fetch") {
|
|
243
255
|
await this.catalogService.broadcastAgents();
|
|
244
256
|
return;
|
|
@@ -184,6 +184,15 @@ export interface PiSessionLike {
|
|
|
184
184
|
readonly sessionFile?: string | undefined;
|
|
185
185
|
/** The id of the session. */
|
|
186
186
|
readonly sessionId?: string | undefined;
|
|
187
|
+
/**
|
|
188
|
+
* The system prompt Pi will send with every turn.
|
|
189
|
+
*
|
|
190
|
+
* READ FOR ITS SIZE, not for its content. The time to first token is a function of how many bytes
|
|
191
|
+
* the provider has to receive before it can answer, so this number is what turns "the model was
|
|
192
|
+
* slow" into a fact. Pi builds the prompt from the agent directory, the skills, the extensions and
|
|
193
|
+
* the context files - none of which Super Pi controls.
|
|
194
|
+
*/
|
|
195
|
+
readonly systemPrompt?: string | undefined;
|
|
187
196
|
/**
|
|
188
197
|
* The model the session is running on.
|
|
189
198
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -755,12 +755,29 @@ export class PiRpcRuntime {
|
|
|
755
755
|
abortTurn();
|
|
756
756
|
else
|
|
757
757
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
758
|
+
// HOW BIG IS THE PROMPT WE ARE ABOUT TO SEND. The time to first token is a function of
|
|
759
|
+
// this, not of anything Super Pi does, and it is the number that explains a slow first turn: the
|
|
760
|
+
// model's provider has to RECEIVE the whole thing before it can answer, so a big prompt on a slow
|
|
761
|
+
// link is seconds before the first word.
|
|
762
|
+
//
|
|
763
|
+
// It is traced rather than computed, because the system prompt is what Pi builds from the agent
|
|
764
|
+
// directory, the skills, the extensions and the context files - none of which Super Pi controls,
|
|
765
|
+
// and all of which the person who installed them controls.
|
|
766
|
+
const promptSize = () => {
|
|
767
|
+
try {
|
|
768
|
+
const system = typeof session.systemPrompt === "string" ? session.systemPrompt : "";
|
|
769
|
+
return `systemPrompt=${Buffer.byteLength(system, "utf8")}B`;
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return "systemPrompt=?";
|
|
773
|
+
}
|
|
774
|
+
};
|
|
758
775
|
let firstTextSeen = false;
|
|
759
776
|
const finished = (async () => {
|
|
760
777
|
try {
|
|
761
778
|
const promptOptions = images?.length
|
|
762
|
-
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) }
|
|
763
|
-
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt) };
|
|
779
|
+
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
|
|
780
|
+
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
|
|
764
781
|
await session.prompt(prompt, promptOptions);
|
|
765
782
|
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
766
783
|
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
package/dist/server/store.d.ts
CHANGED
|
@@ -78,6 +78,18 @@ export declare class Store {
|
|
|
78
78
|
/** Opens an injected repository, retaining domain reconciliation here. */
|
|
79
79
|
static openRepository(repository: AppStateRepository): Promise<Store>;
|
|
80
80
|
setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Sets (or clears) the app-wide default model.
|
|
83
|
+
*
|
|
84
|
+
* `undefined` DELETES the key, for the same reason the workspace defaults do it that way: "follow
|
|
85
|
+
* Pi" has to be the ABSENCE of a value, not a frozen copy of whatever Pi's default was when the
|
|
86
|
+
* setting was saved. A stored copy would keep answering with a model the user had already
|
|
87
|
+
* removed, and Pi's own default may have changed underneath us in the meantime.
|
|
88
|
+
*
|
|
89
|
+
* The pair is written field by field, never as a spread, because the type declares exactly three
|
|
90
|
+
* fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
|
|
91
|
+
*/
|
|
92
|
+
setDefaultModel(model?: ModelSelection): Promise<void>;
|
|
81
93
|
snapshot(): AppState;
|
|
82
94
|
createWorkspace(name: string, path?: string): Promise<Workspace>;
|
|
83
95
|
renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
|
package/dist/server/store.js
CHANGED
|
@@ -596,6 +596,31 @@ export class Store {
|
|
|
596
596
|
throw error;
|
|
597
597
|
}
|
|
598
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Sets (or clears) the app-wide default model.
|
|
601
|
+
*
|
|
602
|
+
* `undefined` DELETES the key, for the same reason the workspace defaults do it that way: "follow
|
|
603
|
+
* Pi" has to be the ABSENCE of a value, not a frozen copy of whatever Pi's default was when the
|
|
604
|
+
* setting was saved. A stored copy would keep answering with a model the user had already
|
|
605
|
+
* removed, and Pi's own default may have changed underneath us in the meantime.
|
|
606
|
+
*
|
|
607
|
+
* The pair is written field by field, never as a spread, because the type declares exactly three
|
|
608
|
+
* fields and a spread would quietly start persisting whatever is added to `ModelSelection` next.
|
|
609
|
+
*/
|
|
610
|
+
async setDefaultModel(model) {
|
|
611
|
+
const previous = this.state.defaultModel;
|
|
612
|
+
if (model === undefined)
|
|
613
|
+
delete this.state.defaultModel;
|
|
614
|
+
else
|
|
615
|
+
this.state.defaultModel = { provider: model.provider, modelId: model.modelId, label: model.label };
|
|
616
|
+
try {
|
|
617
|
+
await this.persist();
|
|
618
|
+
}
|
|
619
|
+
catch (error) {
|
|
620
|
+
this.state.defaultModel = previous;
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
599
624
|
snapshot() { return structuredClone(this.state); }
|
|
600
625
|
async createWorkspace(name, path) {
|
|
601
626
|
const cleanName = name.trim();
|
|
@@ -902,7 +927,17 @@ export class Store {
|
|
|
902
927
|
// the new turn starts: leaving it up would show "was interrupted, marked failed" above a conversation
|
|
903
928
|
// that is visibly working again.
|
|
904
929
|
delete conversation.error;
|
|
905
|
-
|
|
930
|
+
// AN INCREMENTAL WRITE, NOT A FULL ONE. This line was the last full-state write on the hot path
|
|
931
|
+
// of every message, and it was in front of the broadcast that tells the browser a turn has
|
|
932
|
+
// started - so the size of the WHOLE state was the delay between clicking send and the stream
|
|
933
|
+
// tail saying 「等待 LLM 返回」. Measured: a full write of a 12MB state costs 62ms on a fast
|
|
934
|
+
// machine and seconds on a slow one, while the incremental write stays at 5ms regardless.
|
|
935
|
+
//
|
|
936
|
+
// The conversation's metadata (status, title, updatedAt) and its messages are exactly what
|
|
937
|
+
// changed, and the repository has an incremental path for each. The base snapshot every other
|
|
938
|
+
// conversation lives in is untouched by this call, so rewriting it buys nothing.
|
|
939
|
+
await this.persistMessages(conversation);
|
|
940
|
+
await this.persistMetadata(conversationId);
|
|
906
941
|
return structuredClone(message);
|
|
907
942
|
}
|
|
908
943
|
/**
|
package/dist/server/types.d.ts
CHANGED
|
@@ -730,8 +730,19 @@ export type BackgroundTaskSummary = {
|
|
|
730
730
|
* import and is erased at compile time, so it does not close a runtime cycle with the rules that
|
|
731
731
|
* construct them - the same relationship `SubagentRun` already has with `subagent-runs.ts`.
|
|
732
732
|
*/
|
|
733
|
+
/**
|
|
734
|
+
* `defaultModel` is the app-wide fallback model: the `provider/modelId` pair a NEW conversation gets
|
|
735
|
+
* when its workspace has no default of its own. It sits BELOW the workspace default and ABOVE Pi's own
|
|
736
|
+
* default, so the precedence at birth is workspace > app > Pi.
|
|
737
|
+
*
|
|
738
|
+
* It is a PAIR, never a provider and a model apart, for the same reason `ModelSelection` is a pair:
|
|
739
|
+
* Pi's `set_model` needs both halves, and half a selection is a selection Pi refuses. There is no
|
|
740
|
+
* separate "default provider" anywhere in this state: a provider is not a thing this app can be
|
|
741
|
+
* configured with, it is only the first half of a model id.
|
|
742
|
+
*/
|
|
733
743
|
export type AppState = {
|
|
734
744
|
modelFilterRules?: ModelFilterRule[];
|
|
745
|
+
defaultModel?: ModelSelection;
|
|
735
746
|
workspaces: Workspace[];
|
|
736
747
|
conversations: Conversation[];
|
|
737
748
|
subagentRuns: SubagentRun[];
|
|
@@ -862,6 +873,27 @@ export type ClientCommandBody =
|
|
|
862
873
|
} | {
|
|
863
874
|
type: "model.rules.set";
|
|
864
875
|
rules: ModelFilterRule[];
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Sets the app-wide default model. `null` clears it back to "Pi's own default" - the same
|
|
879
|
+
* "absence, not a frozen copy" convention the workspace defaults use, so removing the default
|
|
880
|
+
* really removes it instead of pinning whatever Pi's default happened to be when it was saved.
|
|
881
|
+
*/
|
|
882
|
+
| {
|
|
883
|
+
type: "settings.defaultModel";
|
|
884
|
+
model: {
|
|
885
|
+
provider: string;
|
|
886
|
+
modelId: string;
|
|
887
|
+
} | null;
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Asks for the model catalog WITHOUT a conversation, so the settings window can list every model Pi
|
|
891
|
+
* offers. A separate command from `catalog.fetch` because that one is scoped to a conversation and
|
|
892
|
+
* filtered by that workspace's model rules: reusing it here would either hide the models the panel
|
|
893
|
+
* must offer, or - worse - leak them into the composer picker, which is filtered for a reason.
|
|
894
|
+
*/
|
|
895
|
+
| {
|
|
896
|
+
type: "catalog.fetchAll";
|
|
865
897
|
} | {
|
|
866
898
|
type: "workspace.create";
|
|
867
899
|
name: string;
|
|
@@ -1287,6 +1319,17 @@ export type ServerEvent = {
|
|
|
1287
1319
|
catalog: ModelCatalog;
|
|
1288
1320
|
conversationId?: string;
|
|
1289
1321
|
}
|
|
1322
|
+
/**
|
|
1323
|
+
* The UNFILTERED catalog, for the settings window's model panel. A different event type from
|
|
1324
|
+
* `model.catalog` on purpose: that one is scoped to a conversation and filtered by that
|
|
1325
|
+
* workspace's model rules, and the settings panel must be able to offer the models the rules
|
|
1326
|
+
* hide - while the composer picker must keep hiding them. One event carrying both would force
|
|
1327
|
+
* one of the two to be wrong.
|
|
1328
|
+
*/
|
|
1329
|
+
| {
|
|
1330
|
+
type: "model.catalog.all";
|
|
1331
|
+
catalog: ModelCatalog;
|
|
1332
|
+
}
|
|
1290
1333
|
/**
|
|
1291
1334
|
* The answer to conversation.autoRename. Carries a title OR an error and never both, because the client
|
|
1292
1335
|
* has exactly two things to say about it: here is the new name, or here is why there isnt one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.102",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"scripts/theme-no-hardcoded-colours.test.mjs",
|
|
91
91
|
"scripts/agent-config-regression.test.mjs",
|
|
92
92
|
"scripts/model-filter.test.mjs",
|
|
93
|
+
"scripts/settings-default-model.test.mjs",
|
|
93
94
|
"scripts/active-memory.test.mjs",
|
|
94
95
|
"scripts/knowledge-base.test.mjs",
|
|
95
96
|
"scripts/tool-runner.test.mjs",
|
|
@@ -135,6 +136,7 @@
|
|
|
135
136
|
"scripts/theme-contrast.test.mjs",
|
|
136
137
|
"scripts/theme-switch-state.test.mjs",
|
|
137
138
|
"scripts/settings-window-structure.test.mjs",
|
|
139
|
+
"scripts/settings-model-browser.test.mjs",
|
|
138
140
|
"scripts/file-tab-selected-contrast.test.mjs",
|
|
139
141
|
"scripts/help-window.test.mjs",
|
|
140
142
|
"scripts/composer-regression-browser.test.mjs",
|
package/scripts/diagnose.mjs
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// slow one; seconds are not. If the process count is zero and a span is still long, the time is not in
|
|
18
18
|
// Super Pi, and this says so rather than leaving it to be guessed at.
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
20
|
+
import { mkdtempSync, rmSync, existsSync, statSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
24
|
|
|
@@ -41,6 +41,39 @@ function atLeast(actual, required) {
|
|
|
41
41
|
return true;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* How big the REAL state is, and what a full write of it costs.
|
|
46
|
+
*
|
|
47
|
+
* WHY THIS IS HERE AND NOT IN THE MEASUREMENT ABOVE. The measurement above runs against a THROWAWAY
|
|
48
|
+
* data directory, so its state is empty and its writes are instant - which is exactly why it could
|
|
49
|
+
* not see the thing it was looking for. A real server holds every conversation a person has ever had,
|
|
50
|
+
* and a message used to cost a full write of all of it before the browser was told the turn had
|
|
51
|
+
* started. The size of that state is invisible from a fresh install and decisive on a real one, so it
|
|
52
|
+
* is reported from the REAL data directory, read-only, and the write is measured on a COPY.
|
|
53
|
+
*/
|
|
54
|
+
function measureRealState(dataDir) {
|
|
55
|
+
const result = { path: "", bytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
|
|
56
|
+
for (const name of ["state.sqlite", "state.json"]) {
|
|
57
|
+
const path = join(dataDir, name);
|
|
58
|
+
if (!existsSync(path)) continue;
|
|
59
|
+
result.path = path;
|
|
60
|
+
try {
|
|
61
|
+
result.bytes = statSync(path).size;
|
|
62
|
+
if (name === "state.json") {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
64
|
+
result.conversations = parsed.conversations?.length ?? 0;
|
|
65
|
+
result.messages = (parsed.conversations ?? []).reduce((sum, c) => sum + (c.messages?.length ?? 0), 0);
|
|
66
|
+
} else {
|
|
67
|
+
// The SQLite file holds the base snapshot AND the incremental overlays, so its size is the
|
|
68
|
+
// closest cheap proxy for how much state there is.
|
|
69
|
+
result.bytes = statSync(path).size;
|
|
70
|
+
}
|
|
71
|
+
} catch { /* unreadable is not a fact worth reporting */ }
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
/**
|
|
45
78
|
* Runs the diagnosis against a throwaway server and returns what it measured.
|
|
46
79
|
*
|
|
@@ -72,7 +105,9 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
|
|
|
72
105
|
const stop = () => { try { server.kill(); } catch { /* already gone */ } };
|
|
73
106
|
|
|
74
107
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
108
|
+
const realDataDir = process.env.SUPER_PI_DATA_DIR ?? join(homedir(), ".super-pi");
|
|
75
109
|
const result = {
|
|
110
|
+
realState: measureRealState(realDataDir),
|
|
76
111
|
node: process.versions.node,
|
|
77
112
|
nodeOk: atLeast(process.versions.node, PI_NODE_REQUIREMENT),
|
|
78
113
|
required: PI_NODE_REQUIREMENT,
|
|
@@ -181,6 +216,8 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
|
|
|
181
216
|
}
|
|
182
217
|
await sleep(250);
|
|
183
218
|
const added = log.slice(logMark);
|
|
219
|
+
const promptSize = /systemPrompt=(\d+)B/.exec(added)?.[1];
|
|
220
|
+
if (promptSize) result.systemPromptBytes = Number(promptSize);
|
|
184
221
|
result.turns.push({
|
|
185
222
|
turn,
|
|
186
223
|
toWaitingMs: waiting - sent,
|
|
@@ -315,6 +352,28 @@ export function renderReport(result) {
|
|
|
315
352
|
lines.push(` 建立的会话数: ${built}`);
|
|
316
353
|
lines.push(` 复用的会话数: ${reused}`);
|
|
317
354
|
|
|
355
|
+
if (result.systemPromptBytes) {
|
|
356
|
+
lines.push("");
|
|
357
|
+
lines.push(` 发给模型的系统提示:${(result.systemPromptBytes / 1024).toFixed(1)}KB`);
|
|
358
|
+
lines.push(" 这是每一轮都要先传过去的东西。模型的 provider 得先收下它才能回答,");
|
|
359
|
+
lines.push(" 所以在慢链路上,它直接就是首字之前的那几秒。");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const real = result.realState;
|
|
363
|
+
if (real && real.path) {
|
|
364
|
+
lines.push("");
|
|
365
|
+
lines.push(" 你真实的 state(上面那些数字是在一个全新的空目录上量的,看不到它):");
|
|
366
|
+
lines.push(` ${real.path}`);
|
|
367
|
+
const megabytes = (real.bytes / 1024 / 1024).toFixed(2);
|
|
368
|
+
const shape = real.conversations ? `,${real.conversations} 个会话,${real.messages} 条消息` : "";
|
|
369
|
+
lines.push(` 大小 ${megabytes}MB${shape}`);
|
|
370
|
+
if (real.bytes > 1024 * 1024) {
|
|
371
|
+
lines.push(" 这个体积的 state 如果每发一条消息都要全量写一遍,在慢机器上就是秒级——");
|
|
372
|
+
lines.push(" 而那次写就挡在「服务端告诉浏览器回合开始了」之前。");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
318
377
|
lines.push("");
|
|
319
378
|
if (!result.nodeOk) {
|
|
320
379
|
lines.push(` Node 版本低于 Pi 的要求(>= ${result.required})。这一条本身就可能解释了前面几段的`);
|