@evandu/super-pi 0.1.90 → 0.1.91

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-p1BjYE_T.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DgQRMp3j.css">
11
+ <script type="module" crossorigin src="/assets/index-DYjBTgl4.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BcHIMxNF.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,9 +37,35 @@ export class ConversationService {
37
37
  const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
38
38
  if (!workspace)
39
39
  throw new Error("Workspace not found");
40
- const defaults = workspace.defaultAgentId
41
- ? await this.resolveAgentConfiguration({ id: workspaceId, model: workspace.defaultModel, thinkingLevel: workspace.defaultThinkingLevel }, workspace.defaultAgentId, workspaceId)
42
- : undefined;
40
+ // Workspace defaults are copied at birth, but Pi's model list can change after the
41
+ // setting was saved (for example when a model is removed from Pi's config). Do not
42
+ // stamp a dead selection onto every new conversation: the scoped catalog probe would
43
+ // fail and leave the model/thinking controls unavailable. A missing or invalid default
44
+ // means "follow Pi's current default" for this newly-created conversation.
45
+ let model = workspace.defaultModel;
46
+ let thinkingLevel = workspace.defaultThinkingLevel;
47
+ const catalog = model || thinkingLevel ? await this.runtime.catalog() : undefined;
48
+ if (catalog?.source === "pi" && model) {
49
+ const option = catalog.models.find(item => item.provider === model?.provider && item.modelId === model?.modelId);
50
+ if (!option) {
51
+ model = undefined;
52
+ thinkingLevel = undefined;
53
+ }
54
+ else {
55
+ model = { provider: option.provider, modelId: option.modelId, label: option.label };
56
+ if (thinkingLevel) {
57
+ const scoped = await this.runtime.catalog(model);
58
+ if (scoped.source === "pi" && !scoped.levels.includes(thinkingLevel))
59
+ thinkingLevel = undefined;
60
+ }
61
+ }
62
+ }
63
+ const resolved = workspace.defaultAgentId
64
+ ? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
65
+ : { model, thinkingLevel };
66
+ // `null` is intentional here: Store uses it to remove a workspace value that it
67
+ // would otherwise have copied before applying the resolved configuration.
68
+ const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null };
43
69
  const created = await this.store.createConversation(workspaceId, defaults);
44
70
  this.broadcaster.broadcastState();
45
71
  return created.id;
