@evandu/super-pi 0.1.108 → 0.1.110

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.
Files changed (31) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +7 -6
  3. package/dist/client/assets/CodeMirrorEditor-DdNOmxYW.js +2 -0
  4. package/dist/client/assets/dist-B0owH_FO.js +23 -0
  5. package/dist/client/assets/dist-BMx01Qs-.js +3 -0
  6. package/dist/client/assets/dist-BaXshVGE.js +1 -0
  7. package/dist/client/assets/dist-BcnfVRW2.js +1 -0
  8. package/dist/client/assets/dist-CB4FCjuA.js +1 -0
  9. package/dist/client/assets/dist-CUsER3MR.js +2 -0
  10. package/dist/client/assets/dist-CiFn8_Gb.js +11 -0
  11. package/dist/client/assets/dist-CmNELnsL.js +6 -0
  12. package/dist/client/assets/dist-CyuGJahk.js +1 -0
  13. package/dist/client/assets/dist-D6LObgqS.js +1 -0
  14. package/dist/client/assets/dist-DFYECMpZ.js +7 -0
  15. package/dist/client/assets/dist-D_JtGl0X.js +1 -0
  16. package/dist/client/assets/dist-DfUFnKum.js +5 -0
  17. package/dist/client/assets/dist-DyYBaVQ5.js +1 -0
  18. package/dist/client/assets/dist-VljXHWNt.js +9 -0
  19. package/dist/client/assets/dist-r4nQMwvT.js +2 -0
  20. package/dist/client/assets/index-CGlVRKg9.css +1 -0
  21. package/dist/client/assets/index-DmN1fZRn.js +99 -0
  22. package/dist/client/index.html +2 -2
  23. package/dist/server/application/run-service.d.ts +6 -0
  24. package/dist/server/application/run-service.js +15 -6
  25. package/dist/server/persistence/sqlite-state-repository.js +51 -14
  26. package/dist/server/store.d.ts +16 -0
  27. package/dist/server/store.js +21 -0
  28. package/dist/server/ws-server.js +11 -1
  29. package/package.json +16 -1
  30. package/dist/client/assets/index-Aa8KN3IB.css +0 -1
  31. package/dist/client/assets/index-CnIOGNIm.js +0 -79
@@ -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-CnIOGNIm.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-Aa8KN3IB.css">
11
+ <script type="module" crossorigin src="/assets/index-DmN1fZRn.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-CGlVRKg9.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -190,6 +190,12 @@ export declare class RunService {
190
190
  askQuestion(conversationId: string): Promise<unknown>;
191
191
  /**
192
192
  * Resolves the conversation and workspace synchronously for a run.
193
+ *
194
+ * TWO CLONES, NOT TWO SNAPSHOTS. This used to call `store.snapshot()` twice - once for the
195
+ * conversation and once for the workspace - and a snapshot deep-clones the ENTIRE application
196
+ * state: every other conversation, every message in it, every tool result. It runs a handful of
197
+ * times per turn, twice of them before the frame that tells the browser a turn has started, so the
198
+ * size of the whole state was part of the wait between clicking 发送 and seeing 等待 LLM 返回.
193
199
  */
