@evandu/super-pi 0.1.101 → 0.1.103
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 +7 -7
- package/dist/client/assets/{index-8olCbCbk.css → index-DDkbPYLz.css} +1 -1
- package/dist/client/assets/index-mAu39Bra.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/application/run-service.d.ts +51 -0
- package/dist/server/application/run-service.js +157 -7
- package/dist/server/command-handler.js +12 -0
- package/dist/server/pi-session.d.ts +34 -0
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +89 -2
- package/dist/server/store.d.ts +19 -1
- package/dist/server/store.js +37 -2
- package/dist/server/types.d.ts +43 -0
- package/package.json +4 -1
- package/scripts/diagnose.mjs +122 -8
- package/dist/client/assets/index-gjLqMWYT.js +0 -74
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>;
|
|
@@ -533,8 +545,14 @@ export declare class Store {
|
|
|
533
545
|
* was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
|
|
534
546
|
* exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
|
|
535
547
|
* with no question above it.
|
|
548
|
+
*
|
|
549
|
+
* `messageId` says WHICH message Pi picked up, for the case where position cannot answer it. A message
|
|
550
|
+
* handed to Pi's follow-up queue was never CLAIMED here - it is still `waiting`, because Super Pi keeps
|
|
551
|
+
* it in its own list so the user can still withdraw it - and a conversation can hold a `waiting` message
|
|
552
|
+
* and a `sending` one at the same time. The caller resolves the delivery by the text Pi announced,
|
|
553
|
+
* which is the same text Pi itself matched on.
|
|
536
554
|
*/
|
|
537
|
-
deliverWaitingMessage(conversationId: string, agentIdentity?: Message["agentIdentity"]): Promise<{
|
|
555
|
+
deliverWaitingMessage(conversationId: string, agentIdentity?: Message["agentIdentity"], messageId?: string): Promise<{
|
|
538
556
|
user: Message;
|
|
539
557
|
assistant: Message;
|
|
540
558
|
} | undefined>;
|
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();
|
|
@@ -1609,17 +1634,27 @@ export class Store {
|
|
|
1609
1634
|
* was written into the transcript when the user pressed send, long before Pi ever saw it - and it stays
|
|
1610
1635
|
* exactly where it was, now with the agent answering it. Deleting it is what made a steered reply appear
|
|
1611
1636
|
* with no question above it.
|
|
1637
|
+
*
|
|
1638
|
+
* `messageId` says WHICH message Pi picked up, for the case where position cannot answer it. A message
|
|
1639
|
+
* handed to Pi's follow-up queue was never CLAIMED here - it is still `waiting`, because Super Pi keeps
|
|
1640
|
+
* it in its own list so the user can still withdraw it - and a conversation can hold a `waiting` message
|
|
1641
|
+
* and a `sending` one at the same time. The caller resolves the delivery by the text Pi announced,
|
|
1642
|
+
* which is the same text Pi itself matched on.
|
|
1612
1643
|
*/
|
|
1613
|
-
async deliverWaitingMessage(conversationId, agentIdentity) {
|
|
1644
|
+
async deliverWaitingMessage(conversationId, agentIdentity, messageId) {
|
|
1614
1645
|
const conversation = this.requireConversation(conversationId);
|
|
1615
1646
|
// Looks for the single message Pi is currently answering. A `role: "tool"` 后台回传 that has been
|
|
1616
1647
|
// steered or taken is on the same delivery state machine as a user message, so it is the answer
|
|
1617
1648
|
// target too; the assistant placeholder still lands directly below it and the role/identity is read
|
|
1618
1649
|
// from the conversation (not the message) when the turn opens.
|
|
1619
|
-
const index =
|
|
1650
|
+
const index = messageId
|
|
1651
|
+
? conversation.messages.findIndex((item) => item.id === messageId)
|
|
1652
|
+
: conversation.messages.findIndex((item) => (item.role === "user" || item.role === "tool") && item.delivery === "sending");
|
|
1620
1653
|
if (index < 0)
|
|
1621
1654
|
return undefined;
|
|
1622
1655
|
const user = conversation.messages[index];
|
|
1656
|
+
if (user.role !== "user" && user.role !== "tool")
|
|
1657
|
+
return undefined;
|
|
1623
1658
|
delete user.delivery;
|
|
1624
1659
|
const assistant = { id: id("message"), role: "assistant", content: "", createdAt: now(), ...(agentIdentity ? { agentIdentity: structuredClone(agentIdentity) } : {}) };
|
|
1625
1660
|
conversation.messages.splice(index + 1, 0, assistant);
|
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.103",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"scripts/agent-vis-records.test.mjs",
|
|
76
76
|
"scripts/queue-store.test.mjs",
|
|
77
77
|
"scripts/followup.test.mjs",
|
|
78
|
+
"scripts/pi-followup-queue.test.mjs",
|
|
78
79
|
"scripts/md-render.test.mjs",
|
|
79
80
|
"scripts/revert-session-format.test.mjs",
|
|
80
81
|
"scripts/revert-session-file.test.mjs",
|
|
@@ -90,6 +91,7 @@
|
|
|
90
91
|
"scripts/theme-no-hardcoded-colours.test.mjs",
|
|
91
92
|
"scripts/agent-config-regression.test.mjs",
|
|
92
93
|
"scripts/model-filter.test.mjs",
|
|
94
|
+
"scripts/settings-default-model.test.mjs",
|
|
93
95
|
"scripts/active-memory.test.mjs",
|
|
94
96
|
"scripts/knowledge-base.test.mjs",
|
|
95
97
|
"scripts/tool-runner.test.mjs",
|
|
@@ -135,6 +137,7 @@
|
|
|
135
137
|
"scripts/theme-contrast.test.mjs",
|
|
136
138
|
"scripts/theme-switch-state.test.mjs",
|
|
137
139
|
"scripts/settings-window-structure.test.mjs",
|
|
140
|
+
"scripts/settings-model-browser.test.mjs",
|
|
138
141
|
"scripts/file-tab-selected-contrast.test.mjs",
|
|
139
142
|
"scripts/help-window.test.mjs",
|
|
140
143
|
"scripts/composer-regression-browser.test.mjs",
|
package/scripts/diagnose.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { mkdtempSync, rmSync, existsSync, statSync, readFileSync } from "node:fs
|
|
|
21
21
|
import { homedir, tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
24
25
|
|
|
25
26
|
/** The Node version Pi requires. Below it, module loading and jiti transpiling are measurably slower. */
|
|
26
27
|
const PI_NODE_REQUIREMENT = "22.19.0";
|
|
@@ -50,23 +51,42 @@ function atLeast(actual, required) {
|
|
|
50
51
|
* and a message used to cost a full write of all of it before the browser was told the turn had
|
|
51
52
|
* started. The size of that state is invisible from a fresh install and decisive on a real one, so it
|
|
52
53
|
* is reported from the REAL data directory, read-only, and the write is measured on a COPY.
|
|
54
|
+
*
|
|
55
|
+
* THE NUMBER THAT MATTERS IS THE STATE, NOT THE FILE. An earlier version of this report called the
|
|
56
|
+
* SQLite FILE size "the state", and that is a different number: the file holds the base snapshot AND
|
|
57
|
+
* the incremental overlay tables, and the overlay is never pruned, so the file grows with every write
|
|
58
|
+
* while the state the BROWSER RECEIVES does not grow the same way. The number that costs time is the
|
|
59
|
+
* one that is cloned (`store.snapshot()`), serialised (`JSON.stringify`) and parsed (`JSON.parse` in
|
|
60
|
+
* the browser) on every state broadcast - so that is what is measured here, by rebuilding the state
|
|
61
|
+
* out of the database and measuring its JSON.
|
|
53
62
|
*/
|
|
54
63
|
function measureRealState(dataDir) {
|
|
55
|
-
const result = { path: "", bytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
|
|
64
|
+
const result = { path: "", bytes: 0, fileBytes: 0, conversations: 0, messages: 0, fullWriteMs: undefined };
|
|
56
65
|
for (const name of ["state.sqlite", "state.json"]) {
|
|
57
66
|
const path = join(dataDir, name);
|
|
58
67
|
if (!existsSync(path)) continue;
|
|
59
68
|
result.path = path;
|
|
60
69
|
try {
|
|
61
|
-
result.
|
|
70
|
+
result.fileBytes = statSync(path).size;
|
|
62
71
|
if (name === "state.json") {
|
|
63
72
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
64
73
|
result.conversations = parsed.conversations?.length ?? 0;
|
|
65
74
|
result.messages = (parsed.conversations ?? []).reduce((sum, c) => sum + (c.messages?.length ?? 0), 0);
|
|
75
|
+
// THE SIZE THAT IS ACTUALLY SENT. The browser gets this JSON, not the file.
|
|
76
|
+
result.bytes = Buffer.byteLength(readFileSync(path, "utf8"), "utf8");
|
|
66
77
|
} else {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
78
|
+
// THE SQLITE STATE, REBUILT AND MEASURED. The repository's own loader is the authority on what
|
|
79
|
+
// the state IS, so the report does not guess at it: the database is opened READ-ONLY, the
|
|
80
|
+
// overlays are folded onto the base snapshot the way `load()` folds them, and the result is
|
|
81
|
+
// measured as JSON - the same bytes a state broadcast carries.
|
|
82
|
+
const measured = measureSqliteState(path);
|
|
83
|
+
if (measured.bytes > 0) {
|
|
84
|
+
result.bytes = measured.bytes;
|
|
85
|
+
result.conversations = measured.conversations;
|
|
86
|
+
result.messages = measured.messages;
|
|
87
|
+
} else {
|
|
88
|
+
result.bytes = result.fileBytes;
|
|
89
|
+
}
|
|
70
90
|
}
|
|
71
91
|
} catch { /* unreadable is not a fact worth reporting */ }
|
|
72
92
|
break;
|
|
@@ -74,6 +94,72 @@ function measureRealState(dataDir) {
|
|
|
74
94
|
return result;
|
|
75
95
|
}
|
|
76
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The size of the state a SQLite store holds, measured as the JSON a broadcast would carry.
|
|
99
|
+
*
|
|
100
|
+
* READ-ONLY, and it opens its OWN connection rather than the server's: a report must not disturb the
|
|
101
|
+
* session someone is in the middle of, and a second read-only connection sees a consistent snapshot
|
|
102
|
+
* without taking any lock the server needs.
|
|
103
|
+
*/
|
|
104
|
+
function measureSqliteState(path) {
|
|
105
|
+
let db;
|
|
106
|
+
const empty = { bytes: 0, conversations: 0, messages: 0 };
|
|
107
|
+
try {
|
|
108
|
+
const require = createRequire(import.meta.url);
|
|
109
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
110
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
111
|
+
const row = db.prepare("SELECT json FROM state WHERE id=1").get();
|
|
112
|
+
if (!row) return empty;
|
|
113
|
+
const state = JSON.parse(String(row.json));
|
|
114
|
+
if (!Array.isArray(state.conversations)) state.conversations = [];
|
|
115
|
+
// THE OVERLAYS, folded the way `load()` folds them: a conversation's metadata replaces its fields
|
|
116
|
+
// except its messages, and its messages are the union of the base array and the overlay rows - with
|
|
117
|
+
// the authoritative order rows winning when they are there.
|
|
118
|
+
const metadata = db.prepare("SELECT conversation,json FROM metadata").all();
|
|
119
|
+
const overrides = db.prepare("SELECT conversation,id,json FROM messages").all();
|
|
120
|
+
const ordered = db.prepare("SELECT conversation,id,position,json FROM message_order").all();
|
|
121
|
+
const byConversation = new Map();
|
|
122
|
+
for (const item of overrides) {
|
|
123
|
+
const list = byConversation.get(String(item.conversation)) ?? new Map();
|
|
124
|
+
list.set(String(item.id), JSON.parse(String(item.json)));
|
|
125
|
+
byConversation.set(String(item.conversation), list);
|
|
126
|
+
}
|
|
127
|
+
const orderByConversation = new Map();
|
|
128
|
+
for (const item of ordered) {
|
|
129
|
+
const list = orderByConversation.get(String(item.conversation)) ?? [];
|
|
130
|
+
list.push({ position: Number(item.position), message: JSON.parse(String(item.json)) });
|
|
131
|
+
orderByConversation.set(String(item.conversation), list);
|
|
132
|
+
}
|
|
133
|
+
for (const item of metadata) {
|
|
134
|
+
const conversation = state.conversations.find((entry) => entry.id === String(item.conversation));
|
|
135
|
+
if (!conversation) continue;
|
|
136
|
+
const messages = conversation.messages;
|
|
137
|
+
for (const key of Object.keys(conversation)) Reflect.deleteProperty(conversation, key);
|
|
138
|
+
Object.assign(conversation, JSON.parse(String(item.json)), { messages });
|
|
139
|
+
}
|
|
140
|
+
for (const conversation of state.conversations) {
|
|
141
|
+
const map = byConversation.get(conversation.id) ?? new Map();
|
|
142
|
+
const order = orderByConversation.get(conversation.id) ?? [];
|
|
143
|
+
if (order.length) {
|
|
144
|
+
conversation.messages = order.sort((a, b) => a.position - b.position).map((entry) => entry.message);
|
|
145
|
+
} else if (Array.isArray(conversation.messages)) {
|
|
146
|
+
conversation.messages = conversation.messages.map((message) => map.get(message.id) ?? message);
|
|
147
|
+
} else {
|
|
148
|
+
conversation.messages = [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
bytes: Buffer.byteLength(JSON.stringify(state), "utf8"),
|
|
153
|
+
conversations: state.conversations.length,
|
|
154
|
+
messages: state.conversations.reduce((sum, conversation) => sum + (conversation.messages?.length ?? 0), 0),
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return empty;
|
|
158
|
+
} finally {
|
|
159
|
+
try { db?.close(); } catch { /* already closed */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
77
163
|
/**
|
|
78
164
|
* Runs the diagnosis against a throwaway server and returns what it measured.
|
|
79
165
|
*
|
|
@@ -216,6 +302,8 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
|
|
|
216
302
|
}
|
|
217
303
|
await sleep(250);
|
|
218
304
|
const added = log.slice(logMark);
|
|
305
|
+
const promptSize = /systemPrompt=(\d+)B/.exec(added)?.[1];
|
|
306
|
+
if (promptSize) result.systemPromptBytes = Number(promptSize);
|
|
219
307
|
result.turns.push({
|
|
220
308
|
turn,
|
|
221
309
|
toWaitingMs: waiting - sent,
|
|
@@ -274,6 +362,14 @@ export async function diagnose({ repositoryRoot, turns = 2, onProgress = () => {
|
|
|
274
362
|
} catch { /* reported as not measured below */ }
|
|
275
363
|
const interruptText = Date.now() - clicked;
|
|
276
364
|
const interruptLog = log.slice(interruptLogMark);
|
|
365
|
+
// The whole trace of the interrupt, in order. It is kept because the interrupt is the one path
|
|
366
|
+
// whose cost has turned out to be surprising, and a number without the phases around it cannot
|
|
367
|
+
// say which phase the number belongs to.
|
|
368
|
+
result.interruptTrace = interruptLog
|
|
369
|
+
.split("\n")
|
|
370
|
+
.filter((line) => line.includes("[super-pi latency]"))
|
|
371
|
+
.map((line) => line.replace(/.*\[super-pi latency\] /, "").trim())
|
|
372
|
+
.slice(0, 24);
|
|
277
373
|
result.interrupt = {
|
|
278
374
|
toWaitingMs: interruptWaiting || undefined,
|
|
279
375
|
toTextMs: interruptText,
|
|
@@ -328,6 +424,11 @@ export function renderReport(result) {
|
|
|
328
424
|
lines.push(` 第${turn.turn}轮 等待中 -> 出字 ${ms(turn.modelMs)} 模型 (不是 Super Pi)`);
|
|
329
425
|
}
|
|
330
426
|
if (result.interrupt) {
|
|
427
|
+
if (Array.isArray(result.interruptTrace) && result.interruptTrace.length) {
|
|
428
|
+
lines.push(" 打断时的完整阶段(按顺序):");
|
|
429
|
+
for (const entry of result.interruptTrace) lines.push(` ${entry}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
331
432
|
lines.push("");
|
|
332
433
|
lines.push(" 打断(点「打断并回答」之后):");
|
|
333
434
|
lines.push(` 点击 -> 新一轮报等待中 ${ms(result.interrupt.toWaitingMs)} Super Pi (等旧轮退完 + 等槽位)`);
|
|
@@ -350,6 +451,13 @@ export function renderReport(result) {
|
|
|
350
451
|
lines.push(` 建立的会话数: ${built}`);
|
|
351
452
|
lines.push(` 复用的会话数: ${reused}`);
|
|
352
453
|
|
|
454
|
+
if (result.systemPromptBytes) {
|
|
455
|
+
lines.push("");
|
|
456
|
+
lines.push(` 发给模型的系统提示:${(result.systemPromptBytes / 1024).toFixed(1)}KB`);
|
|
457
|
+
lines.push(" 这是每一轮都要先传过去的东西。模型的 provider 得先收下它才能回答,");
|
|
458
|
+
lines.push(" 所以在慢链路上,它直接就是首字之前的那几秒。");
|
|
459
|
+
}
|
|
460
|
+
|
|
353
461
|
const real = result.realState;
|
|
354
462
|
if (real && real.path) {
|
|
355
463
|
lines.push("");
|
|
@@ -357,10 +465,16 @@ export function renderReport(result) {
|
|
|
357
465
|
lines.push(` ${real.path}`);
|
|
358
466
|
const megabytes = (real.bytes / 1024 / 1024).toFixed(2);
|
|
359
467
|
const shape = real.conversations ? `,${real.conversations} 个会话,${real.messages} 条消息` : "";
|
|
360
|
-
lines.push(`
|
|
468
|
+
lines.push(` 浏览器每次收到的那份 state:${megabytes}MB${shape}`);
|
|
469
|
+
if (real.fileBytes && real.fileBytes !== real.bytes) {
|
|
470
|
+
lines.push(` 数据库文件本身:${(real.fileBytes / 1024 / 1024).toFixed(2)}MB`);
|
|
471
|
+
lines.push(" (文件比 state 大是因为它还存着增量写的那几层,那些层从不被清理;");
|
|
472
|
+
lines.push(" 真正花时间的是上面那个数字——每次广播都要克隆它、序列化它、");
|
|
473
|
+
lines.push(" 再在浏览器里解析一遍。)");
|
|
474
|
+
}
|
|
361
475
|
if (real.bytes > 1024 * 1024) {
|
|
362
|
-
lines.push(" 这个体积的 state
|
|
363
|
-
lines.push("
|
|
476
|
+
lines.push(" 这个体积的 state 每变一次就要整份克隆 + 整份序列化 + 浏览器整份解析,");
|
|
477
|
+
lines.push(" 在慢机器上每一次广播都是秒级,而一轮里会广播好几次。");
|
|
364
478
|
}
|
|
365
479
|
}
|
|
366
480
|
|