@evandu/super-pi 0.1.133 → 0.1.136
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 +6 -6
- package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-CWN8JIx4.js} +2 -2
- package/dist/client/assets/{dist-D4x1XTQb.js → dist-0vYGgCC5.js} +1 -1
- package/dist/client/assets/{dist-CFPXj4ws.js → dist-B0vHKLg1.js} +1 -1
- package/dist/client/assets/{dist-4Pts9xPO.js → dist-BA6BeFaf.js} +1 -1
- package/dist/client/assets/{dist-BtTFCNVQ.js → dist-BQjtlfpY.js} +1 -1
- package/dist/client/assets/{dist-CUKaDTyO.js → dist-BSOBu3lQ.js} +1 -1
- package/dist/client/assets/{dist-CYD2SqmG.js → dist-BtNKV30Z.js} +1 -1
- package/dist/client/assets/{dist-DHqqpxo4.js → dist-CTXz1R9C.js} +1 -1
- package/dist/client/assets/{dist-BffE00vI.js → dist-CV9QN-qU.js} +1 -1
- package/dist/client/assets/{dist-azDN8a65.js → dist-CYLUz40X.js} +1 -1
- package/dist/client/assets/{dist-aL2NGji_.js → dist-CwNZuQ4i.js} +1 -1
- package/dist/client/assets/{dist-CApAttfj.js → dist-DCuAT0n-.js} +1 -1
- package/dist/client/assets/{dist-DOYWHMIq.js → dist-DErmxG4Q.js} +1 -1
- package/dist/client/assets/{dist-DQf5nNxa.js → dist-DHTyhm7w.js} +1 -1
- package/dist/client/assets/{dist-fYp8Xwrx.js → dist-DMDxxHpj.js} +1 -1
- package/dist/client/assets/{dist-B5ruVtUG.js → dist-Dalb5yHq.js} +1 -1
- package/dist/client/assets/{dist-0FH-oFco.js → dist-OKarkelj.js} +1 -1
- package/dist/client/assets/{dist-DbbBgSwL.js → dist-Qc0xt1y7.js} +1 -1
- package/dist/client/assets/{dist-CxsunljU.js → dist-WkcafMsD.js} +1 -1
- package/dist/client/assets/{dist-CE5BnwJa.js → dist-ZvUavnTX.js} +1 -1
- package/dist/client/assets/{dist-BJinarhP.js → dist-q1T0uV1c.js} +1 -1
- package/dist/client/assets/{dist-C2nuBCP4.js → dist-t0oHfWqq.js} +1 -1
- package/dist/client/assets/{dist-LFfokYJs.js → dist-yOveRczL.js} +1 -1
- package/dist/client/assets/{index-YEN4ysHF.js → index-oDRgSKPX.js} +37 -37
- package/dist/client/assets/index-r-L_XCYs.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +18 -2
- package/dist/server/active-memory.js +73 -8
- package/dist/server/application/conversation-service.js +4 -2
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +114 -93
- package/dist/server/application/scheduled-task-service.d.ts +7 -0
- package/dist/server/application/scheduled-task-service.js +14 -5
- package/dist/server/application/workspace-service.d.ts +8 -4
- package/dist/server/application/workspace-service.js +11 -10
- package/dist/server/failure-text.d.ts +18 -0
- package/dist/server/failure-text.js +44 -0
- package/dist/server/files.js +11 -1
- package/dist/server/http-server.js +16 -5
- package/dist/server/index.js +5 -2
- package/dist/server/knowledge-embedding-worker.d.ts +1 -0
- package/dist/server/knowledge-embedding-worker.js +27 -0
- package/dist/server/knowledge-vectors.d.ts +5 -3
- package/dist/server/knowledge-vectors.js +49 -47
- package/dist/server/knowledge.d.ts +1 -0
- package/dist/server/knowledge.js +6 -2
- package/dist/server/model-filter.d.ts +13 -3
- package/dist/server/model-filter.js +7 -5
- package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
- package/dist/server/persistence/sqlite-state-repository.js +37 -1
- package/dist/server/providers.d.ts +13 -0
- package/dist/server/providers.js +27 -2
- package/dist/server/runtime.js +46 -0
- package/dist/server/store.d.ts +83 -0
- package/dist/server/store.js +140 -14
- package/dist/server/subagents.js +10 -2
- package/dist/server/types.d.ts +10 -0
- package/dist/server/ws-server.d.ts +3 -0
- package/dist/server/ws-server.js +35 -6
- package/package.json +21 -2
- package/dist/client/assets/index-D3MEG8uH.css +0 -1
package/dist/server/providers.js
CHANGED
|
@@ -741,6 +741,7 @@ export async function fetchRemoteModels(input) {
|
|
|
741
741
|
candidates.push(`${cleanBase}/v1/models`, `${cleanBase}/models`);
|
|
742
742
|
}
|
|
743
743
|
let lastError = "";
|
|
744
|
+
let lastCause;
|
|
744
745
|
let payload = null;
|
|
745
746
|
for (const candidate of candidates) {
|
|
746
747
|
try {
|
|
@@ -758,11 +759,35 @@ export async function fetchRemoteModels(input) {
|
|
|
758
759
|
}
|
|
759
760
|
catch (error) {
|
|
760
761
|
lastError = error instanceof Error ? error.message : String(error);
|
|
762
|
+
lastCause = error;
|
|
761
763
|
}
|
|
762
764
|
}
|
|
763
765
|
if (!payload) {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
+
// THE CAUSE, NOT JUST "fetch failed". `fetch` reports every transport-level problem - a refused
|
|
767
|
+
// connection, a reset, an aborted timeout - as the same one-word "fetch failed", so a user staring
|
|
768
|
+
// at 「无法从远程服务获取模型列表 (fetch failed)」 learns nothing and neither does the log. The
|
|
769
|
+
// cause is where the truth is: "The operation was aborted due to timeout" and "connect
|
|
770
|
+
// ECONNREFUSED" are two different problems with two different fixes.
|
|
771
|
+
const cause = lastCause instanceof Error
|
|
772
|
+
? (lastCause.cause instanceof Error ? lastCause.cause.message : lastCause.cause instanceof Object && lastCause.cause !== null ? String(lastCause.cause.code ?? "") : undefined)
|
|
773
|
+
: undefined;
|
|
774
|
+
throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"}${cause ? `:${cause}` : ""})`);
|
|
775
|
+
}
|
|
776
|
+
return parseRemoteModels(payload);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* THE ANSWER, READ.
|
|
780
|
+
*
|
|
781
|
+
* Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
|
|
782
|
+
* can be tested without a network in between. A remote provider answers in three shapes (a bare array,
|
|
783
|
+
* `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
|
|
784
|
+
* `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
|
|
785
|
+
* images - is inferred from the name, because that is all most providers tell us.
|
|
786
|
+
*
|
|
787
|
+
* `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
|
|
788
|
+
* be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
|
|
789
|
+
*/
|
|
790
|
+
export function parseRemoteModels(payload) {
|
|
766
791
|
const rawList = Array.isArray(payload)
|
|
767
792
|
? payload
|
|
768
793
|
: Array.isArray(payload.data)
|
package/dist/server/runtime.js
CHANGED
|
@@ -941,7 +941,46 @@ export class PiRpcRuntime {
|
|
|
941
941
|
const promptOptions = images?.length
|
|
942
942
|
? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
|
|
943
943
|
: { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
|
|
944
|
+
// A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
|
|
945
|
+
//
|
|
946
|
+
// The window between "the person pressed stop" and "the prompt goes out" is the whole session
|
|
947
|
+
// build: Pi's resource loader, the extensions, the transcript, the model lookup - seconds, on a
|
|
948
|
+
// cold session. The abort listener above already noticed the cancellation, but `session.abort()`
|
|
949
|
+
// on a session that has not started running is a no-op, so nothing stopped the prompt that was
|
|
950
|
+
// about to be sent: the model was asked a question the person had already cancelled, and it
|
|
951
|
+
// answered it - which is why pressing stop looked like it "took effect" only once the reply was
|
|
952
|
+
// over.
|
|
953
|
+
//
|
|
954
|
+
// So the cancellation is checked HERE, at the last moment before the prompt goes out, and a
|
|
955
|
+
// cancelled turn ends with no question asked at all.
|
|
956
|
+
//
|
|
957
|
+
// `turnController.signal.aborted` is the OTHER half of the same fact: the turn's own controller
|
|
958
|
+
// is what `abortTurn()` trips, and it is tripped by the very same cancellation. Both are checked
|
|
959
|
+
// because they are tripped by different readers - one by the person, one by Pi's abort.
|
|
960
|
+
if (signal.aborted || turnController.signal.aborted) {
|
|
961
|
+
queue.push({ kind: "end" });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
944
964
|
await session.prompt(prompt, promptOptions);
|
|
965
|
+
// THE CANCELLATION MAY HAVE ARRIVED WHILE PI WAS PREPARING THE PROMPT.
|
|
966
|
+
//
|
|
967
|
+
// `session.prompt()` is not one step: it runs Pi's input handlers (the extensions' hooks -
|
|
968
|
+
// Super Pi's own memory search is one of them), the compaction check and the auth check before
|
|
969
|
+
// the agent's run starts. An abort that lands in that window finds a session that has NOT
|
|
970
|
+
// started running, so Pi's abort has nothing to abort - and the prompt goes on to the model,
|
|
971
|
+
// which answers a question the person cancelled.
|
|
972
|
+
//
|
|
973
|
+
// The run IS started by the time `prompt()` resolves, so the abort is said AGAIN here. The
|
|
974
|
+
// second one is the one that lands.
|
|
975
|
+
if (signal.aborted) {
|
|
976
|
+
// The same 5s backstop `runtime.stop` uses: a wedged session must not hang the turn forever.
|
|
977
|
+
await Promise.race([
|
|
978
|
+
session.abort().catch((error) => {
|
|
979
|
+
console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} after a late cancellation threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
980
|
+
}),
|
|
981
|
+
new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
|
|
982
|
+
]);
|
|
983
|
+
}
|
|
945
984
|
// `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
|
|
946
985
|
// been ACCEPTED and the run has been started; the agent is still streaming, still executing
|
|
947
986
|
// tools, still settling. A turn that ended here would release the reader while the model was
|
|
@@ -1437,6 +1476,13 @@ export class PiRpcRuntime {
|
|
|
1437
1476
|
const imagePayloads = this.options.uploadsDir && images?.length
|
|
1438
1477
|
? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
|
|
1439
1478
|
: undefined;
|
|
1479
|
+
// A STOP THAT ARRIVED WHILE THE CHILD WAS STARTING MUST NOT SEND THE PROMPT.
|
|
1480
|
+
//
|
|
1481
|
+
// The same gap the session path has, and the same reason: `abort` above writes an `abort`
|
|
1482
|
+
// command to a child that has not been given anything yet, so it does nothing - and the prompt
|
|
1483
|
+
// went out anyway, and the model answered a question the person had already cancelled.
|
|
1484
|
+
if (signal.aborted)
|
|
1485
|
+
return;
|
|
1440
1486
|
channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
|
|
1441
1487
|
traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
|
|
1442
1488
|
let settled = false;
|
package/dist/server/store.d.ts
CHANGED
|
@@ -11,6 +11,16 @@ import type { AppState, Conversation, Message, MessageDelivery, MessageImage, Mo
|
|
|
11
11
|
* disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
|
|
12
12
|
* therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
|
|
13
13
|
export declare function newConversationId(): string;
|
|
14
|
+
/**
|
|
15
|
+
* ONE FOLDER, ONE KEY.
|
|
16
|
+
*
|
|
17
|
+
* The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
|
|
18
|
+
* `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
|
|
19
|
+
* The key folds the separators and the trailing slash away, and folds case away only where the
|
|
20
|
+
* filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
|
|
21
|
+
* does not, and pretending otherwise would merge two real projects into one.
|
|
22
|
+
*/
|
|
23
|
+
export declare function workspacePathKey(path: string): string;
|
|
14
24
|
/**
|
|
15
25
|
* How many SETTLED sub-agent runs `state.json` keeps, so the file cannot grow without bound.
|
|
16
26
|
*
|
|
@@ -86,6 +96,28 @@ export declare class Store {
|
|
|
86
96
|
static open(filePath: string): Promise<Store>;
|
|
87
97
|
/** Opens an injected repository, retaining domain reconciliation here. */
|
|
88
98
|
static openRepository(repository: AppStateRepository): Promise<Store>;
|
|
99
|
+
/**
|
|
100
|
+
* THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
|
|
101
|
+
*
|
|
102
|
+
* `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
|
|
103
|
+
* message of every conversation - and the model filter check needs ONE small array. Reading that
|
|
104
|
+
* array through a full clone is the difference between a few hundred bytes and the entire
|
|
105
|
+
* transcript of the app, on the path that runs every time a message is sent.
|
|
106
|
+
*
|
|
107
|
+
* So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
|
|
108
|
+
* it, and the store's own state must not change under it), and the individual rules are treated
|
|
109
|
+
* as read-only - which is what the filter does with them.
|
|
110
|
+
*/
|
|
111
|
+
modelFilterRules(): import("./types.js").ModelFilterRule[];
|
|
112
|
+
/**
|
|
113
|
+
* THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
|
|
114
|
+
*
|
|
115
|
+
* The same argument as the rules above: the filter needs to know WHICH workspace the conversation
|
|
116
|
+
* is in (the rules are per workspace), and the only way to know that today is to clone everything
|
|
117
|
+
* and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
|
|
118
|
+
* caller cannot reach into the store's state through it.
|
|
119
|
+
*/
|
|
120
|
+
workspaceForConversation(conversationId: string): Workspace | undefined;
|
|
89
121
|
setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
|
|
90
122
|
/**
|
|
91
123
|
* Sets (or clears) the app-wide default model.
|
|
@@ -101,6 +133,15 @@ export declare class Store {
|
|
|
101
133
|
setDefaultModel(model?: ModelSelection): Promise<void>;
|
|
102
134
|
setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
|
|
103
135
|
snapshot(): AppState;
|
|
136
|
+
/** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
|
|
137
|
+
hasWorkspace(workspaceId: string): boolean;
|
|
138
|
+
conversationStatus(conversationId: string): Conversation["status"] | undefined;
|
|
139
|
+
question(conversationId: string, messageId: string, index: number): ToolCallQuestion | undefined;
|
|
140
|
+
pendingQuestion(conversationId: string): {
|
|
141
|
+
messageId: string;
|
|
142
|
+
index: number;
|
|
143
|
+
question: ToolCallQuestion;
|
|
144
|
+
} | undefined;
|
|
104
145
|
/**
|
|
105
146
|
* Whether a conversation already holds a message with this id.
|
|
106
147
|
*
|
|
@@ -126,6 +167,17 @@ export declare class Store {
|
|
|
126
167
|
conversation: Conversation;
|
|
127
168
|
workspace: Workspace;
|
|
128
169
|
} | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
|
|
172
|
+
*
|
|
173
|
+
* A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
|
|
174
|
+
* the same project, and the app used to mint a fresh UUID for each one. That is what broke
|
|
175
|
+
* re-adding a workspace - the new id matched none of the conversations that belonged to that
|
|
176
|
+
* directory, so the history was unreachable (and, before the soft unmount, actually deleted).
|
|
177
|
+
*
|
|
178
|
+
* So the same folder always means the same workspace id, and the id is what the conversations
|
|
179
|
+
* point at.
|
|
180
|
+
*/
|
|
129
181
|
createWorkspace(name: string, path?: string): Promise<Workspace>;
|
|
130
182
|
renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
|
|
131
183
|
/**
|
|
@@ -151,7 +203,38 @@ export declare class Store {
|
|
|
151
203
|
thinkingLevel?: string;
|
|
152
204
|
agentId?: string | null;
|
|
153
205
|
}): Promise<Workspace>;
|
|
206
|
+
/**
|
|
207
|
+
* TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
|
|
208
|
+
*
|
|
209
|
+
* This used to cascade: the workspace row AND every conversation that pointed at it were erased
|
|
210
|
+
* from the state, in one persist. That made the most natural thing a user could do - remove a
|
|
211
|
+
* workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
|
|
212
|
+
* that project, and there was no way to get it back.
|
|
213
|
+
*
|
|
214
|
+
* So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
|
|
215
|
+
* stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
|
|
216
|
+
* broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
|
|
217
|
+
* workspace - same id, same conversations - which is the whole point of the physical path being
|
|
218
|
+
* the workspace's identity.
|
|
219
|
+
*
|
|
220
|
+
* The returned list is the conversations that were STOPPED, not deleted: a turn that was still
|
|
221
|
+
* running under a workspace that just left the sidebar cannot keep burning a model session
|
|
222
|
+
* behind the user's back, and a pending question would otherwise wait for an answer that the
|
|
223
|
+
* sidebar can no longer reach.
|
|
224
|
+
*/
|
|
154
225
|
deleteWorkspace(workspaceId: string): Promise<string[]>;
|
|
226
|
+
/**
|
|
227
|
+
* THE STATE THE CLIENT IS ALLOWED TO SEE.
|
|
228
|
+
*
|
|
229
|
+
* `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
|
|
230
|
+
* difference is the closed workspaces: one that was removed from the sidebar is still in the store
|
|
231
|
+
* - that is exactly why re-adding the folder brings its history back - but it is not part of the
|
|
232
|
+
* app any more, so neither it nor its conversations are broadcast.
|
|
233
|
+
*
|
|
234
|
+
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
235
|
+
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
236
|
+
*/
|
|
237
|
+
clientState(): AppState;
|
|
155
238
|
createConversation(workspaceId: string, configuration?: {
|
|
156
239
|
agentId?: string;
|
|
157
240
|
model?: ModelSelection | null;
|
package/dist/server/store.js
CHANGED
|
@@ -11,6 +11,19 @@ function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
|
|
|
11
11
|
* disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
|
|
12
12
|
* therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
|
|
13
13
|
export function newConversationId() { return id("conversation"); }
|
|
14
|
+
/**
|
|
15
|
+
* ONE FOLDER, ONE KEY.
|
|
16
|
+
*
|
|
17
|
+
* The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
|
|
18
|
+
* `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
|
|
19
|
+
* The key folds the separators and the trailing slash away, and folds case away only where the
|
|
20
|
+
* filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
|
|
21
|
+
* does not, and pretending otherwise would merge two real projects into one.
|
|
22
|
+
*/
|
|
23
|
+
export function workspacePathKey(path) {
|
|
24
|
+
const folded = path.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
25
|
+
return process.platform === "linux" ? folded : folded.toLowerCase();
|
|
26
|
+
}
|
|
14
27
|
function now() { return new Date().toISOString(); }
|
|
15
28
|
const DEFAULT_CONVERSATION_TITLE = "\u65b0\u4f1a\u8bdd";
|
|
16
29
|
/** Whether a value survives a `new Date()` round-trip. `createdAt` is typed string, but state.json only
|
|
@@ -676,6 +689,34 @@ export class Store {
|
|
|
676
689
|
return new Store(repository, { workspaces: [], conversations: [], subagentRuns: [], backgroundTasks: [] });
|
|
677
690
|
}
|
|
678
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
|
|
694
|
+
*
|
|
695
|
+
* `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
|
|
696
|
+
* message of every conversation - and the model filter check needs ONE small array. Reading that
|
|
697
|
+
* array through a full clone is the difference between a few hundred bytes and the entire
|
|
698
|
+
* transcript of the app, on the path that runs every time a message is sent.
|
|
699
|
+
*
|
|
700
|
+
* So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
|
|
701
|
+
* it, and the store's own state must not change under it), and the individual rules are treated
|
|
702
|
+
* as read-only - which is what the filter does with them.
|
|
703
|
+
*/
|
|
704
|
+
modelFilterRules() {
|
|
705
|
+
return this.state.modelFilterRules ? [...this.state.modelFilterRules] : [];
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
|
|
709
|
+
*
|
|
710
|
+
* The same argument as the rules above: the filter needs to know WHICH workspace the conversation
|
|
711
|
+
* is in (the rules are per workspace), and the only way to know that today is to clone everything
|
|
712
|
+
* and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
|
|
713
|
+
* caller cannot reach into the store's state through it.
|
|
714
|
+
*/
|
|
715
|
+
workspaceForConversation(conversationId) {
|
|
716
|
+
const conversation = this.state.conversations.find((item) => item.id === conversationId);
|
|
717
|
+
const workspace = this.state.workspaces.find((item) => item.id === conversation?.workspaceId);
|
|
718
|
+
return workspace ? { ...workspace } : undefined;
|
|
719
|
+
}
|
|
679
720
|
async setModelFilterRules(rules) {
|
|
680
721
|
if (!Array.isArray(rules) || rules.some(rule => !rule || typeof rule.id !== "string" || typeof rule.workspacePattern !== "string" || typeof rule.modelPattern !== "string" || (rule.label !== undefined && typeof rule.label !== "string")))
|
|
681
722
|
throw new Error("模型过滤规则格式不合法");
|
|
@@ -735,6 +776,35 @@ export class Store {
|
|
|
735
776
|
console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
|
|
736
777
|
return value;
|
|
737
778
|
}
|
|
779
|
+
/** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
|
|
780
|
+
hasWorkspace(workspaceId) {
|
|
781
|
+
return this.state.workspaces.some((workspace) => workspace.id === workspaceId);
|
|
782
|
+
}
|
|
783
|
+
conversationStatus(conversationId) {
|
|
784
|
+
return this.state.conversations.find((conversation) => conversation.id === conversationId)?.status;
|
|
785
|
+
}
|
|
786
|
+
question(conversationId, messageId, index) {
|
|
787
|
+
const question = this.state.conversations.find((conversation) => conversation.id === conversationId)
|
|
788
|
+
?.messages.find((message) => message.id === messageId)?.toolCalls?.[index]?.question;
|
|
789
|
+
return question ? structuredClone(question) : undefined;
|
|
790
|
+
}
|
|
791
|
+
pendingQuestion(conversationId) {
|
|
792
|
+
const conversation = this.state.conversations.find((item) => item.id === conversationId);
|
|
793
|
+
if (!conversation)
|
|
794
|
+
return undefined;
|
|
795
|
+
for (let messageIndex = conversation.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
796
|
+
const message = conversation.messages[messageIndex];
|
|
797
|
+
if (message.role !== "assistant")
|
|
798
|
+
continue;
|
|
799
|
+
for (let index = (message.toolCalls?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
800
|
+
const question = message.toolCalls?.[index]?.question;
|
|
801
|
+
if (message.toolCalls?.[index]?.label === "question" && question?.status === "pending") {
|
|
802
|
+
return { messageId: message.id, index, question: structuredClone(question) };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
738
808
|
/**
|
|
739
809
|
* Whether a conversation already holds a message with this id.
|
|
740
810
|
*
|
|
@@ -767,6 +837,17 @@ export class Store {
|
|
|
767
837
|
return undefined;
|
|
768
838
|
return { conversation: structuredClone(conversation), workspace: structuredClone(workspace) };
|
|
769
839
|
}
|
|
840
|
+
/**
|
|
841
|
+
* THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
|
|
842
|
+
*
|
|
843
|
+
* A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
|
|
844
|
+
* the same project, and the app used to mint a fresh UUID for each one. That is what broke
|
|
845
|
+
* re-adding a workspace - the new id matched none of the conversations that belonged to that
|
|
846
|
+
* directory, so the history was unreachable (and, before the soft unmount, actually deleted).
|
|
847
|
+
*
|
|
848
|
+
* So the same folder always means the same workspace id, and the id is what the conversations
|
|
849
|
+
* point at.
|
|
850
|
+
*/
|
|
770
851
|
async createWorkspace(name, path) {
|
|
771
852
|
const cleanName = name.trim();
|
|
772
853
|
if (!cleanName || cleanName.length > 80)
|
|
@@ -774,6 +855,19 @@ export class Store {
|
|
|
774
855
|
const cleanPath = path?.trim() || process.cwd();
|
|
775
856
|
if (cleanPath.length > 500)
|
|
776
857
|
throw new Error("工作区路径太长了。");
|
|
858
|
+
// THE SAME FOLDER IS THE SAME WORKSPACE. Adding a directory that is already here re-opens it
|
|
859
|
+
// instead of forking a second one: the id stays, the conversations stay attached to it, and the
|
|
860
|
+
// name is left alone (renaming is an explicit action elsewhere, and silently renaming a
|
|
861
|
+
// workspace because someone typed a name into the add dialog would be its own surprise).
|
|
862
|
+
const existing = this.state.workspaces.find((item) => workspacePathKey(item.path) === workspacePathKey(cleanPath));
|
|
863
|
+
if (existing) {
|
|
864
|
+
if (existing.closedAt) {
|
|
865
|
+
delete existing.closedAt;
|
|
866
|
+
existing.updatedAt = now();
|
|
867
|
+
await this.persist();
|
|
868
|
+
}
|
|
869
|
+
return structuredClone(existing);
|
|
870
|
+
}
|
|
777
871
|
const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
|
|
778
872
|
this.state.workspaces.push(workspace);
|
|
779
873
|
await this.persist();
|
|
@@ -830,23 +924,55 @@ export class Store {
|
|
|
830
924
|
await this.persist();
|
|
831
925
|
return structuredClone(workspace);
|
|
832
926
|
}
|
|
927
|
+
/**
|
|
928
|
+
* TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
|
|
929
|
+
*
|
|
930
|
+
* This used to cascade: the workspace row AND every conversation that pointed at it were erased
|
|
931
|
+
* from the state, in one persist. That made the most natural thing a user could do - remove a
|
|
932
|
+
* workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
|
|
933
|
+
* that project, and there was no way to get it back.
|
|
934
|
+
*
|
|
935
|
+
* So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
|
|
936
|
+
* stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
|
|
937
|
+
* broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
|
|
938
|
+
* workspace - same id, same conversations - which is the whole point of the physical path being
|
|
939
|
+
* the workspace's identity.
|
|
940
|
+
*
|
|
941
|
+
* The returned list is the conversations that were STOPPED, not deleted: a turn that was still
|
|
942
|
+
* running under a workspace that just left the sidebar cannot keep burning a model session
|
|
943
|
+
* behind the user's back, and a pending question would otherwise wait for an answer that the
|
|
944
|
+
* sidebar can no longer reach.
|
|
945
|
+
*/
|
|
833
946
|
async deleteWorkspace(workspaceId) {
|
|
834
|
-
|
|
947
|
+
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
|
948
|
+
if (!workspace)
|
|
835
949
|
throw new Error("找不到这个工作区。");
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
// both together. A waiting message needs no sweep of its own any more - it is a message inside one of
|
|
841
|
-
// the conversations being removed, so it goes with its container, which is the whole reason it is a
|
|
842
|
-
// better shape than an entry keyed by conversation id in a separate array: that array needed this
|
|
843
|
-
// cascade to avoid naming a conversation that no longer exists, and could not have been trusted to
|
|
844
|
-
// get it right on its own.
|
|
845
|
-
const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
|
|
846
|
-
this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
|
|
847
|
-
this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
|
|
950
|
+
if (workspace.closedAt)
|
|
951
|
+
return [];
|
|
952
|
+
workspace.closedAt = now();
|
|
953
|
+
workspace.updatedAt = workspace.closedAt;
|
|
848
954
|
await this.persist();
|
|
849
|
-
return [
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* THE STATE THE CLIENT IS ALLOWED TO SEE.
|
|
959
|
+
*
|
|
960
|
+
* `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
|
|
961
|
+
* difference is the closed workspaces: one that was removed from the sidebar is still in the store
|
|
962
|
+
* - that is exactly why re-adding the folder brings its history back - but it is not part of the
|
|
963
|
+
* app any more, so neither it nor its conversations are broadcast.
|
|
964
|
+
*
|
|
965
|
+
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
966
|
+
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
967
|
+
*/
|
|
968
|
+
clientState() {
|
|
969
|
+
const state = this.snapshot();
|
|
970
|
+
const closed = new Set(state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
|
|
971
|
+
if (!closed.size)
|
|
972
|
+
return state;
|
|
973
|
+
state.workspaces = state.workspaces.filter((item) => !closed.has(item.id));
|
|
974
|
+
state.conversations = state.conversations.filter((item) => !closed.has(item.workspaceId));
|
|
975
|
+
return state;
|
|
850
976
|
}
|
|
851
977
|
async createConversation(workspaceId, configuration) {
|
|
852
978
|
const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
|
package/dist/server/subagents.js
CHANGED
|
@@ -421,8 +421,16 @@ export class SubagentSupervisor {
|
|
|
421
421
|
}
|
|
422
422
|
if (!record)
|
|
423
423
|
break;
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
// THE RECEIPT FOR OUR OWN PROMPT IS A KNOWN RECORD, NOT AN UNKNOWN ONE. Pi answers every
|
|
425
|
+
// command with `type:"response"`, so the successful receipt for the prompt we just wrote is
|
|
426
|
+
// part of the protocol - and treating it as unrecognised made every ordinary sub-agent run
|
|
427
|
+
// log a false alarm ("unhandled child record response - consumed without action"). A FAILED
|
|
428
|
+
// receipt is the run's failure and is thrown; a successful one is simply consumed.
|
|
429
|
+
if (record.type === "response" && record.id === "prompt") {
|
|
430
|
+
if (record.success === false)
|
|
431
|
+
throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
426
434
|
if (record.type === "message_update") {
|
|
427
435
|
const event = record.assistantMessageEvent;
|
|
428
436
|
// `text_delta` is the only inner event that is output. `start` and `done` are the boundaries
|
package/dist/server/types.d.ts
CHANGED
|
@@ -586,6 +586,12 @@ export type AgentCatalog = {
|
|
|
586
586
|
* the conversation's own field, an existing conversation is therefore immune to later workspace
|
|
587
587
|
* edits, and deleting the workspace default cannot reach back into sessions already born.
|
|
588
588
|
*/
|
|
589
|
+
/**
|
|
590
|
+
* `closedAt` marks a workspace that was REMOVED FROM THE SIDEBAR but is not gone: its conversations
|
|
591
|
+
* are still on disk and still belong to it. The removal is a soft unmount, exactly like a session's
|
|
592
|
+
* `archivedAt` - a state marker, never a deletion - and the marker is what lets the same physical
|
|
593
|
+
* directory be added again later and come back with its history instead of with an empty list.
|
|
594
|
+
*/
|
|
589
595
|
export type Workspace = {
|
|
590
596
|
id: string;
|
|
591
597
|
name: string;
|
|
@@ -594,6 +600,7 @@ export type Workspace = {
|
|
|
594
600
|
defaultModel?: ModelSelection;
|
|
595
601
|
defaultThinkingLevel?: string;
|
|
596
602
|
defaultAgentId?: string;
|
|
603
|
+
closedAt?: string;
|
|
597
604
|
};
|
|
598
605
|
/**
|
|
599
606
|
* Usage accumulated across one sub-agent run. `turns` is the number of finished assistant messages,
|
|
@@ -1469,6 +1476,9 @@ export type ChangedFile = {
|
|
|
1469
1476
|
at: number;
|
|
1470
1477
|
};
|
|
1471
1478
|
export type ServerEvent = {
|
|
1479
|
+
type: "run.stopping";
|
|
1480
|
+
conversationId: string;
|
|
1481
|
+
} | {
|
|
1472
1482
|
type: "tools.updated";
|
|
1473
1483
|
tools: MountedToolSummary[];
|
|
1474
1484
|
}
|
|
@@ -13,6 +13,9 @@ export declare class WsBroadcaster implements EventBroadcaster {
|
|
|
13
13
|
private readonly sockets;
|
|
14
14
|
private readonly coalescedSockets;
|
|
15
15
|
private readonly pendingStreams;
|
|
16
|
+
private cachedState?;
|
|
17
|
+
private cachedStatePayload?;
|
|
18
|
+
private stateFlushScheduled;
|
|
16
19
|
constructor(getState: () => AppState);
|
|
17
20
|
register(socket: WebSocket): void;
|
|
18
21
|
unregister(socket: WebSocket): void;
|
package/dist/server/ws-server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from "ws";
|
|
2
2
|
import { isAuthorized } from "./http-server.js";
|
|
3
|
+
import { describeThrown, describeThrownWithStack } from "./failure-text.js";
|
|
3
4
|
/**
|
|
4
5
|
* Concrete implementation of EventBroadcaster delivering server events
|
|
5
6
|
* across active WebSocket connections.
|
|
@@ -9,6 +10,9 @@ export class WsBroadcaster {
|
|
|
9
10
|
sockets = new Set();
|
|
10
11
|
coalescedSockets = new Set();
|
|
11
12
|
pendingStreams = new Map();
|
|
13
|
+
cachedState;
|
|
14
|
+
cachedStatePayload;
|
|
15
|
+
stateFlushScheduled = false;
|
|
12
16
|
constructor(getState) {
|
|
13
17
|
this.getState = getState;
|
|
14
18
|
}
|
|
@@ -90,7 +94,23 @@ export class WsBroadcaster {
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
broadcastState() {
|
|
93
|
-
|
|
97
|
+
if (this.stateFlushScheduled)
|
|
98
|
+
return;
|
|
99
|
+
this.stateFlushScheduled = true;
|
|
100
|
+
queueMicrotask(() => {
|
|
101
|
+
this.stateFlushScheduled = false;
|
|
102
|
+
this.cachedState = this.getState();
|
|
103
|
+
this.cachedStatePayload = JSON.stringify({ type: "state", state: this.cachedState });
|
|
104
|
+
const payload = this.cachedStatePayload;
|
|
105
|
+
for (const socket of this.sockets) {
|
|
106
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
107
|
+
continue;
|
|
108
|
+
this.flushStreaming(socket);
|
|
109
|
+
socket.send(payload);
|
|
110
|
+
}
|
|
111
|
+
this.cachedState = undefined;
|
|
112
|
+
this.cachedStatePayload = undefined;
|
|
113
|
+
});
|
|
94
114
|
}
|
|
95
115
|
getStateSnapshot() {
|
|
96
116
|
return this.getState();
|
|
@@ -228,7 +248,8 @@ export class WsServer {
|
|
|
228
248
|
parsed = JSON.parse(raw.toString());
|
|
229
249
|
}
|
|
230
250
|
catch {
|
|
231
|
-
|
|
251
|
+
console.error("Super Pi command error: received a frame that is not valid JSON");
|
|
252
|
+
socket.send(JSON.stringify({ type: "error", message: "收到一条无法解析的命令,已忽略。" }));
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
234
255
|
if (parsed.type === "stream.configure") {
|
|
@@ -264,13 +285,21 @@ export class WsServer {
|
|
|
264
285
|
}
|
|
265
286
|
}
|
|
266
287
|
catch (error) {
|
|
288
|
+
// A FAILED COMMAND WRITES ITSELF DOWN BEFORE IT ANSWERS.
|
|
289
|
+
//
|
|
290
|
+
// The server runs detached with its streams thrown away, so `console.error` here is not
|
|
291
|
+
// decoration: the logger tees it into `<dataDir>/logs/server.log`, which is the only place
|
|
292
|
+
// the stack survives. The browser gets the reason (never a stand-in phrase like
|
|
293
|
+
// `command failed`), because the reader is the one who has to act on it.
|
|
294
|
+
console.error("Super Pi command error:", describeThrownWithStack(error));
|
|
295
|
+
const reason = describeThrown(error);
|
|
267
296
|
if (command?.operationRequestId) {
|
|
268
|
-
socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message:
|
|
297
|
+
socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: reason }));
|
|
269
298
|
return;
|
|
270
299
|
}
|
|
271
300
|
socket.send(JSON.stringify({
|
|
272
301
|
type: "error",
|
|
273
|
-
message:
|
|
302
|
+
message: reason || "服务端没有说明这次失败的原因。",
|
|
274
303
|
}));
|
|
275
304
|
}
|
|
276
305
|
finally {
|
|
@@ -284,11 +313,11 @@ export class WsServer {
|
|
|
284
313
|
};
|
|
285
314
|
if (runIndependently)
|
|
286
315
|
void execute().catch((error) => {
|
|
287
|
-
console.
|
|
316
|
+
console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
|
|
288
317
|
});
|
|
289
318
|
else
|
|
290
319
|
commandQueue = commandQueue.then(execute).catch((error) => {
|
|
291
|
-
console.
|
|
320
|
+
console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
|
|
292
321
|
});
|
|
293
322
|
});
|
|
294
323
|
});
|