@@ -239,6 +239,15 @@ export declare class RunService {
239
239
  steer(conversationId: string, messageId: string): Promise<void>;
240
240
  /** Starts a chosen waiting message as the next turn, only when the conversation is idle. */
241
241
  sendQueued(conversationId: string, messageId: string): Promise<void>;
242
+ /**
243
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
244
+ *
245
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
246
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
247
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
248
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
249
+ */
250
+ interruptQueued(conversationId: string, messageId: string): Promise<void>;
242
251
  /**
243
252
  * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
244
253
  *
@@ -542,6 +542,22 @@ export class RunService {
542
542
  let assistant = assistantMessage;
543
543
  let toolCalls = [];
544
544
  let pendingTools = Promise.resolve();
545
+ // A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
546
+ // OR a tool-call notice. The `running` state means "we have data flowing", and that is true the
547
+ // moment Pi yields anything back, regardless of shape. Without this, a pure tool-call turn would
548
+ // stay in `waiting` until the very end and the reader would see a stuck turn for the whole of a
549
+ // long tool execution. The flag is the only way to make the flip one-shot: calling `setStatus` on
550
+ // every delta would be a per-token status write.
551
+ let firstSignal = true;
552
+ const promoteToRunning = async () => {
553
+ if (!firstSignal)
554
+ return;
555
+ firstSignal = false;
556
+ if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
557
+ await this.store.setStatus(conversation.id, "running");
558
+ this.broadcaster.broadcastState();
559
+ }
560
+ };
545
561
  // Live events are independent from durable checkpoints. Persisting every token made the stdout
546
562
  // reader wait for one disk transaction per token; over a remote browser connection that backlog
547
563
  // looked exactly like a slow model. Keep the in-memory transcript current, publish immediately, and
@@ -570,6 +586,12 @@ export class RunService {
570
586
  };
571
587
  const enqueueTools = (what, work) => {
572
588
  pendingTools = pendingTools.then(async () => {
589
+ // A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
590
+ // path as text deltas: a tool row appearing on screen is the same "we have data" event
591
+ // the status needs to reflect. Doing it BEFORE the tool write keeps the order
592
+ // honest - a client that saw the new tool row in the same frame also saw the running
593
+ // status, instead of flashing back to a still-spinning waiting mark for the next paint.
594
+ await promoteToRunning();
573
595
  toolCalls = await work();
574
596
  this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
575
597
  }).catch((error) => {
@@ -853,6 +875,12 @@ export class RunService {
853
875
  // publish a new message underneath it. One `await` at the top covers both, and costs nothing:
854
876
  // the lane is empty most of the time.
855
877
  await pendingTools;
878
+ // `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
879
+ // opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
880
+ // and the first response token is the observable fact that says the LLM is actually
881
+ // answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
882
+ // after the first signal so this stays a one-shot status write, not a per-token one.
883
+ await promoteToRunning();
856
884
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
857
885
  spoken += delta.length;
858
886
  // Tool events are structural transcript boundaries. Publish any boundary Pi announced before
@@ -1088,6 +1116,19 @@ export class RunService {
1088
1116
  }
1089
1117
  await this.processWaitingMessage(conversationId, messageId);
1090
1118
  }
1119
+ /**
1120
+ * Stops the current turn and immediately gives one waiting message its own fresh turn.
1121
+ *
1122
+ * `steer` cannot provide this meaning: Pi only reads its steering queue after the current tool batch
1123
+ * finishes. Stopping first is what makes a long-running tool give way now. `sendQueued` already owns
1124
+ * the hand-off window after a stop, so it waits until the old run has released its slot before opening
1125
+ * the selected message. That preserves the one-Pi-per-conversation invariant.
1126
+ */
1127
+ async interruptQueued(conversationId, messageId) {
1128
+ if (this.activeRuns.has(conversationId))
1129
+ await this.stopRun(conversationId);
1130
+ await this.sendQueued(conversationId, messageId);
1131
+ }
1091
1132
  /**
1092
1133
  * The lower-level "wait for run slot, open the turn on a specific waiting message, run it" sequence.
1093
1134
  *
@@ -169,6 +169,10 @@ export class CommandHandler {
169
169
  await this.runService.steer(command.conversationId, command.messageId);
170
170
  return;
171
171
  }
172
+ if (command.type === "message.interrupt") {
173
+ await this.runService.interruptQueued(command.conversationId, command.messageId);
174
+ return;
175
+ }
172
176
  if (command.type === "message.sendQueued") {
173
177
  await this.runService.sendQueued(command.conversationId, command.messageId);
174
178
  return;
@@ -107,8 +107,8 @@ export declare class Store {
107
107
  deleteWorkspace(workspaceId: string): Promise<string[]>;
108
108
  createConversation(workspaceId: string, configuration?: {
109
109
  agentId?: string;
110
- model?: ModelSelection;
111
- thinkingLevel?: string;
110
+ model?: ModelSelection | null;
111
+ thinkingLevel?: string | null;
112
112
  }): Promise<Conversation>;
113
113
  /**
114
114
  * Copies a conversation into a NEW one, under an id the caller has already minted.
@@ -549,9 +549,10 @@ export declare class Store {
549
549
  * question that is still waiting - a transcript lying about its own order. Splicing at
550
550
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
551
551
  *
552
- * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
552
+ * The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
553
553
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
554
- * waiting. They belong here, at the moment the turn genuinely begins.
554
+ * waiting. They belong here, at the moment the turn genuinely begins - the model still has not
555
+ * returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
555
556
  */
556
557
  openTurnOnMessage(conversationId: string, messageId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
557
558
  user: Message;
@@ -135,7 +135,11 @@ function reconcileConversations(state) {
135
135
  state.conversations = conversations;
136
136
  const orphaned = [];
137
137
  for (const conversation of conversations) {
138
- if (!conversation || conversation.status !== "running")
138
+ // `waiting` joins the orphan set for the same reason `running` does: the turn was opened (a prompt
139
+ // was sent) and the server died before any settled state was recorded. A conversation that was in
140
+ // either of those two states when the process went away has a half-open turn whose outcome nobody
141
+ // can determine from the file alone - exactly the situation this sweep exists to remove.
142
+ if (!conversation || (conversation.status !== "running" && conversation.status !== "waiting"))
139
143
  continue;
140
144
  conversation.status = "failed";
141
145
  conversation.error = ORPHANED_CONVERSATION_ERROR;
@@ -690,10 +694,18 @@ export class Store {
690
694
  if (workspace.defaultAgentId)
691
695
  conversation.agentId = workspace.defaultAgentId;
692
696
  if (configuration) {
693
- if (configuration.model)
694
- conversation.model = { ...configuration.model };
695
- if (configuration.thinkingLevel)
696
- conversation.thinkingLevel = configuration.thinkingLevel;
697
+ if (configuration.model !== undefined) {
698
+ if (configuration.model)
699
+ conversation.model = { ...configuration.model };
700
+ else
701
+ delete conversation.model;
702
+ }
703
+ if (configuration.thinkingLevel !== undefined) {
704
+ if (configuration.thinkingLevel)
705
+ conversation.thinkingLevel = configuration.thinkingLevel;
706
+ else
707
+ delete conversation.thinkingLevel;
708
+ }
697
709
  if (configuration.agentId)
698
710
  conversation.agentId = configuration.agentId;
699
711
  }
@@ -876,8 +888,13 @@ export class Store {
876
888
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
877
889
  if (!conversation.titleLocked)
878
890
  conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
891
+ // A TURN STARTS IN `waiting`, NOT `running`. The first delta from the model is what flips the
892
+ // conversation to `running` (see `RunService.streamTurn`); opening a turn only proves a prompt was
893
+ // sent, and a prompt that was sent but never answered looks identical to one that is mid-stream
894
+ // until something tells them apart. Holding the gap open is what makes a stuck LLM endpoint
895
+ // visible to a person - the `waiting` mark on the sidebar row is the only honest signal.
879
896
  conversation.updatedAt = message.createdAt;
880
- conversation.status = "running";
897
+ conversation.status = "waiting";
881
898
  // The reason this conversation was failed - startup reconciliation's note that a previous run was cut
882
899
  // short - describes THAT run, and this line is the moment it stops being the current one. Deleting the
883
900
  // key rather than overwriting it keeps the file byte-identical to a conversation that was never
@@ -1610,9 +1627,10 @@ export class Store {
1610
1627
  * question that is still waiting - a transcript lying about its own order. Splicing at
1611
1628
  * `indexOf(user) + 1` keeps every waiting message below the reply that belongs to the one above it.
1612
1629
  *
1613
- * The side effects - title, `updatedAt`, `status: "running"`, clearing a stale failure - all describe a
1630
+ * The side effects - title, `updatedAt`, `status: "waiting"`, clearing a stale failure - all describe a
1614
1631
  * TURN rather than a message, which is why `addUserMessage` skips them for a message that is only
1615
- * waiting. They belong here, at the moment the turn genuinely begins.
1632
+ * waiting. They belong here, at the moment the turn genuinely begins - the model still has not
1633
+ * returned a token, and that is the shape the row carries until `streamTurn` sees the first delta.
1616
1634
  */
1617
1635
  async openTurnOnMessage(conversationId, messageId, agentIdentity) {
1618
1636
  const conversation = this.requireConversation(conversationId);
@@ -1629,8 +1647,10 @@ export class Store {
1629
1647
  : (firstUser?.images?.length ? "【图片】" : DEFAULT_CONVERSATION_TITLE);
1630
1648
  if (!conversation.titleLocked)
1631
1649
  conversation.title = autoTitle || DEFAULT_CONVERSATION_TITLE;
1650
+ // Same `waiting` reasoning as `addUserMessage` above - the prompt was just handed off and no
1651
+ // token has come back yet, which is a different observable state from "we have data flowing".
1632
1652
  conversation.updatedAt = user.createdAt;
1633
- conversation.status = "running";
1653
+ conversation.status = "waiting";
1634
1654
  delete conversation.error;
1635
1655
  await this.persistMessages(conversation);
1636
1656
  return { user: structuredClone(user), assistant: structuredClone(assistant) };
@@ -5,7 +5,19 @@ export type ModelFilterRule = {
5
5
  label?: string;
6
6
  };
7
7
  import type { WorkflowRun } from "./workflow.js";
8
- export type RunStatus = "idle" | "running" | "completed" | "stopped" | "failed";
8
+ /**
9
+ * The lifecycle states a `Conversation.status` moves through while a turn is in flight. `waiting` is the
10
+ * gap between "prompt handed to the LLM" and "the first response token arrived" - holding it apart from
11
+ * `running` is what lets the reader tell a slow or broken LLM endpoint (still waiting) apart from a
12
+ * streaming turn that is just slow (already running, just no new tokens in the last second). Without
13
+ * the gap the two states look the same on screen for as long as the model is silent, and the reader has
14
+ * no signal to debug from.
15
+ *
16
+ * `completed` / `stopped` / `failed` are the three settled states. `stopped` is what `run.stop` lands;
17
+ * `failed` is what any unhandled throw lands (with `Conversation.error` carrying the translated reason);
18
+ * `completed` is the clean path.
19
+ */
20
+ export type RunStatus = "idle" | "waiting" | "running" | "completed" | "stopped" | "failed";
9
21
  export type MessageRole = "user" | "assistant" | "tool" | "system";
10
22
  /**
11
23
  * One message in `state.json`.
@@ -1078,6 +1090,12 @@ export type ClientCommandBody =
1078
1090
  conversationId: string;
1079
1091
  messageId: string;
1080
1092
  }
1093
+ /** Stops the active turn, then starts this waiting message as a fresh turn. */
1094
+ | {
1095
+ type: "message.interrupt";
1096
+ conversationId: string;
1097
+ messageId: string;
1098
+ }
1081
1099
  /** Starts this waiting message as the next turn, only after the current turn has ended. */
1082
1100
  | {
1083
1101
  type: "message.sendQueued";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",