@evandu/super-pi 0.1.133 → 0.1.135
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-D6awRUZO.js} +2 -2
- package/dist/client/assets/{dist-azDN8a65.js → dist-1jSyapdW.js} +1 -1
- package/dist/client/assets/{dist-CE5BnwJa.js → dist-BGgidsaF.js} +1 -1
- package/dist/client/assets/{dist-DOYWHMIq.js → dist-BYMYC_2I.js} +1 -1
- package/dist/client/assets/{dist-CYD2SqmG.js → dist-BkVaPA_q.js} +1 -1
- package/dist/client/assets/{dist-CxsunljU.js → dist-BoeM5xW7.js} +1 -1
- package/dist/client/assets/{dist-D4x1XTQb.js → dist-C46474ys.js} +1 -1
- package/dist/client/assets/{dist-BJinarhP.js → dist-CCkdCH6a.js} +1 -1
- package/dist/client/assets/{dist-DQf5nNxa.js → dist-CNuKauOq.js} +1 -1
- package/dist/client/assets/{dist-CApAttfj.js → dist-CubYGjAk.js} +1 -1
- package/dist/client/assets/{dist-0FH-oFco.js → dist-Cwb-Lb5-.js} +1 -1
- package/dist/client/assets/{dist-CFPXj4ws.js → dist-D0Iw5XQA.js} +1 -1
- package/dist/client/assets/{dist-CUKaDTyO.js → dist-DCiuVvGU.js} +1 -1
- package/dist/client/assets/{dist-B5ruVtUG.js → dist-DHBWUQhM.js} +1 -1
- package/dist/client/assets/{dist-BtTFCNVQ.js → dist-DOwrSiuY.js} +1 -1
- package/dist/client/assets/{dist-4Pts9xPO.js → dist-DeZsNbFx.js} +1 -1
- package/dist/client/assets/{dist-aL2NGji_.js → dist-Dk3e6RfY.js} +1 -1
- package/dist/client/assets/{dist-LFfokYJs.js → dist-DoDmoWTn.js} +1 -1
- package/dist/client/assets/{dist-DbbBgSwL.js → dist-WkVUFX28.js} +1 -1
- package/dist/client/assets/{dist-C2nuBCP4.js → dist-lZUWPQbT.js} +1 -1
- package/dist/client/assets/{dist-DHqqpxo4.js → dist-mPR8uuzk.js} +1 -1
- package/dist/client/assets/{dist-fYp8Xwrx.js → dist-spB2Q2CG.js} +1 -1
- package/dist/client/assets/{dist-BffE00vI.js → dist-yTTCht0J.js} +1 -1
- package/dist/client/assets/{index-YEN4ysHF.js → index-Dyta-HkQ.js} +37 -37
- package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +1 -0
- package/dist/server/active-memory.js +6 -2
- package/dist/server/application/run-service.d.ts +4 -0
- package/dist/server/application/run-service.js +114 -93
- 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/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/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 +61 -0
- package/dist/server/store.js +112 -14
- 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 +16 -2
package/dist/server/index.js
CHANGED
|
@@ -137,7 +137,10 @@ const paths = {
|
|
|
137
137
|
const runtime = process.env.SUPER_PI_RUNTIME === "welcome"
|
|
138
138
|
? new WelcomeRuntime()
|
|
139
139
|
: new PiRpcRuntime(join(dataDir, "pi-sessions"), undefined, { dir: initialAgentDirs, promptDir: agentPromptDir }, { endpoint: taskEndpoint, execEndpoint, todoEndpoint, workflowEndpoint, questionEndpoint, toolsEndpoint, documentsEndpoint, scheduledTaskEndpoint, uploadsDir });
|
|
140
|
-
|
|
140
|
+
// THE CLIENT GETS THE VISIBLE STATE, NOT THE WHOLE STORE. `clientState()` is `snapshot()` minus the
|
|
141
|
+
// workspaces that were taken out of the sidebar - they are still in the store (that is what lets the
|
|
142
|
+
// same folder be added back with its history), but they are not part of the app any more.
|
|
143
|
+
const broadcaster = new WsBroadcaster(() => store.clientState());
|
|
141
144
|
const memory = new ActiveMemory(join(dataDir, "memories"), store, runtime);
|
|
142
145
|
process.env.SUPER_PI_MEMORY_ENDPOINT = `http://${internalHost}:${port}/api/memory/tool`;
|
|
143
146
|
if (runtime instanceof PiRpcRuntime)
|
|
@@ -243,7 +246,7 @@ const nameIndexes = new Map();
|
|
|
243
246
|
runService.setNameIndexes(nameIndexes);
|
|
244
247
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
245
248
|
conversationService.setMemory(memory);
|
|
246
|
-
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService,
|
|
249
|
+
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
247
250
|
/**
|
|
248
251
|
* The terminal, constructed before `serverContext` because that object carries the two predicates
|
|
249
252
|
* `/api/terminal` answers with.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
let extractor;
|
|
3
|
+
let loading;
|
|
4
|
+
async function load(request) {
|
|
5
|
+
if (extractor)
|
|
6
|
+
return extractor;
|
|
7
|
+
loading ??= (async () => {
|
|
8
|
+
const specifier = "@huggingface/transformers";
|
|
9
|
+
const transformers = await import(specifier);
|
|
10
|
+
transformers.env.localModelPath = request.modelRoot;
|
|
11
|
+
transformers.env.allowRemoteModels = false;
|
|
12
|
+
extractor = await transformers.pipeline("feature-extraction", request.model, { dtype: "q8" });
|
|
13
|
+
return extractor;
|
|
14
|
+
})();
|
|
15
|
+
return loading;
|
|
16
|
+
}
|
|
17
|
+
parentPort?.on("message", async (request) => {
|
|
18
|
+
try {
|
|
19
|
+
const run = await load(request);
|
|
20
|
+
const output = await run(request.texts, { pooling: "cls", normalize: true });
|
|
21
|
+
const buffer = output.data.buffer;
|
|
22
|
+
parentPort?.postMessage({ id: request.id, data: output.data, dims: output.dims }, [buffer]);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
parentPort?.postMessage({ id: request.id, error: error instanceof Error ? error.message : String(error) });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
@@ -59,8 +59,10 @@ export type VectorDocument = {
|
|
|
59
59
|
*/
|
|
60
60
|
export declare class VectorIndex {
|
|
61
61
|
private readonly directory;
|
|
62
|
-
private
|
|
63
|
-
private
|
|
62
|
+
private worker?;
|
|
63
|
+
private workerSequence;
|
|
64
|
+
private workerReady;
|
|
65
|
+
private workerPending;
|
|
64
66
|
private failure?;
|
|
65
67
|
private cache;
|
|
66
68
|
private pending;
|
|
@@ -91,7 +93,7 @@ export declare class VectorIndex {
|
|
|
91
93
|
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
92
94
|
* been online since it was installed.
|
|
93
95
|
*/
|
|
94
|
-
private
|
|
96
|
+
private ensureWorker;
|
|
95
97
|
private embed;
|
|
96
98
|
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
97
99
|
private read;
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
+
import { Worker } from "node:worker_threads";
|
|
5
6
|
/**
|
|
6
7
|
* The model, and the only one this feature can use.
|
|
7
8
|
*
|
|
@@ -78,8 +79,10 @@ export function cosine(a, b) {
|
|
|
78
79
|
*/
|
|
79
80
|
export class VectorIndex {
|
|
80
81
|
directory;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
worker;
|
|
83
|
+
workerSequence = 0;
|
|
84
|
+
workerReady = false;
|
|
85
|
+
workerPending = new Map();
|
|
83
86
|
failure;
|
|
84
87
|
cache = new Map();
|
|
85
88
|
pending = new Set();
|
|
@@ -100,7 +103,7 @@ export class VectorIndex {
|
|
|
100
103
|
enabled() { return process.env.SUPER_PI_KNOWLEDGE_EMBEDDINGS !== "0"; }
|
|
101
104
|
status(indexed) {
|
|
102
105
|
return {
|
|
103
|
-
enabled: this.enabled(), ready:
|
|
106
|
+
enabled: this.enabled(), ready: this.workerReady, indexed, error: this.failure,
|
|
104
107
|
// Whether the shipped weights are on disk, reported before the model is ever loaded: "the
|
|
105
108
|
// package was built without its assets" and "the model failed to load" are different problems.
|
|
106
109
|
bundled: existsSync(this.bundledModel()),
|
|
@@ -119,57 +122,56 @@ export class VectorIndex {
|
|
|
119
122
|
* weights turns a failure mode into a file, and makes the feature work on a machine that has never
|
|
120
123
|
* been online since it was installed.
|
|
121
124
|
*/
|
|
122
|
-
|
|
123
|
-
if (this.
|
|
124
|
-
return
|
|
125
|
+
ensureWorker() {
|
|
126
|
+
if (this.worker)
|
|
127
|
+
return this.worker;
|
|
125
128
|
if (this.failure)
|
|
126
|
-
return
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
env.allowRemoteModels = false;
|
|
143
|
-
this.extractor = await pipeline("feature-extraction", KNOWLEDGE_MODEL, { dtype: "q8" });
|
|
144
|
-
return this.extractor;
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
this.failure = error instanceof Error ? error.message : String(error);
|
|
148
|
-
console.warn(`Super Pi: 语义检索不可用,将只用关键词检索:${this.failure}`);
|
|
149
|
-
return undefined;
|
|
150
|
-
}
|
|
151
|
-
finally {
|
|
152
|
-
this.loading = undefined;
|
|
129
|
+
return undefined;
|
|
130
|
+
if (!existsSync(this.bundledModel())) {
|
|
131
|
+
this.failure = `随包分发的模型缺失:${this.bundledModel()}`;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const worker = new Worker(new URL("./knowledge-embedding-worker.js", import.meta.url));
|
|
135
|
+
worker.unref();
|
|
136
|
+
worker.on("message", (message) => {
|
|
137
|
+
const pending = this.workerPending.get(message.id);
|
|
138
|
+
if (!pending)
|
|
139
|
+
return;
|
|
140
|
+
this.workerPending.delete(message.id);
|
|
141
|
+
if (message.error || !message.data || !message.dims) {
|
|
142
|
+
this.failure = message.error ?? "向量 worker 返回了无效结果";
|
|
143
|
+
pending.resolve(null);
|
|
144
|
+
return;
|
|
153
145
|
}
|
|
154
|
-
|
|
155
|
-
|
|
146
|
+
this.workerReady = true;
|
|
147
|
+
const dimension = message.dims.at(-1) ?? 0;
|
|
148
|
+
pending.resolve(Array.from({ length: message.dims[0] ?? 0 }, (_, row) => Array.from(message.data.slice(row * dimension, (row + 1) * dimension))));
|
|
149
|
+
});
|
|
150
|
+
const fail = (error) => {
|
|
151
|
+
this.failure = error.message;
|
|
152
|
+
for (const pending of this.workerPending.values())
|
|
153
|
+
pending.resolve(null);
|
|
154
|
+
this.workerPending.clear();
|
|
155
|
+
this.worker = undefined;
|
|
156
|
+
this.workerReady = false;
|
|
157
|
+
};
|
|
158
|
+
worker.on("error", fail);
|
|
159
|
+
worker.on("exit", (code) => { if (code !== 0)
|
|
160
|
+
fail(new Error(`向量 worker 异常退出(${code})`)); });
|
|
161
|
+
this.worker = worker;
|
|
162
|
+
return worker;
|
|
156
163
|
}
|
|
157
164
|
async embed(texts) {
|
|
158
165
|
if (!texts.length || !this.enabled())
|
|
159
166
|
return null;
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
167
|
+
const worker = this.ensureWorker();
|
|
168
|
+
if (!worker)
|
|
162
169
|
return null;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
this.failure = error instanceof Error ? error.message : String(error);
|
|
170
|
-
console.warn(`Super Pi: 生成向量失败,将只用关键词检索:${this.failure}`);
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
170
|
+
const id = ++this.workerSequence;
|
|
171
|
+
return new Promise((resolvePromise) => {
|
|
172
|
+
this.workerPending.set(id, { resolve: resolvePromise });
|
|
173
|
+
worker.postMessage({ id, texts, modelRoot: this.bundledModel(), model: KNOWLEDGE_MODEL });
|
|
174
|
+
});
|
|
173
175
|
}
|
|
174
176
|
/** Vectors already stored for one workspace, keyed by document then chunk index. */
|
|
175
177
|
async read(workspaceId, documentId) {
|
|
@@ -125,6 +125,7 @@ export declare class KnowledgeBase {
|
|
|
125
125
|
private indexes;
|
|
126
126
|
private serial;
|
|
127
127
|
constructor(directory: string, store: Store, runtime?: KnowledgeRuntime | undefined, vectors?: VectorIndex | undefined);
|
|
128
|
+
private hasWorkspace;
|
|
128
129
|
/** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
|
|
129
130
|
enabled(): boolean;
|
|
130
131
|
/** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
|
package/dist/server/knowledge.js
CHANGED
|
@@ -253,13 +253,17 @@ export class KnowledgeBase {
|
|
|
253
253
|
this.runtime = runtime;
|
|
254
254
|
this.vectors = vectors;
|
|
255
255
|
}
|
|
256
|
+
hasWorkspace(workspaceId) {
|
|
257
|
+
const store = this.store;
|
|
258
|
+
return store.hasWorkspace ? store.hasWorkspace(workspaceId) : store.snapshot().workspaces.some(workspace => workspace.id === workspaceId);
|
|
259
|
+
}
|
|
256
260
|
/** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
|
|
257
261
|
enabled() { return process.env.SUPER_PI_KNOWLEDGE_ENABLED !== "0"; }
|
|
258
262
|
/** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
|
|
259
263
|
folder(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
|
|
260
264
|
file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.json`); }
|
|
261
265
|
requireWorkspace(workspaceId) {
|
|
262
|
-
if (!this.
|
|
266
|
+
if (!this.hasWorkspace(workspaceId))
|
|
263
267
|
throw new Error("工作区不存在");
|
|
264
268
|
}
|
|
265
269
|
async locked(work) {
|
|
@@ -710,7 +714,7 @@ export class KnowledgeBase {
|
|
|
710
714
|
const identity = this.capabilities.get(token);
|
|
711
715
|
// The workspace is re-checked against the store, so a token minted before a workspace was deleted
|
|
712
716
|
// cannot keep serving its documents afterwards.
|
|
713
|
-
if (!identity || !this.
|
|
717
|
+
if (!identity || !this.hasWorkspace(identity.workspaceId))
|
|
714
718
|
throw new Error("知识库授权已失效");
|
|
715
719
|
return identity;
|
|
716
720
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { AppState } from "../types.js";
|
|
2
2
|
import type { AppStateRepository, StateTransaction } from "./app-state-repository.js";
|
|
3
|
-
/** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
|
|
4
3
|
export declare class SqliteStateRepository implements AppStateRepository {
|
|
5
4
|
private readonly db;
|
|
6
5
|
constructor(path: string);
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
/** SQLite snapshot adapter with row-level writes for streamed messages and runs. */
|
|
3
|
+
/**
|
|
4
|
+
* THE ROW THAT SAYS "THIS CONVERSATION'S ARRAY IS EMPTY".
|
|
5
|
+
*
|
|
6
|
+
* A missing row in `message_order` used to mean two different things: "this conversation was never
|
|
7
|
+
* written incrementally" AND "this conversation's array was emptied". The loader could only read the
|
|
8
|
+
* first one, so a conversation whose LAST message was withdrawn fell back to the base snapshot - and
|
|
9
|
+
* the withdrawn message came back, carrying `delivery: "waiting"`, to be handed to the model by the
|
|
10
|
+
* next turn. A withdrawal is the last act of "take back what I have not sent yet", and it did not
|
|
11
|
+
* survive a restart (ledger TEST-041).
|
|
12
|
+
*
|
|
13
|
+
* A sentinel row says the difference. It is marked by a NEGATIVE POSITION - a real message's
|
|
14
|
+
* position is never negative - so it is never mistaken for one, and the loader reads it as "empty"
|
|
15
|
+
* rather than as "not written". (The marker is the position and NOT the id on purpose: a NUL in the
|
|
16
|
+
* id would be truncated by SQLite's TEXT handling, and the row would stop being recognisable.)
|
|
17
|
+
*/
|
|
18
|
+
const EMPTY_ARRAY_POSITION = -1;
|
|
19
|
+
/** The id that goes with the sentinel row. It never becomes a message: the loader drops every row
|
|
20
|
+
* whose position is negative, and this row's position IS negative. */
|
|
21
|
+
const EMPTY_ARRAY_SENTINEL_ID = "empty-array";
|
|
3
22
|
export class SqliteStateRepository {
|
|
4
23
|
db;
|
|
5
24
|
constructor(path) {
|
|
@@ -58,7 +77,12 @@ export class SqliteStateRepository {
|
|
|
58
77
|
// was last rewritten - the placeholder an assistant message was born with, not what it streamed.
|
|
59
78
|
const ordered = this.db.prepare("SELECT id,position,json FROM message_order WHERE conversation=? ORDER BY position").all(conversation.id);
|
|
60
79
|
if (ordered.length) {
|
|
61
|
-
conversation
|
|
80
|
+
// Rows EXIST, so this conversation was written incrementally and these rows are authoritative -
|
|
81
|
+
// including the sentinel, which says the array is empty. The sentinel is not a message and
|
|
82
|
+
// never becomes one.
|
|
83
|
+
conversation.messages = ordered
|
|
84
|
+
.filter((row) => Number(row.position) >= 0)
|
|
85
|
+
.map((row) => overrides.get(String(row.id)) ?? JSON.parse(String(row.json)));
|
|
62
86
|
}
|
|
63
87
|
else {
|
|
64
88
|
if (!Array.isArray(conversation.messages))
|
|
@@ -163,6 +187,12 @@ export class SqliteStateRepository {
|
|
|
163
187
|
upsertMessage.run(conversationId, message.id, json);
|
|
164
188
|
insertOrdered.run(conversationId, message.id, position, json);
|
|
165
189
|
});
|
|
190
|
+
// An EMPTY ARRAY MUST BE WRITTEN DOWN, or "no rows" will read as "never written" and the
|
|
191
|
+
// loader will fall back to the base snapshot - which still holds the message the person
|
|
192
|
+
// just withdrew (ledger TEST-041).
|
|
193
|
+
if (transaction.messages.length === 0) {
|
|
194
|
+
insertOrdered.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
|
|
195
|
+
}
|
|
166
196
|
}
|
|
167
197
|
else {
|
|
168
198
|
const nextIds = new Set(transaction.messages.map((message) => message.id));
|
|
@@ -185,6 +215,12 @@ export class SqliteStateRepository {
|
|
|
185
215
|
if (positions.get(message.id) !== position)
|
|
186
216
|
upsertOrder.run(conversationId, message.id, position, json);
|
|
187
217
|
});
|
|
218
|
+
// The LAST MESSAGE GONE leaves the order table with no rows, and no rows read as "never
|
|
219
|
+
// written" - so the loader would fall back to the base snapshot and hand the withdrawn
|
|
220
|
+
// message back to the model. The sentinel says the array is empty (ledger TEST-041).
|
|
221
|
+
if (transaction.messages.length === 0) {
|
|
222
|
+
upsertOrder.run(conversationId, EMPTY_ARRAY_SENTINEL_ID, EMPTY_ARRAY_POSITION, "[]");
|
|
223
|
+
}
|
|
188
224
|
}
|
|
189
225
|
// The stamp is WRITTEN, not cleared. Opening a turn moves `updatedAt`, and this is the only write
|
|
190
226
|
// that can record it; clearing the overlay instead would leave the reopened conversation reporting
|
|
@@ -284,3 +284,16 @@ export declare function fetchRemoteModels(input: {
|
|
|
284
284
|
apiKey?: string;
|
|
285
285
|
api?: string;
|
|
286
286
|
}): Promise<DiscoveredModel[]>;
|
|
287
|
+
/**
|
|
288
|
+
* THE ANSWER, READ.
|
|
289
|
+
*
|
|
290
|
+
* Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
|
|
291
|
+
* can be tested without a network in between. A remote provider answers in three shapes (a bare array,
|
|
292
|
+
* `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
|
|
293
|
+
* `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
|
|
294
|
+
* images - is inferred from the name, because that is all most providers tell us.
|
|
295
|
+
*
|
|
296
|
+
* `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
|
|
297
|
+
* be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
|
|
298
|
+
*/
|
|
299
|
+
export declare function parseRemoteModels(payload: unknown): DiscoveredModel[];
|
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
|
*
|
|
@@ -101,6 +111,15 @@ export declare class Store {
|
|
|
101
111
|
setDefaultModel(model?: ModelSelection): Promise<void>;
|
|
102
112
|
setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
|
|
103
113
|
snapshot(): AppState;
|
|
114
|
+
/** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
|
|
115
|
+
hasWorkspace(workspaceId: string): boolean;
|
|
116
|
+
conversationStatus(conversationId: string): Conversation["status"] | undefined;
|
|
117
|
+
question(conversationId: string, messageId: string, index: number): ToolCallQuestion | undefined;
|
|
118
|
+
pendingQuestion(conversationId: string): {
|
|
119
|
+
messageId: string;
|
|
120
|
+
index: number;
|
|
121
|
+
question: ToolCallQuestion;
|
|
122
|
+
} | undefined;
|
|
104
123
|
/**
|
|
105
124
|
* Whether a conversation already holds a message with this id.
|
|
106
125
|
*
|
|
@@ -126,6 +145,17 @@ export declare class Store {
|
|
|
126
145
|
conversation: Conversation;
|
|
127
146
|
workspace: Workspace;
|
|
128
147
|
} | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
|
|
150
|
+
*
|
|
151
|
+
* A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
|
|
152
|
+
* the same project, and the app used to mint a fresh UUID for each one. That is what broke
|
|
153
|
+
* re-adding a workspace - the new id matched none of the conversations that belonged to that
|
|
154
|
+
* directory, so the history was unreachable (and, before the soft unmount, actually deleted).
|
|
155
|
+
*
|
|
156
|
+
* So the same folder always means the same workspace id, and the id is what the conversations
|
|
157
|
+
* point at.
|
|
158
|
+
*/
|
|
129
159
|
createWorkspace(name: string, path?: string): Promise<Workspace>;
|
|
130
160
|
renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
|
|
131
161
|
/**
|
|
@@ -151,7 +181,38 @@ export declare class Store {
|
|
|
151
181
|
thinkingLevel?: string;
|
|
152
182
|
agentId?: string | null;
|
|
153
183
|
}): Promise<Workspace>;
|
|
184
|
+
/**
|
|
185
|
+
* TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
|
|
186
|
+
*
|
|
187
|
+
* This used to cascade: the workspace row AND every conversation that pointed at it were erased
|
|
188
|
+
* from the state, in one persist. That made the most natural thing a user could do - remove a
|
|
189
|
+
* workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
|
|
190
|
+
* that project, and there was no way to get it back.
|
|
191
|
+
*
|
|
192
|
+
* So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
|
|
193
|
+
* stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
|
|
194
|
+
* broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
|
|
195
|
+
* workspace - same id, same conversations - which is the whole point of the physical path being
|
|
196
|
+
* the workspace's identity.
|
|
197
|
+
*
|
|
198
|
+
* The returned list is the conversations that were STOPPED, not deleted: a turn that was still
|
|
199
|
+
* running under a workspace that just left the sidebar cannot keep burning a model session
|
|
200
|
+
* behind the user's back, and a pending question would otherwise wait for an answer that the
|
|
201
|
+
* sidebar can no longer reach.
|
|
202
|
+
*/
|
|
154
203
|
deleteWorkspace(workspaceId: string): Promise<string[]>;
|
|
204
|
+
/**
|
|
205
|
+
* THE STATE THE CLIENT IS ALLOWED TO SEE.
|
|
206
|
+
*
|
|
207
|
+
* `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
|
|
208
|
+
* difference is the closed workspaces: one that was removed from the sidebar is still in the store
|
|
209
|
+
* - that is exactly why re-adding the folder brings its history back - but it is not part of the
|
|
210
|
+
* app any more, so neither it nor its conversations are broadcast.
|
|
211
|
+
*
|
|
212
|
+
* Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
|
|
213
|
+
* sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
|
|
214
|
+
*/
|
|
215
|
+
clientState(): AppState;
|
|
155
216
|
createConversation(workspaceId: string, configuration?: {
|
|
156
217
|
agentId?: string;
|
|
157
218
|
model?: ModelSelection | null;
|