@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.
@@ -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-gjLqMWYT.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-8olCbCbk.css">
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
- let model = workspace.defaultModel;
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
  *
@@ -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
@@ -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>;
@@ -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
- await this.persist();
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
  /**
@@ -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.100",
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",
@@ -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})。这一条本身就可能解释了前面几段的`);