@evandu/super-pi 0.1.101 → 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();
@@ -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.101",
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",
@@ -216,6 +216,8 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
216
216
  }
217
217
  await sleep(250);
218
218
  const added = log.slice(logMark);
219
+ const promptSize = /systemPrompt=(\d+)B/.exec(added)?.[1];
220
+ if (promptSize) result.systemPromptBytes = Number(promptSize);
219
221
  result.turns.push({
220
222
  turn,
221
223
  toWaitingMs: waiting - sent,
@@ -350,6 +352,13 @@ export function renderReport(result) {
350
352
  lines.push(` 建立的会话数: ${built}`);
351
353
  lines.push(` 复用的会话数: ${reused}`);
352
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
+
353
362
  const real = result.realState;
354
363
  if (real && real.path) {
355
364
  lines.push("");