@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
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { assertModelAllowed } from "../model-filter.js";
|
|
2
|
-
import { deleteConversationUploads } from "../upload.js";
|
|
3
2
|
/**
|
|
4
3
|
* Service managing workspaces: creation, rename, cascade deletion, and defaults configuration.
|
|
5
4
|
*/
|
|
@@ -10,16 +9,14 @@ export class WorkspaceService {
|
|
|
10
9
|
catalogService;
|
|
11
10
|
runService;
|
|
12
11
|
extensionUiService;
|
|
13
|
-
uploadsDir;
|
|
14
12
|
agentsDir;
|
|
15
|
-
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService,
|
|
13
|
+
constructor(store, runtime, broadcaster, catalogService, runService, extensionUiService, agentsDir) {
|
|
16
14
|
this.store = store;
|
|
17
15
|
this.runtime = runtime;
|
|
18
16
|
this.broadcaster = broadcaster;
|
|
19
17
|
this.catalogService = catalogService;
|
|
20
18
|
this.runService = runService;
|
|
21
19
|
this.extensionUiService = extensionUiService;
|
|
22
|
-
this.uploadsDir = uploadsDir;
|
|
23
20
|
this.agentsDir = agentsDir;
|
|
24
21
|
}
|
|
25
22
|
setAgentsDir(dir) {
|
|
@@ -41,21 +38,25 @@ export class WorkspaceService {
|
|
|
41
38
|
this.broadcaster.broadcastState();
|
|
42
39
|
}
|
|
43
40
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
41
|
+
* Takes a workspace out of the sidebar. NOTHING IS DELETED.
|
|
42
|
+
*
|
|
43
|
+
* The conversations under it, the uploads attached to them and the workspace's own defaults all
|
|
44
|
+
* stay on disk: removing a workspace from the list is a soft unmount, and adding the same
|
|
45
|
+
* folder back brings the whole history with it. What does stop is the WORK: a turn that was still
|
|
46
|
+
* running cannot keep a model session alive behind a workspace the user can no longer see, and a
|
|
47
|
+
* pending question would wait for an answer the sidebar can no longer reach.
|
|
46
48
|
*/
|
|
47
49
|
async deleteWorkspace(workspaceId) {
|
|
48
50
|
const snapshot = this.store.snapshot();
|
|
49
|
-
const
|
|
50
|
-
for (const c of
|
|
51
|
+
const affected = snapshot.conversations.filter((c) => c.workspaceId === workspaceId);
|
|
52
|
+
for (const c of affected) {
|
|
51
53
|
this.extensionUiService.cancelPendingUi(c.id, "aborted");
|
|
52
54
|
if (this.runService.isRunning(c.id)) {
|
|
53
55
|
await this.runService.stopRun(c.id);
|
|
54
56
|
}
|
|
55
57
|
this.runService.stopConversationSubagents(c.id);
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
await Promise.all(doomedIds.map((id) => deleteConversationUploads(this.uploadsDir, id)));
|
|
59
|
+
await this.store.deleteWorkspace(workspaceId);
|
|
59
60
|
this.broadcaster.broadcastState();
|
|
60
61
|
}
|
|
61
62
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export declare function describeThrown(error: unknown): string;
|
|
17
|
+
/** The failure with its stack, for the log file. */
|
|
18
|
+
export declare function describeThrownWithStack(error: unknown): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TEXT OF A FAILURE.
|
|
3
|
+
*
|
|
4
|
+
* A server failure reaches the browser as a sentence, and the reader is entitled to know what
|
|
5
|
+
* actually went wrong. The trouble is that what was thrown is not always an `Error`: it can be a
|
|
6
|
+
* string, a plain object, a rejected promise with no reason at all. The old code answered
|
|
7
|
+
* `command failed` in those cases - the one sentence that says nothing about anything - so a real
|
|
8
|
+
* cause that had reached the server was replaced by a sentence with no cause in it.
|
|
9
|
+
*
|
|
10
|
+
* This module is the one place that decides what a thrown value turns into. It never invents
|
|
11
|
+
* text: an `Error` gives its message, a string gives itself, an object gives its `message` or its
|
|
12
|
+
* JSON, and a value that carries no information at all gives an EMPTY STRING - which the client
|
|
13
|
+
* reads as "no reason was given", and says so out loud instead of pretending.
|
|
14
|
+
*/
|
|
15
|
+
/** The failure as a person should read it. Empty when the thrown value carries no information. */
|
|
16
|
+
export function describeThrown(error) {
|
|
17
|
+
if (error instanceof Error)
|
|
18
|
+
return error.message || error.name;
|
|
19
|
+
if (typeof error === "string")
|
|
20
|
+
return error;
|
|
21
|
+
if (error === null || error === undefined)
|
|
22
|
+
return "";
|
|
23
|
+
if (typeof error === "object") {
|
|
24
|
+
const message = error.message;
|
|
25
|
+
if (typeof message === "string" && message.trim())
|
|
26
|
+
return message;
|
|
27
|
+
try {
|
|
28
|
+
const text = JSON.stringify(error);
|
|
29
|
+
if (text && text !== "{}")
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Circular or otherwise unserializable: there is nothing better to say than "no reason".
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return String(error);
|
|
38
|
+
}
|
|
39
|
+
/** The failure with its stack, for the log file. */
|
|
40
|
+
export function describeThrownWithStack(error) {
|
|
41
|
+
if (error instanceof Error && error.stack)
|
|
42
|
+
return error.stack;
|
|
43
|
+
return describeThrown(error);
|
|
44
|
+
}
|
package/dist/server/files.js
CHANGED
|
@@ -650,7 +650,17 @@ export async function writeWorkspaceFile(root, requested, content, baseEtag) {
|
|
|
650
650
|
throw new FilesError(415, "拒绝写入:内容包含二进制字节(\\0)", "binary");
|
|
651
651
|
const original = await readFile(target);
|
|
652
652
|
const hadBom = original.length >= 3 && original[0] === 0xef && original[1] === 0xbb && original[2] === 0xbf;
|
|
653
|
-
|
|
653
|
+
// THE LINE ENDINGS THE FILE HAD, NOT THE ONES THE EDITOR NORMALIZED.
|
|
654
|
+
//
|
|
655
|
+
// CodeMirror's document is always LF (it splits lines on `/\r\n?|\n/`), so a CRLF file comes back
|
|
656
|
+
// from the editor as LF text - and writing that back is how one edited character turns into a
|
|
657
|
+
// whole-file diff, on Windows, in every repository with `core.autocrlf=true`. The contract in this
|
|
658
|
+
// file already says it ("a CRLF file is saved back as CRLF instead of silently rewritten to LF");
|
|
659
|
+
// it just was not implemented HERE: the BOM's byte-level convention was honoured two lines above
|
|
660
|
+
// and the EOL's was dropped.
|
|
661
|
+
const eol = original.includes("\r\n") ? "\r\n" : "\n";
|
|
662
|
+
const body = eol === "\r\n" ? content.replace(/\r?\n/g, "\r\n") : content.replace(/\r\n/g, "\n");
|
|
663
|
+
const output = hadBom ? `\uFEFF${body}` : body;
|
|
654
664
|
const temporary = await mkdtemp(`${target}.super-pi-${process.pid}-`);
|
|
655
665
|
const temporaryFile = join(temporary, basename(target));
|
|
656
666
|
try {
|
|
@@ -6,6 +6,7 @@ import { homedir, networkInterfaces } from "node:os";
|
|
|
6
6
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
|
+
import { describeThrown } from "./failure-text.js";
|
|
9
10
|
import { checkDiskVersion } from "./version.js";
|
|
10
11
|
import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
|
|
11
12
|
import { computeFileDiff } from "./file-diff.js";
|
|
@@ -282,7 +283,7 @@ export function createHttpServer(options) {
|
|
|
282
283
|
return;
|
|
283
284
|
}
|
|
284
285
|
catch (error) {
|
|
285
|
-
json(400, { error: error
|
|
286
|
+
json(400, { error: describeThrown(error) || "导出工具失败,没有拿到失败原因。" });
|
|
286
287
|
return;
|
|
287
288
|
}
|
|
288
289
|
}
|
|
@@ -360,7 +361,7 @@ export function createHttpServer(options) {
|
|
|
360
361
|
return;
|
|
361
362
|
}
|
|
362
363
|
catch (error) {
|
|
363
|
-
json(400, { error: error
|
|
364
|
+
json(400, { error: describeThrown(error) || "工具请求失败,没有拿到失败原因。" });
|
|
364
365
|
return;
|
|
365
366
|
}
|
|
366
367
|
}
|
|
@@ -422,7 +423,7 @@ export function createHttpServer(options) {
|
|
|
422
423
|
toolApi(202, run);
|
|
423
424
|
}
|
|
424
425
|
catch (error) {
|
|
425
|
-
toolApi(400, { error: error
|
|
426
|
+
toolApi(400, { error: describeThrown(error) || "工具运行失败,没有拿到失败原因。" });
|
|
426
427
|
}
|
|
427
428
|
return;
|
|
428
429
|
}
|
|
@@ -513,7 +514,7 @@ export function createHttpServer(options) {
|
|
|
513
514
|
toolApi(202, context.toolLlm.start({ toolId: id, prompt: body.prompt ?? "", system: body.system, timeoutMs: body.timeoutMs }));
|
|
514
515
|
}
|
|
515
516
|
catch (error) {
|
|
516
|
-
toolApi(400, { error: error
|
|
517
|
+
toolApi(400, { error: describeThrown(error) || "模型调用失败,没有拿到失败原因。" });
|
|
517
518
|
}
|
|
518
519
|
return;
|
|
519
520
|
}
|
|
@@ -1039,6 +1040,16 @@ export function createHttpServer(options) {
|
|
|
1039
1040
|
return;
|
|
1040
1041
|
}
|
|
1041
1042
|
if (request.method === "POST") {
|
|
1043
|
+
// A GIT WRITE IS A WRITE, AND IT GETS THE SAME GATE AS EVERY OTHER WRITE. `gitMutate` can
|
|
1044
|
+
// stage, DISCARD (`git restore -- <path>`, which throws away uncommitted work), commit,
|
|
1045
|
+
// pull and PUSH - so an endpoint that skips the address predicate hands a public caller the
|
|
1046
|
+
// keys to somebody's repository. The server listens on 0.0.0.0 by default and the global
|
|
1047
|
+
// access token is OPTIONAL (`isAuthorized` passes everyone when there is none), so this
|
|
1048
|
+
// predicate IS the boundary for every other write endpoint in this file (ledger TEST-042).
|
|
1049
|
+
if (!isLocalOrPrivateAddress(remote)) {
|
|
1050
|
+
json(403, { error: "Git 写操作只允许来自本机或局域网的请求。" });
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1042
1053
|
let raw = "";
|
|
1043
1054
|
for await (const chunk of request)
|
|
1044
1055
|
raw += chunk;
|
|
@@ -1417,7 +1428,7 @@ export function createHttpServer(options) {
|
|
|
1417
1428
|
response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(result));
|
|
1418
1429
|
}
|
|
1419
1430
|
catch (error) {
|
|
1420
|
-
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: error
|
|
1431
|
+
response.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: describeThrown(error) || "问答失败,没有拿到失败原因。" }));
|
|
1421
1432
|
}
|
|
1422
1433
|
return;
|
|
1423
1434
|
}
|
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
|
}
|
|
@@ -6,8 +6,18 @@ export declare function applicableRules(rules: ModelFilterRule[] | undefined, wo
|
|
|
6
6
|
export declare function assertModelAllowed(rules: ModelFilterRule[] | undefined, workspace: Workspace, model: ModelSelection | undefined): void;
|
|
7
7
|
export declare function conversationWorkspace(state: AppState, conversationId: string): Workspace;
|
|
8
8
|
/** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/**
|
|
10
|
+
* THE STORE READS THIS NEEDS, AND NOTHING ELSE.
|
|
11
|
+
*
|
|
12
|
+
* The check wants two things: the rules, and the workspace the conversation is in. Both now have a
|
|
13
|
+
* narrow reader on the store (`modelFilterRules()` / `workspaceForConversation()`), so this path
|
|
14
|
+
* clones NOTHING - it used to deep-clone the whole application three times over, on the way to
|
|
15
|
+
* sending a message.
|
|
16
|
+
*/
|
|
17
|
+
export interface ModelFilterStore {
|
|
18
|
+
modelFilterRules(): import("./types.js").ModelFilterRule[];
|
|
19
|
+
workspaceForConversation(conversationId: string): Workspace | undefined;
|
|
20
|
+
}
|
|
21
|
+
export declare function validateConversationModel(store: ModelFilterStore, runtime: {
|
|
12
22
|
catalog(selection?: ModelSelection): Promise<import("./types.js").ModelCatalog>;
|
|
13
23
|
}, conversationId: string, selection?: ModelSelection): Promise<void>;
|
|
@@ -29,18 +29,20 @@ export function conversationWorkspace(state, conversationId) {
|
|
|
29
29
|
throw new Error("会话所属工作区不存在");
|
|
30
30
|
return workspace;
|
|
31
31
|
}
|
|
32
|
-
/** Resolve Pi's actual global default when no model was selected; never guess a permitted replacement. */
|
|
33
32
|
export async function validateConversationModel(store, runtime, conversationId, selection) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const workspace = store.workspaceForConversation(conversationId);
|
|
34
|
+
if (!workspace)
|
|
35
|
+
throw new Error("会话所属工作区不存在");
|
|
36
|
+
if (!applicableRules(store.modelFilterRules(), workspace).length)
|
|
37
37
|
return;
|
|
38
38
|
const startedAt = performance.now();
|
|
39
39
|
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
40
40
|
console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.begin selection=${selection ? `${selection.provider}/${selection.modelId}` : "default"}`);
|
|
41
41
|
const catalog = await runtime.catalog();
|
|
42
42
|
const model = selection ? catalog.models.find(item => item.provider === selection.provider && item.modelId === selection.modelId) ?? selection : catalog.defaultModel;
|
|
43
|
-
|
|
43
|
+
// READ AGAIN, NOT CACHED: the catalog fetch above is an await, and a rule edited during it must
|
|
44
|
+
// still be enforced for this message. The narrow readers make that re-read cheap.
|
|
45
|
+
assertModelAllowed(store.modelFilterRules(), store.workspaceForConversation(conversationId), model);
|
|
44
46
|
if (process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
45
47
|
console.warn(`[super-pi latency] conversation=${conversationId} phase=model.validate.end elapsedMs=${Math.round(performance.now() - startedAt)}`);
|
|
46
48
|
}
|
|
@@ -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[];
|