194
200
  turnContext(conversationId: string): {
195
201
  conversation: Conversation;
@@ -488,15 +488,24 @@ export class RunService {
488
488
  }
489
489
  /**
490
490
  * Resolves the conversation and workspace synchronously for a run.
491
+ *
492
+ * TWO CLONES, NOT TWO SNAPSHOTS. This used to call `store.snapshot()` twice - once for the
493
+ * conversation and once for the workspace - and a snapshot deep-clones the ENTIRE application
494
+ * state: every other conversation, every message in it, every tool result. It runs a handful of
495
+ * times per turn, twice of them before the frame that tells the browser a turn has started, so the
496
+ * size of the whole state was part of the wait between clicking 发送 and seeing 等待 LLM 返回.
491
497
  */
492
498
  turnContext(conversationId) {
493
- const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
494
- if (!conversation)
495
- throw new Error("会话不存在");
496
- const workspace = this.store.snapshot().workspaces.find((item) => item.id === conversation.workspaceId);
497
- if (!workspace)
499
+ const context = this.store.turnContext(conversationId);
500
+ if (!context) {
501
+ // One snapshot, on the error path only, to say WHICH of the two is missing - the messages are
502
+ // what a person acts on, and this path never runs for a conversation that is merely being used.
503
+ const conversation = this.store.snapshot().conversations.find((item) => item.id === conversationId);
504
+ if (!conversation)
505
+ throw new Error("会话不存在");
498
506
  throw new Error(`会话所属的工作区 ${conversation.workspaceId} 已不存在,拒绝运行`);
499
- return { conversation, workspace };
507
+ }
508
+ return context;
500
509
  }
501
510
  /**
502
511
  * Resolves conversation and workspace working directory for subagent dispatch.
@@ -51,9 +51,14 @@ export class SqliteStateRepository {
51
51
  // An authoritative order row set means this conversation's transcript is rebuilt from it, rather
52
52
  // than from the base snapshot's array. That is what makes a WITHDRAWN message stay withdrawn: the
53
53
  // snapshot still lists it, and reading the array back would resurrect words the user took back.
54
+ //
55
+ // THE CONTENT COMES FROM THE `messages` TABLE WHEN IT HAS THE ROW, and only the ORDER comes from
56
+ // here. `message.replace` (the 50ms streaming checkpoint) writes that table alone, so preferring
57
+ // this table's copy of the JSON would reload a message at whatever content it had when the order
58
+ // was last rewritten - the placeholder an assistant message was born with, not what it streamed.
54
59
  const ordered = this.db.prepare("SELECT id,position,json FROM message_order WHERE conversation=? ORDER BY position").all(conversation.id);
55
60
  if (ordered.length) {
56
- conversation.messages = ordered.map(row => JSON.parse(String(row.json)));
61
+ conversation.messages = ordered.map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
57
62
  }
58
63
  else {
59
64
  if (!Array.isArray(conversation.messages))
@@ -114,20 +119,52 @@ export class SqliteStateRepository {
114
119
  write.run(name, JSON.stringify(value));
115
120
  }
116
121
  else if (transaction.kind === "conversation.messages") {
117
- // Rewrite the WHOLE array, order included. A per-id upsert cannot express this transaction: it
118
- // both reorders (a waiting message moving within its group) and removes (a withdrawn one), and
119
- // both change which messages exist. Replacing the set is the honest write, and it is one
120
- // transaction because a reader that saw the order without the content, or the content without
121
- // the order, would see a transcript that was never true.
122
- this.db.prepare("DELETE FROM message_order WHERE conversation=?").run(transaction.conversationId);
123
- this.db.prepare("DELETE FROM messages WHERE conversation=?").run(transaction.conversationId);
122
+ // A DIFF, NOT A REWRITE. This used to DELETE the conversation's rows and re-INSERT every
123
+ // message - stringified twice, once per table - on every single send, under
124
+ // `synchronous=FULL`. That is O(transcript) work in front of the broadcast that tells the
125
+ // browser a turn has started, so a long conversation showed 「就绪」 for seconds after the
126
+ // click. A send changes ONE message (the appended user row); the rest are already on disk,
127
+ // byte for byte.
128
+ //
129
+ // THE ORDER TABLE IS STILL AUTHORITATIVE FOR ORDER, and its presence is what makes a
130
+ // withdrawn message stay withdrawn (the base snapshot still lists it). When it has no rows
131
+ // yet, the base array is authoritative - that is the `load` contract - so this falls back to
132
+ // the full write ONCE to materialise the order, and every later write is a diff.
133
+ const conversationId = transaction.conversationId;
134
+ const stored = new Map(this.db.prepare("SELECT id,json FROM messages WHERE conversation=?").all(conversationId)
135
+ .map((row) => [String(row.id), String(row.json)]));
136
+ const ordered = this.db.prepare("SELECT id,position FROM message_order WHERE conversation=? ORDER BY position").all(conversationId);
124
137
  const upsertMessage = this.db.prepare("INSERT INTO messages VALUES(?,?,?) ON CONFLICT(conversation,id) DO UPDATE SET json=excluded.json");
125
- const insertOrdered = this.db.prepare("INSERT INTO message_order VALUES(?,?,?,?)");
126
- transaction.messages.forEach((message, position) => {
127
- const json = JSON.stringify(message);
128
- upsertMessage.run(transaction.conversationId, message.id, json);
129
- insertOrdered.run(transaction.conversationId, message.id, position, json);
130
- });
138
+ if (ordered.length === 0) {
139
+ const insertOrdered = this.db.prepare("INSERT INTO message_order VALUES(?,?,?,?)");
140
+ transaction.messages.forEach((message, position) => {
141
+ const json = JSON.stringify(message);
142
+ upsertMessage.run(conversationId, message.id, json);
143
+ insertOrdered.run(conversationId, message.id, position, json);
144
+ });
145
+ }
146
+ else {
147
+ const nextIds = new Set(transaction.messages.map((message) => message.id));
148
+ const deleteMessage = this.db.prepare("DELETE FROM messages WHERE conversation=? AND id=?");
149
+ const deleteOrder = this.db.prepare("DELETE FROM message_order WHERE conversation=? AND id=?");
150
+ for (const row of ordered) {
151
+ if (nextIds.has(String(row.id)))
152
+ continue;
153
+ deleteMessage.run(conversationId, row.id);
154
+ deleteOrder.run(conversationId, row.id);
155
+ }
156
+ const upsertOrder = this.db.prepare("INSERT INTO message_order VALUES(?,?,?,?) ON CONFLICT(conversation,id) DO UPDATE SET position=excluded.position, json=excluded.json");
157
+ const positions = new Map(ordered.map((row) => [String(row.id), Number(row.position)]));
158
+ transaction.messages.forEach((message, position) => {
159
+ const json = JSON.stringify(message);
160
+ if (stored.get(message.id) !== json)
161
+ upsertMessage.run(conversationId, message.id, json);
162
+ // An unchanged message at an unchanged position needs NO write at all. A pure append
163
+ // therefore costs exactly one message row plus one order row.
164
+ if (positions.get(message.id) !== position)
165
+ upsertOrder.run(conversationId, message.id, position, json);
166
+ });
167
+ }
131
168
  // The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
132
169
  // that can record it; clearing the overlay instead would leave the reopened conversation reporting
133
170
  // whatever the base snapshot held, which is the value the server had already decided was wrong.
@@ -91,6 +91,22 @@ export declare class Store {
91
91
  */
92
92
  setDefaultModel(model?: ModelSelection): Promise<void>;
93
93
  snapshot(): AppState;
94
+ /**
95
+ * One conversation and its workspace, as DETACHED copies, without cloning the whole state.
96
+ *
97
+ * The alternative - what the run path used to do - was two `snapshot()` calls, and `snapshot()`
98
+ * deep-clones every conversation, every message and every tool result in the app. That is the
99
+ * shape of the bug it fixes: reading one conversation's model cost O(entire state), and it sat on
100
+ * the path between a click on 发送 and the frame that tells the browser a turn has started.
101
+ *
102
+ * The two objects are still CLONES, because every caller of this is allowed to keep them after the
103
+ * store has moved on (a streamed turn holds its conversation for minutes), and handing out live
104
+ * references would turn the first such caller into a corruption bug that only shows up under load.
105
+ */
106
+ turnContext(conversationId: string): {
107
+ conversation: Conversation;
108
+ workspace: Workspace;
109
+ } | undefined;
94
110
  createWorkspace(name: string, path?: string): Promise<Workspace>;
95
111
  renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
96
112
  /**
@@ -684,6 +684,27 @@ export class Store {
684
684
  }
685
685
  }
686
686
  snapshot() { return structuredClone(this.state); }
687
+ /**
688
+ * One conversation and its workspace, as DETACHED copies, without cloning the whole state.
689
+ *
690
+ * The alternative - what the run path used to do - was two `snapshot()` calls, and `snapshot()`
691
+ * deep-clones every conversation, every message and every tool result in the app. That is the
692
+ * shape of the bug it fixes: reading one conversation's model cost O(entire state), and it sat on
693
+ * the path between a click on 发送 and the frame that tells the browser a turn has started.
694
+ *
695
+ * The two objects are still CLONES, because every caller of this is allowed to keep them after the
696
+ * store has moved on (a streamed turn holds its conversation for minutes), and handing out live
697
+ * references would turn the first such caller into a corruption bug that only shows up under load.
698
+ */
699
+ turnContext(conversationId) {
700
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
701
+ if (!conversation)
702
+ return undefined;
703
+ const workspace = this.state.workspaces.find((item) => item.id === conversation.workspaceId);
704
+ if (!workspace)
705
+ return undefined;
706
+ return { conversation: structuredClone(conversation), workspace: structuredClone(workspace) };
707
+ }
687
708
  async createWorkspace(name, path) {
688
709
  const cleanName = name.trim();
689
710
  if (!cleanName || cleanName.length > 80)
@@ -64,6 +64,16 @@ export class WsBroadcaster {
64
64
  }
65
65
  broadcast(event) {
66
66
  const streaming = event.type === "message.delta" || event.type === "message.thinking";
67
+ /**
68
+ * SERIALIZED ONCE, NOT ONCE PER SOCKET. A `state` frame carries the whole application state, so
69
+ * `JSON.stringify` on it is the most expensive thing this method does - and every open tab used
70
+ * to pay for its own copy of the same string. The bytes are identical (no socket-specific field),
71
+ * so the string is built once and handed to every socket.
72
+ *
73
+ * Stream deltas stay per-event: they are coalesced per socket, so there is no single payload to
74
+ * share, and their cost is one small string.
75
+ */
76
+ const payload = streaming ? undefined : JSON.stringify(event);
67
77
  for (const socket of this.sockets) {
68
78
  if (socket.readyState !== WebSocket.OPEN)
69
79
  continue;
@@ -72,7 +82,7 @@ export class WsBroadcaster {
72
82
  else {
73
83
  // A state/tool/lifecycle event is a transcript boundary. Flush its preceding text first.
74
84
  this.flushStreaming(socket);
75
- socket.send(JSON.stringify(event));
85
+ socket.send(payload ?? JSON.stringify(event));
76
86
  }
77
87
  }
78
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.108",
3
+ "version": "0.1.110",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,6 +95,7 @@
95
95
  "scripts/tool-register-env.test.mjs",
96
96
  "scripts/bg-task-zombie.test.mjs",
97
97
  "scripts/scoped-sidebar-tabs.test.mjs",
98
+ "scripts/git-diff-view.test.mjs",
98
99
  "scripts/fork-server.test.mjs",
99
100
  "scripts/styles-no-dangling.test.mjs",
100
101
  "scripts/theme-no-hardcoded-colours.test.mjs",
@@ -163,7 +164,21 @@
163
164
  "scripts/knowledge-base-browser.test.mjs"
164
165
  ],
165
166
  "dependencies": {
167
+ "@codemirror/commands": "^6.11.1",
168
+ "@codemirror/lang-css": "^6.3.1",
169
+ "@codemirror/lang-go": "^6.0.1",
170
+ "@codemirror/lang-html": "^6.4.12",
171
+ "@codemirror/lang-javascript": "^6.2.5",
172
+ "@codemirror/lang-json": "^6.0.2",
173
+ "@codemirror/lang-markdown": "^6.5.2",
174
+ "@codemirror/lang-python": "^6.2.1",
175
+ "@codemirror/lang-rust": "^6.0.2",
176
+ "@codemirror/language": "^6.13.1",
177
+ "@codemirror/search": "^6.7.2",
178
+ "@codemirror/state": "^6.7.6",
179
+ "@codemirror/view": "^6.43.15",
166
180
  "@earendil-works/pi-coding-agent": "^0.86.1",
181
+ "@git-diff-view/react": "^0.1.8",
167
182
  "@types/diff": "^5.2.3",
168
183
  "@xterm/addon-fit": "^0.11.0",
169
184
  "@xterm/xterm": "^6.0.0",