@evandu/super-pi 0.1.83 → 0.1.85
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/agents/super-pi-knowledge/index.ts +40 -0
- package/assets/knowledge-model/README.md +37 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/config.json +31 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/special_tokens_map.json +7 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer.json +21278 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/tokenizer_config.json +15 -0
- package/assets/knowledge-model/Xenova/bge-small-zh-v1.5/vocab.txt +21128 -0
- package/dist/.super-pi-check-inputs.json +4 -0
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-B_jOwjyp.js +72 -0
- package/dist/client/assets/index-CNNGl4eF.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/run-service.d.ts +50 -11
- package/dist/server/application/run-service.js +206 -77
- package/dist/server/command-handler.js +16 -4
- package/dist/server/http-server.js +59 -1
- package/dist/server/index.js +7 -0
- package/dist/server/knowledge-vectors.d.ts +133 -0
- package/dist/server/knowledge-vectors.js +302 -0
- package/dist/server/knowledge.d.ts +263 -0
- package/dist/server/knowledge.js +743 -0
- package/dist/server/persistence/app-state-repository.d.ts +25 -1
- package/dist/server/persistence/sqlite-state-repository.js +47 -4
- package/dist/server/runtime.d.ts +48 -1
- package/dist/server/runtime.js +83 -7
- package/dist/server/server-context.d.ts +1 -0
- package/dist/server/store.d.ts +153 -68
- package/dist/server/store.js +405 -187
- package/dist/server/types.d.ts +97 -65
- package/package.json +163 -154
- package/dist/client/assets/index-DhOfFh0G.js +0 -72
- package/dist/client/assets/index-v2n_FYv_.css +0 -1
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { looksBinary, resolveInsideRoot } from "./files.js";
|
|
5
|
+
/** 1 MB. Big enough for a design doc or a spec, small enough that chunking stays instant. */
|
|
6
|
+
export const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
7
|
+
/** A pasted document rides inside a JSON body, so the request cap leaves room for escaping overhead. */
|
|
8
|
+
export const MAX_KNOWLEDGE_REQUEST_BYTES = MAX_DOCUMENT_BYTES * 2;
|
|
9
|
+
/** Per workspace, across every document. A knowledge base is a curated asset, not a file server. */
|
|
10
|
+
export const MAX_WORKSPACE_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
const CHUNK_CHARS = 1200;
|
|
12
|
+
const CHUNK_OVERLAP_CHARS = 150;
|
|
13
|
+
const MAX_CHUNKS_PER_DOCUMENT = 2000;
|
|
14
|
+
export const DEFAULT_RESULT_LIMIT = 6;
|
|
15
|
+
export const MAX_RESULT_LIMIT = 20;
|
|
16
|
+
/** Per hit. Enough to answer from, short enough that six hits stay inside one turn's prompt budget. */
|
|
17
|
+
const RESULT_TEXT_CAP = 1200;
|
|
18
|
+
/** Across all hits of one search. A model that asked for 20 passages still gets a bounded prompt. */
|
|
19
|
+
const RESULT_TOTAL_CAP = 12000;
|
|
20
|
+
/** Below this many candidates there is no ranking to correct, and the model call is not worth it. */
|
|
21
|
+
const RERANK_MIN_CANDIDATES = 3;
|
|
22
|
+
/** How many candidates the model sees. Larger pools cost prompt without improving the top few. */
|
|
23
|
+
const RERANK_POOL = 12;
|
|
24
|
+
/** Per candidate, and the whole rerank. Both are ceilings on latency a tool call can add to a turn. */
|
|
25
|
+
const RERANK_SNIPPET_CHARS = 240;
|
|
26
|
+
const RERANK_TIMEOUT_MS = 12000;
|
|
27
|
+
/**
|
|
28
|
+
* The first integer array in the model's answer, or null.
|
|
29
|
+
*
|
|
30
|
+
* WHY TOLERANT PARSING. The prompt asks for a bare JSON array, and a model that mostly complies will
|
|
31
|
+
* still sometimes wrap it in a sentence or a code fence. Recovering the array from that is worth doing;
|
|
32
|
+
* guessing when there is no array at all is not, so anything unparseable is null and the caller keeps
|
|
33
|
+
* the lexical order.
|
|
34
|
+
*/
|
|
35
|
+
export function parseRanking(output) {
|
|
36
|
+
const match = /\[\s*\d+(?:\s*,\s*\d+)*\s*\]/.exec(output ?? "");
|
|
37
|
+
if (!match)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(match[0]);
|
|
41
|
+
if (!Array.isArray(parsed))
|
|
42
|
+
return null;
|
|
43
|
+
return parsed.filter((value) => Number.isInteger(value) && value > 0);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const CJK_RUN = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/gu;
|
|
50
|
+
const LATIN_WORD = /[\p{L}\p{N}_.+#-]{2,}/gu;
|
|
51
|
+
const HEADING = /^#{1,6}\s+(.*\S)\s*$/;
|
|
52
|
+
/** The `source` of a document the person typed rather than imported. See `add` for why it matters. */
|
|
53
|
+
const PASTED_SOURCE = "pasted";
|
|
54
|
+
/** Duplicated as a literal in `agents/super-pi-knowledge/index.ts`, the way `TASK_TOOL_NAME` is. */
|
|
55
|
+
export const KNOWLEDGE_TOOL_NAME = "knowledge_search";
|
|
56
|
+
/** Extensions a document may be imported from. Mirrors `files.TEXT_EXTENSIONS`, minus the source languages. */
|
|
57
|
+
const IMPORTABLE = new Set([
|
|
58
|
+
".md", ".markdown", ".mdx", ".txt", ".text", ".log", ".rst", ".org",
|
|
59
|
+
".json", ".jsonc", ".json5", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
|
|
60
|
+
".csv", ".tsv", ".html", ".htm", ".xml", ".svg", ".sql", ".diff", ".patch",
|
|
61
|
+
]);
|
|
62
|
+
/**
|
|
63
|
+
* Index terms for one string: CJK bigrams plus Latin words.
|
|
64
|
+
*
|
|
65
|
+
* WHY BIGRAMS AND NOT WORDS. Chinese has no spaces, so the word-list tokenizer the rest of this
|
|
66
|
+
* server uses (`ActiveMemory.search`) returns one enormous "word" for a whole sentence and matches
|
|
67
|
+
* nothing usefully. Character bigrams need no dictionary and no dependency, and they carry enough
|
|
68
|
+
* precision that 知识库 and 记忆库 share 识库 but not the whole run - which is what makes a two-term
|
|
69
|
+
* overlap meaningful rather than accidental.
|
|
70
|
+
*/
|
|
71
|
+
export function tokenize(text) {
|
|
72
|
+
return cjkNgrams(text, 2);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The longer-range channel: character TRIGRAMS, for the passage that repeats a phrase verbatim.
|
|
76
|
+
*
|
|
77
|
+
* WHY A SECOND GRANULARITY AND NOT JUST BIGRAMS. Bigrams answer "do these share any two-character
|
|
78
|
+
* run", which is high recall but low precision - two unrelated Chinese sentences collide constantly on
|
|
79
|
+
* a shared pair. Trigrams only fire on a real three-character overlap, so when the user's question
|
|
80
|
+
* quotes the document's own wording this channel separates it sharply from the rest of the corpus.
|
|
81
|
+
* It is scored on its own and fused with the bigram channel rather than merged into it, because the
|
|
82
|
+
* two answer different questions and a term that is strong for one is usually weak for the other.
|
|
83
|
+
*/
|
|
84
|
+
export function tokenizePhrases(text) {
|
|
85
|
+
return cjkNgrams(text, 3);
|
|
86
|
+
}
|
|
87
|
+
function cjkNgrams(text, size) {
|
|
88
|
+
const lowered = text.toLocaleLowerCase();
|
|
89
|
+
const terms = [];
|
|
90
|
+
for (const run of lowered.match(CJK_RUN) ?? []) {
|
|
91
|
+
// A run shorter than the n-gram falls back to itself rather than contributing nothing: "端口" is
|
|
92
|
+
// a legitimate two-character term that a trigram tokenizer would otherwise silently drop.
|
|
93
|
+
if (run.length <= size)
|
|
94
|
+
terms.push(run);
|
|
95
|
+
else
|
|
96
|
+
for (let i = 0; i + size <= run.length; i++)
|
|
97
|
+
terms.push(run.slice(i, i + size));
|
|
98
|
+
}
|
|
99
|
+
// CJK is removed before the Latin pass, because `\p{L}` also matches Han characters: without this,
|
|
100
|
+
// every Chinese run would be indexed a second time as one long term.
|
|
101
|
+
for (const word of lowered.replace(CJK_RUN, " ").match(LATIN_WORD) ?? [])
|
|
102
|
+
terms.push(word);
|
|
103
|
+
return terms;
|
|
104
|
+
}
|
|
105
|
+
/** The identity of a document's CONTENT, independent of its name, its path and when it arrived. */
|
|
106
|
+
export function digestOf(text) {
|
|
107
|
+
return createHash("sha256").update(text).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Splits the document into units that must not be pulled apart: a fenced code block, a table, and
|
|
111
|
+
* every ordinary line.
|
|
112
|
+
*
|
|
113
|
+
* WHY GROUPING HAPPENS BEFORE CHUNKING. A fence or a table is the case where a naive line-based
|
|
114
|
+
* splitter does real damage rather than cosmetic damage: cutting a 40-line SQL block in half produces
|
|
115
|
+
* two passages that are each syntactically meaningless, and neither of which a reader could have
|
|
116
|
+
* learned anything from - the retrieval then returns one of the halves and the model reasons from a
|
|
117
|
+
* fragment. Grouping first means such a block is only ever split when it alone exceeds a chunk.
|
|
118
|
+
*/
|
|
119
|
+
function units(text) {
|
|
120
|
+
const out = [];
|
|
121
|
+
let fence = "", buffer = [];
|
|
122
|
+
const flush = () => { if (buffer.length) {
|
|
123
|
+
out.push(buffer.join("\n"));
|
|
124
|
+
buffer = [];
|
|
125
|
+
} };
|
|
126
|
+
for (const line of text.split(/\r?\n/)) {
|
|
127
|
+
const marker = /^\s*(```+|~~~+)/.exec(line);
|
|
128
|
+
if (fence) {
|
|
129
|
+
buffer.push(line);
|
|
130
|
+
// A closing fence is one that uses the SAME character and is at least as long as the opener;
|
|
131
|
+
// a ``` inside a ~~~ block is content, not a terminator.
|
|
132
|
+
if (marker && line.trim()[0] === fence[0] && marker[1].length >= fence.length) {
|
|
133
|
+
fence = "";
|
|
134
|
+
flush();
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (marker) {
|
|
139
|
+
flush();
|
|
140
|
+
fence = marker[1];
|
|
141
|
+
buffer.push(line);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (/^\s*\|/.test(line)) {
|
|
145
|
+
buffer.push(line);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
flush();
|
|
149
|
+
out.push(line);
|
|
150
|
+
}
|
|
151
|
+
flush();
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Splits a document into overlapping chunks, remembering the heading each one sits under.
|
|
156
|
+
*
|
|
157
|
+
* WHY OVERLAP. A passage that spans a boundary is the passage most likely to be the answer - the
|
|
158
|
+
* sentence that defines a term usually starts in one chunk and ends in the next - and a retrieval
|
|
159
|
+
* system that cut there would return a fragment ending mid-thought. The 150-character tail is
|
|
160
|
+
* repeated at the head of the next chunk to keep that sentence whole in at least one hit.
|
|
161
|
+
*/
|
|
162
|
+
export function chunkDocument(text) {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let heading = "", buffer = "", bufferHeading = "";
|
|
165
|
+
const flush = () => {
|
|
166
|
+
const trimmed = buffer.trim();
|
|
167
|
+
if (trimmed)
|
|
168
|
+
chunks.push({ index: chunks.length, heading: bufferHeading, text: trimmed });
|
|
169
|
+
buffer = "";
|
|
170
|
+
};
|
|
171
|
+
for (const unit of units(text)) {
|
|
172
|
+
const headingMatch = HEADING.exec(unit.trim());
|
|
173
|
+
// A heading starts a new chunk rather than ending one: a section is the unit a reader would cite.
|
|
174
|
+
if (headingMatch) {
|
|
175
|
+
flush();
|
|
176
|
+
heading = headingMatch[1].trim();
|
|
177
|
+
bufferHeading = heading;
|
|
178
|
+
}
|
|
179
|
+
// A UNIT LONGER THAN A CHUNK IS ITS OWN PROBLEM. Minified JSON, a fenced file too long to read in
|
|
180
|
+
// one go: nothing can keep it whole, so it is split by characters with the overlap preserved,
|
|
181
|
+
// rather than becoming one oversized chunk - which every search would then truncate to the
|
|
182
|
+
// per-passage cap, leaving a document that is indexed but effectively unreadable.
|
|
183
|
+
if (unit.length > CHUNK_CHARS) {
|
|
184
|
+
for (let offset = 0; offset < unit.length; offset += CHUNK_CHARS - CHUNK_OVERLAP_CHARS) {
|
|
185
|
+
bufferHeading = heading;
|
|
186
|
+
buffer = unit.slice(offset, offset + CHUNK_CHARS);
|
|
187
|
+
flush();
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (buffer.length + unit.length + 1 > CHUNK_CHARS) {
|
|
192
|
+
const overlap = buffer.slice(-CHUNK_OVERLAP_CHARS);
|
|
193
|
+
flush();
|
|
194
|
+
buffer = bufferHeading === heading ? overlap : "";
|
|
195
|
+
}
|
|
196
|
+
buffer += (buffer ? "\n" : "") + unit;
|
|
197
|
+
}
|
|
198
|
+
flush();
|
|
199
|
+
return chunks;
|
|
200
|
+
}
|
|
201
|
+
/** BM25's two knobs. Named, because "some other constants" is not a retrievable explanation. */
|
|
202
|
+
const K1 = 1.2;
|
|
203
|
+
const B = 0.75;
|
|
204
|
+
/**
|
|
205
|
+
* How much each channel is worth, BEFORE normalisation.
|
|
206
|
+
*
|
|
207
|
+
* `document` outranks `head` on purpose. A question that names a document ("这个规范怎么规定的") is
|
|
208
|
+
* better answered by boosting every chunk of that document than by matching section titles, because
|
|
209
|
+
* the chunk that actually answers may sit in a section whose heading shares nothing with the question.
|
|
210
|
+
*/
|
|
211
|
+
const WEIGHT = { body: 1, phrase: 0.45, head: 0.5, document: 0.7, vector: 0.9 };
|
|
212
|
+
function countTerms(terms) {
|
|
213
|
+
const counts = new Map();
|
|
214
|
+
for (const term of terms)
|
|
215
|
+
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
216
|
+
return counts;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Standard BM25 for one map against a query.
|
|
220
|
+
*
|
|
221
|
+
* `total` is the number of units being ranked (chunks, or documents for the document channel) and
|
|
222
|
+
* `df` how many of them contain each term - the two inputs that decide how rare a term is.
|
|
223
|
+
*/
|
|
224
|
+
function bm25(counts, length, averageLength, df, total, terms) {
|
|
225
|
+
let score = 0;
|
|
226
|
+
for (const term of terms) {
|
|
227
|
+
const frequency = counts.get(term);
|
|
228
|
+
if (!frequency)
|
|
229
|
+
continue;
|
|
230
|
+
const documentsWithTerm = df.get(term) ?? 0;
|
|
231
|
+
// The +1 inside the log keeps the idf positive for a term EVERY unit contains, which is otherwise
|
|
232
|
+
// a negative weight: a common term would subtract from the score of the very passage that repeats
|
|
233
|
+
// it, and BM25's ceiling would rank that passage below a worse one.
|
|
234
|
+
const idf = Math.log(1 + (total - documentsWithTerm + 0.5) / (documentsWithTerm + 0.5));
|
|
235
|
+
score += idf * (frequency * (K1 + 1)) / (frequency + K1 * (1 - B + B * (length / averageLength)));
|
|
236
|
+
}
|
|
237
|
+
return score;
|
|
238
|
+
}
|
|
239
|
+
export class KnowledgeBase {
|
|
240
|
+
directory;
|
|
241
|
+
store;
|
|
242
|
+
runtime;
|
|
243
|
+
vectors;
|
|
244
|
+
capabilities = new Map();
|
|
245
|
+
indexes = new Map();
|
|
246
|
+
serial = Promise.resolve();
|
|
247
|
+
constructor(directory, store, runtime, vectors) {
|
|
248
|
+
this.directory = directory;
|
|
249
|
+
this.store = store;
|
|
250
|
+
this.runtime = runtime;
|
|
251
|
+
this.vectors = vectors;
|
|
252
|
+
}
|
|
253
|
+
/** `SUPER_PI_KNOWLEDGE_ENABLED=0` turns the whole feature off, the same way memory turns off. */
|
|
254
|
+
enabled() { return process.env.SUPER_PI_KNOWLEDGE_ENABLED !== "0"; }
|
|
255
|
+
/** The opaque workspace identity on disk, encoded. Paths never come from the model or the request. */
|
|
256
|
+
folder(workspaceId) { return join(this.directory, Buffer.from(workspaceId).toString("hex")); }
|
|
257
|
+
file(workspaceId, documentId) { return join(this.folder(workspaceId), `${documentId}.json`); }
|
|
258
|
+
requireWorkspace(workspaceId) {
|
|
259
|
+
if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
|
|
260
|
+
throw new Error("工作区不存在");
|
|
261
|
+
}
|
|
262
|
+
async locked(work) {
|
|
263
|
+
const task = this.serial.catch(() => { }).then(work);
|
|
264
|
+
this.serial = task;
|
|
265
|
+
return task;
|
|
266
|
+
}
|
|
267
|
+
/** Metadata only - the caller listing documents must never receive the full text of a workspace. */
|
|
268
|
+
summarise(document) {
|
|
269
|
+
const { text: _text, ...meta } = document;
|
|
270
|
+
return meta;
|
|
271
|
+
}
|
|
272
|
+
async readAll(workspaceId) {
|
|
273
|
+
let names;
|
|
274
|
+
try {
|
|
275
|
+
names = await readdir(this.folder(workspaceId));
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code === "ENOENT")
|
|
279
|
+
return [];
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
const documents = [];
|
|
283
|
+
for (const name of names) {
|
|
284
|
+
if (!name.endsWith(".json"))
|
|
285
|
+
continue;
|
|
286
|
+
try {
|
|
287
|
+
const document = JSON.parse(await readFile(join(this.folder(workspaceId), name), "utf8"));
|
|
288
|
+
// A half-written or hand-edited file must not take the whole workspace down with it.
|
|
289
|
+
if (typeof document?.id !== "string" || typeof document.text !== "string")
|
|
290
|
+
continue;
|
|
291
|
+
// A document stored before fingerprints existed hashes on read. It is deliberately NOT
|
|
292
|
+
// written back: reading must not mutate, and the next import of that document rewrites it.
|
|
293
|
+
if (typeof document.digest !== "string")
|
|
294
|
+
document.digest = digestOf(document.text);
|
|
295
|
+
documents.push(document);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
console.warn(`Super Pi: 跳过无法解析的知识库文档 ${name}:${error instanceof Error ? error.message : "unknown error"}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return documents;
|
|
302
|
+
}
|
|
303
|
+
async save(document) {
|
|
304
|
+
const folder = this.folder(document.workspaceId);
|
|
305
|
+
await mkdir(folder, { recursive: true });
|
|
306
|
+
const file = this.file(document.workspaceId, document.id), temporary = `${file}.${randomUUID()}.tmp`;
|
|
307
|
+
await writeFile(temporary, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
|
|
308
|
+
await rename(temporary, file);
|
|
309
|
+
}
|
|
310
|
+
index(document) {
|
|
311
|
+
const chunks = chunkDocument(document.text);
|
|
312
|
+
if (chunks.length > MAX_CHUNKS_PER_DOCUMENT)
|
|
313
|
+
throw new Error(`文档过长:切分后有 ${chunks.length} 个片段,超过 ${MAX_CHUNKS_PER_DOCUMENT} 的上限`);
|
|
314
|
+
const channels = [];
|
|
315
|
+
const docTerms = new Map();
|
|
316
|
+
let docLength = 0;
|
|
317
|
+
for (const chunk of chunks) {
|
|
318
|
+
// Tokenised ONCE here, at import time, rather than on every search: a workspace with a hundred
|
|
319
|
+
// documents would otherwise re-split the whole corpus per question.
|
|
320
|
+
const body = countTerms(tokenize(chunk.text));
|
|
321
|
+
const channelsForChunk = {
|
|
322
|
+
body,
|
|
323
|
+
phrase: countTerms(tokenizePhrases(chunk.text)),
|
|
324
|
+
// The document title is folded into every chunk's heading channel: it names the document in
|
|
325
|
+
// the citation the reader sees, so a question about the document should reach it.
|
|
326
|
+
head: countTerms(tokenize(`${document.title} ${chunk.heading}`)),
|
|
327
|
+
bodyLength: [...body.values()].reduce((sum, count) => sum + count, 0),
|
|
328
|
+
};
|
|
329
|
+
channels.push(channelsForChunk);
|
|
330
|
+
for (const [term, count] of body)
|
|
331
|
+
docTerms.set(term, (docTerms.get(term) ?? 0) + count);
|
|
332
|
+
docLength += channelsForChunk.bodyLength;
|
|
333
|
+
}
|
|
334
|
+
return { meta: this.summarise(document), chunks, channels, docTerms, docLength };
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Loads (or rebuilds) one workspace's index. Cache miss is the only thing that reads the disk.
|
|
338
|
+
*
|
|
339
|
+
* WHY REBUILT PER WORKSPACE AND NOT ONE GLOBAL INDEX. Isolation is a security property here, not a
|
|
340
|
+
* performance one: a hit may only ever come from the workspace the turn's capability names. A
|
|
341
|
+
* single shared index would need every search to filter by workspace after scoring, which is one
|
|
342
|
+
* forgotten filter away from a cross-tenant leak. Separate caches cannot express that mistake.
|
|
343
|
+
*
|
|
344
|
+
* WHY THERE IS NO ON-DISK INDEX. Measured, not assumed: rebuilding the index of a 16 MB workspace -
|
|
345
|
+
* the feature's own capacity cap - costs about 180 ms, once per process. A serialised index would be
|
|
346
|
+
* larger than the corpus it describes and JSON.parse of it is not reliably cheaper than the
|
|
347
|
+
* tokenisation, so it would trade a correctness-neutral optimisation for a second on-disk artefact
|
|
348
|
+
* that can drift from the documents.
|
|
349
|
+
*/
|
|
350
|
+
async load(workspaceId) {
|
|
351
|
+
const cached = this.indexes.get(workspaceId);
|
|
352
|
+
if (cached)
|
|
353
|
+
return cached;
|
|
354
|
+
const documents = (await this.readAll(workspaceId)).map(document => this.index(document));
|
|
355
|
+
const index = {
|
|
356
|
+
documents,
|
|
357
|
+
averageLength: documents.length ? documents.reduce((sum, document) => sum + document.docLength, 0) / documents.length : 0,
|
|
358
|
+
};
|
|
359
|
+
this.indexes.set(workspaceId, index);
|
|
360
|
+
// Vectors are built in the background on first sight of a workspace, so a restart with an
|
|
361
|
+
// already-indexed knowledge base costs nothing and one without pays for it while answering
|
|
362
|
+
// lexically rather than while the person waits.
|
|
363
|
+
this.vectors?.schedule(workspaceId, documents.map(document => ({ id: document.meta.id, digest: document.meta.digest, chunks: document.chunks })));
|
|
364
|
+
return index;
|
|
365
|
+
}
|
|
366
|
+
invalidate(workspaceId) { this.indexes.delete(workspaceId); }
|
|
367
|
+
async list(workspaceId) {
|
|
368
|
+
this.requireWorkspace(workspaceId);
|
|
369
|
+
const documents = (await this.readAll(workspaceId)).map(document => this.summarise(document));
|
|
370
|
+
return documents.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Whether this workspace has any document at all, which is what decides whether the turn mounts the
|
|
374
|
+
* extension.
|
|
375
|
+
*
|
|
376
|
+
* WHY A DIRECTORY LISTING AND NOT `list()`. This runs once per turn, before every spawn. `list()`
|
|
377
|
+
* would read and parse every document's full text to answer a yes/no question, so a workspace with
|
|
378
|
+
* a dozen real documents would pay for parsing all of them on every message - including the
|
|
379
|
+
* catalogue fetch the extension makes the moment it loads. `readdir` answers the same question for
|
|
380
|
+
* the price of one directory entry, and a name is all this needs.
|
|
381
|
+
*/
|
|
382
|
+
async hasDocuments(workspaceId) {
|
|
383
|
+
if (!this.enabled())
|
|
384
|
+
return false;
|
|
385
|
+
if (!this.store.snapshot().workspaces.some(workspace => workspace.id === workspaceId))
|
|
386
|
+
return false;
|
|
387
|
+
try {
|
|
388
|
+
return (await readdir(this.folder(workspaceId))).some(name => name.endsWith(".json"));
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (error.code === "ENOENT")
|
|
392
|
+
return false;
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Adds a document, either from a workspace file or from pasted text.
|
|
398
|
+
*
|
|
399
|
+
* WHY THE FILE IS READ HERE AND NOT UPLOADED. A knowledge base is made of documents the person
|
|
400
|
+
* ALREADY has in their workspace - re-uploading a copy of the file would give the agent a second,
|
|
401
|
+
* newer version that diverges from the one the workspace serves. The path therefore goes through
|
|
402
|
+
* the same `resolveInsideRoot` guard the file editor uses, so symlink and `..` escapes are refused
|
|
403
|
+
* by construction instead of by a check someone has to remember.
|
|
404
|
+
*/
|
|
405
|
+
async add(workspaceId, body) {
|
|
406
|
+
this.requireWorkspace(workspaceId);
|
|
407
|
+
if (!this.enabled())
|
|
408
|
+
throw new Error("知识库已关闭");
|
|
409
|
+
let text = typeof body.text === "string" ? body.text : undefined;
|
|
410
|
+
let source = PASTED_SOURCE, title = typeof body.title === "string" ? body.title.trim() : "", format = ".md";
|
|
411
|
+
if (text === undefined) {
|
|
412
|
+
if (typeof body.path !== "string" || !body.path.trim())
|
|
413
|
+
throw new Error("需要提供文件路径或正文");
|
|
414
|
+
const workspace = this.store.snapshot().workspaces.find(item => item.id === workspaceId);
|
|
415
|
+
const absolute = await resolveInsideRoot(workspace.path, body.path.trim());
|
|
416
|
+
const info = await stat(absolute);
|
|
417
|
+
if (!info.isFile())
|
|
418
|
+
throw new Error("只能导入文件");
|
|
419
|
+
format = extname(absolute).toLocaleLowerCase();
|
|
420
|
+
if (!IMPORTABLE.has(format))
|
|
421
|
+
throw new Error(`不支持的文件类型 ${format || "(无扩展名)"};请先转换为 Markdown 或纯文本`);
|
|
422
|
+
if (info.size > MAX_DOCUMENT_BYTES)
|
|
423
|
+
throw new Error(`文件过大:${info.size} 字节超过 ${MAX_DOCUMENT_BYTES} 的上限`);
|
|
424
|
+
const buffer = await readFile(absolute);
|
|
425
|
+
if (looksBinary(buffer.subarray(0, 4096)))
|
|
426
|
+
throw new Error("看起来是二进制文件,无法索引");
|
|
427
|
+
text = buffer.toString("utf8");
|
|
428
|
+
source = body.path.trim();
|
|
429
|
+
if (!title)
|
|
430
|
+
title = basename(absolute);
|
|
431
|
+
}
|
|
432
|
+
const trimmed = text.trim();
|
|
433
|
+
if (!trimmed)
|
|
434
|
+
throw new Error("文档内容为空");
|
|
435
|
+
if (Buffer.byteLength(trimmed, "utf8") > MAX_DOCUMENT_BYTES)
|
|
436
|
+
throw new Error(`正文过大:超过 ${MAX_DOCUMENT_BYTES} 字节的上限`);
|
|
437
|
+
return this.locked(async () => {
|
|
438
|
+
const existing = await this.readAll(workspaceId);
|
|
439
|
+
const digest = digestOf(trimmed);
|
|
440
|
+
// The same CONTENT is already here, under any name or path. Importing it again would give the
|
|
441
|
+
// agent two copies that answer identically and make every search return the same passage twice.
|
|
442
|
+
const duplicate = existing.find(document => document.digest === digest);
|
|
443
|
+
if (duplicate)
|
|
444
|
+
return this.summarise(duplicate);
|
|
445
|
+
// The same PATH with DIFFERENT content is the user having edited the file and re-importing it.
|
|
446
|
+
// That is an update, not a second document: keeping both would leave the agent citing the
|
|
447
|
+
// version the person just replaced, with no way to tell which one is current.
|
|
448
|
+
//
|
|
449
|
+
// PASTED TEXT IS EXEMPT. `source` is the literal "pasted" for every hand-entered document, so
|
|
450
|
+
// without this exemption a second pasted note would match the first one's source and silently
|
|
451
|
+
// REPLACE it - two unrelated notes, one of them gone, with nothing in the response to say so.
|
|
452
|
+
const previous = source === PASTED_SOURCE ? undefined : existing.find(document => document.source === source && document.format === format);
|
|
453
|
+
const document = {
|
|
454
|
+
id: previous?.id ?? randomUUID(), workspaceId,
|
|
455
|
+
title: title.slice(0, 120) || "未命名文档", source, format, digest,
|
|
456
|
+
characters: trimmed.length,
|
|
457
|
+
createdAt: previous?.createdAt ?? new Date().toISOString(),
|
|
458
|
+
updatedAt: new Date().toISOString(), text: trimmed,
|
|
459
|
+
};
|
|
460
|
+
const used = existing.reduce((sum, document) => sum + document.characters, 0) - (previous?.characters ?? 0) + trimmed.length;
|
|
461
|
+
if (used > MAX_WORKSPACE_BYTES)
|
|
462
|
+
throw new Error(`工作区知识库容量不足:${used}/${MAX_WORKSPACE_BYTES} 字符;请删除不再需要的文档`);
|
|
463
|
+
// Chunked before it is persisted, so an unindexable document is refused rather than stored and
|
|
464
|
+
// then failing on every later search.
|
|
465
|
+
this.index(document);
|
|
466
|
+
await this.save(document);
|
|
467
|
+
// Vectors are keyed by content, so a replaced document's old vectors are dropped before the new
|
|
468
|
+
// ones are scheduled. Leaving them would let a search cite the pre-edit passage for the
|
|
469
|
+
// post-edit question.
|
|
470
|
+
await this.vectors?.forget(workspaceId, document.id);
|
|
471
|
+
this.invalidate(workspaceId);
|
|
472
|
+
this.vectors?.schedule(workspaceId, [{ id: document.id, digest: document.digest, chunks: chunkDocument(document.text) }]);
|
|
473
|
+
return this.summarise(document);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
async remove(workspaceId, documentId) {
|
|
477
|
+
this.requireWorkspace(workspaceId);
|
|
478
|
+
return this.locked(async () => {
|
|
479
|
+
// Existence is checked by reading the folder rather than trusting the id: an id that does not
|
|
480
|
+
// belong to THIS workspace must not be able to delete anything, and that is decided by the path.
|
|
481
|
+
const documents = await this.readAll(workspaceId);
|
|
482
|
+
if (!documents.some(document => document.id === documentId))
|
|
483
|
+
throw new Error("知识库文档不存在");
|
|
484
|
+
await unlink(this.file(workspaceId, documentId));
|
|
485
|
+
await this.vectors?.forget(workspaceId, documentId);
|
|
486
|
+
this.invalidate(workspaceId);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Scores this workspace's chunks on four independent lexical channels and fuses them.
|
|
491
|
+
*
|
|
492
|
+
* WHY LEXICAL AND NOT ONLY EMBEDDINGS. See docs/KNOWLEDGE_BASE.md: the vector channel is fused in
|
|
493
|
+
* here rather than replacing this one, because each is wrong in a way the other catches. BM25
|
|
494
|
+
* misses a paraphrase completely; a vector search will happily return the nearest thing at a
|
|
495
|
+
* confident-looking 0.58 when the answer is not in the knowledge base at all. Summing a normalised
|
|
496
|
+
* lexical score with a normalised cosine score keeps both signals - and keeps "no hit" reachable.
|
|
497
|
+
*
|
|
498
|
+
* WHY NORMALISE PER CHANNEL AND SUM, RATHER THAN RRF. Reciprocal rank fusion is the usual way to
|
|
499
|
+
* combine ranked lists, and it would be a regression here: it discards score magnitude, so a
|
|
500
|
+
* passage that matches decisively and one that matches barely would arrive at the same rank and
|
|
501
|
+
* finish within a couple of percent of each other. The gap between a clear winner and a near miss is
|
|
502
|
+
* exactly what BM25 was measuring. Each channel is therefore divided by its own best score for this
|
|
503
|
+
* query, which preserves the shape of that channel's confidence while making the weights comparable.
|
|
504
|
+
*/
|
|
505
|
+
async search(workspaceId, query, options = {}) {
|
|
506
|
+
const wanted = Math.min(Math.max(Math.trunc(options.limit ?? DEFAULT_RESULT_LIMIT) || DEFAULT_RESULT_LIMIT, 1), MAX_RESULT_LIMIT);
|
|
507
|
+
const terms = [...new Set(tokenize(query ?? ""))];
|
|
508
|
+
const phrases = [...new Set(tokenizePhrases(query ?? ""))];
|
|
509
|
+
if (!terms.length && !phrases.length)
|
|
510
|
+
return [];
|
|
511
|
+
const { documents, averageLength } = await this.load(workspaceId);
|
|
512
|
+
if (!documents.length || !averageLength)
|
|
513
|
+
return [];
|
|
514
|
+
const chunkCount = documents.reduce((sum, document) => sum + document.chunks.length, 0);
|
|
515
|
+
// Document frequency is counted once per channel, over that channel's own unit: chunks for the
|
|
516
|
+
// passage channels, documents for the document channel.
|
|
517
|
+
const chunkDf = new Map(), phraseDf = new Map(), headDf = new Map(), docDf = new Map();
|
|
518
|
+
for (const document of documents) {
|
|
519
|
+
for (const channels of document.channels) {
|
|
520
|
+
for (const term of terms) {
|
|
521
|
+
if (channels.body.has(term))
|
|
522
|
+
chunkDf.set(term, (chunkDf.get(term) ?? 0) + 1);
|
|
523
|
+
if (channels.head.has(term))
|
|
524
|
+
headDf.set(term, (headDf.get(term) ?? 0) + 1);
|
|
525
|
+
}
|
|
526
|
+
for (const phrase of phrases)
|
|
527
|
+
if (channels.phrase.has(phrase))
|
|
528
|
+
phraseDf.set(phrase, (phraseDf.get(phrase) ?? 0) + 1);
|
|
529
|
+
}
|
|
530
|
+
for (const term of terms)
|
|
531
|
+
if (document.docTerms.has(term))
|
|
532
|
+
docDf.set(term, (docDf.get(term) ?? 0) + 1);
|
|
533
|
+
}
|
|
534
|
+
const hits = [];
|
|
535
|
+
let bodyBest = 0, phraseBest = 0, headBest = 0, docBest = 0;
|
|
536
|
+
for (const document of documents) {
|
|
537
|
+
// The document channel scores the DOCUMENT once and is then broadcast to all of its chunks: a
|
|
538
|
+
// question that names a document should reach the paragraph answering it, which may sit under a
|
|
539
|
+
// heading sharing no word with the question.
|
|
540
|
+
const documentScore = bm25(document.docTerms, document.docLength, averageLength, docDf, documents.length, terms);
|
|
541
|
+
if (documentScore > docBest)
|
|
542
|
+
docBest = documentScore;
|
|
543
|
+
document.channels.forEach((channels, position) => {
|
|
544
|
+
const chunk = document.chunks[position];
|
|
545
|
+
const body = bm25(channels.body, channels.bodyLength, averageLength, chunkDf, chunkCount, terms);
|
|
546
|
+
const phrase = bm25(channels.phrase, channels.bodyLength, averageLength, phraseDf, chunkCount, phrases);
|
|
547
|
+
const head = bm25(channels.head, channels.bodyLength, averageLength, headDf, chunkCount, terms);
|
|
548
|
+
if (body > bodyBest)
|
|
549
|
+
bodyBest = body;
|
|
550
|
+
if (phrase > phraseBest)
|
|
551
|
+
phraseBest = phrase;
|
|
552
|
+
if (head > headBest)
|
|
553
|
+
headBest = head;
|
|
554
|
+
hits.push({
|
|
555
|
+
hit: { documentId: document.meta.id, title: document.meta.title, source: document.meta.source, heading: chunk.heading, chunk: chunk.index, score: 0, text: chunk.text },
|
|
556
|
+
// Stable across index rebuilds, so vectors written by a previous run still line up.
|
|
557
|
+
key: `${document.meta.id}#${chunk.index}`,
|
|
558
|
+
channels: { body, phrase, head, document: documentScore },
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const lexical = hits.map(({ hit, key, channels }) => {
|
|
563
|
+
// A chunk is a LEXICAL candidate if it matched on its own terms - body, phrase or heading. The
|
|
564
|
+
// document channel only reweights candidates; it never creates one. Without this rule every
|
|
565
|
+
// chunk of a matching document becomes a "hit" on the strength of the document alone, which
|
|
566
|
+
// pads the pool with passages that mention nothing in the question, forces a rerank on
|
|
567
|
+
// searches that never needed one, and spends away the headroom that makes "no hit" visible.
|
|
568
|
+
const own = WEIGHT.body * (bodyBest ? channels.body / bodyBest : 0)
|
|
569
|
+
+ WEIGHT.phrase * (phraseBest ? channels.phrase / phraseBest : 0)
|
|
570
|
+
+ WEIGHT.head * (headBest ? channels.head / headBest : 0);
|
|
571
|
+
return { hit, key, own, lexical: own > 0 ? own : 0, document: channels.document };
|
|
572
|
+
});
|
|
573
|
+
// THE VECTOR CHANNEL, FUSED RATHER THAN SUBSTITUTED, and scored over EVERY chunk rather than over
|
|
574
|
+
// the lexical candidates - the chunks that share no characters with the question are precisely the
|
|
575
|
+
// ones the vector channel exists to find, so narrowing to lexical hits first would throw away the
|
|
576
|
+
// entire point. The two signals fail in opposite directions, which is the argument for both: the
|
|
577
|
+
// lexical channels miss a paraphrase completely, and a vector search returns the nearest thing at
|
|
578
|
+
// a confident-looking 0.58 when the answer is not in the knowledge base at all. The cosine keeps
|
|
579
|
+
// its own scale instead of being divided by the best hit, so "nothing here is close" survives as
|
|
580
|
+
// a low score rather than being rescaled into confidence.
|
|
581
|
+
const similarities = this.vectors
|
|
582
|
+
? await this.vectors.search(workspaceId, query ?? "", lexical.map(entry => [entry.key, entry.hit.documentId]))
|
|
583
|
+
: null;
|
|
584
|
+
const fused = lexical
|
|
585
|
+
.map(entry => {
|
|
586
|
+
const similarity = similarities?.get(entry.key) ?? 0;
|
|
587
|
+
const score = Math.round(1000 * (entry.lexical + (similarities ? WEIGHT.vector * similarity : 0)
|
|
588
|
+
+ (entry.lexical > 0 ? WEIGHT.document * (docBest ? entry.document / docBest : 0) : 0))) / 1000;
|
|
589
|
+
return { ...entry.hit, score };
|
|
590
|
+
})
|
|
591
|
+
.filter(hit => hit.score > 0);
|
|
592
|
+
// Best-first, with the tie broken by chunk order so two equal passages of one document come back
|
|
593
|
+
// in the order a reader would meet them.
|
|
594
|
+
fused.sort((a, b) => b.score - a.score || a.chunk - b.chunk);
|
|
595
|
+
const ordered = options.rerank ? await this.rerank(options.conversation, query ?? "", fused, options.signal) ?? fused : fused;
|
|
596
|
+
const selected = [];
|
|
597
|
+
let spent = 0;
|
|
598
|
+
for (const hit of ordered) {
|
|
599
|
+
if (selected.length >= wanted)
|
|
600
|
+
break;
|
|
601
|
+
const room = RESULT_TOTAL_CAP - spent;
|
|
602
|
+
if (room <= 0)
|
|
603
|
+
break;
|
|
604
|
+
// Spent in score order, so a budget that runs out drops the WORST hits rather than whichever
|
|
605
|
+
// ones happened to be visited last.
|
|
606
|
+
const text = hit.text.slice(0, Math.min(room, RESULT_TEXT_CAP));
|
|
607
|
+
if (!text)
|
|
608
|
+
break;
|
|
609
|
+
selected.push({ ...hit, text });
|
|
610
|
+
spent += text.length;
|
|
611
|
+
}
|
|
612
|
+
return selected;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Re-orders the candidate pool with the session's own model.
|
|
616
|
+
*
|
|
617
|
+
* WHY THIS EXISTS AND WHAT IT IS WORTH. Lexical retrieval ranks on shared characters, so a passage
|
|
618
|
+
* that answers in different words sits below passages that merely repeat the question's wording -
|
|
619
|
+
* often below the fold, where the character budget has already cut it off. Reading the candidates
|
|
620
|
+
* is something the model can already do, so it is asked to pick.
|
|
621
|
+
*
|
|
622
|
+
* ITS CEILING, STATED PLAINLY. Reranking can only reorder what the first stage returned. A query
|
|
623
|
+
* with NO lexical overlap at all never reaches this function, which is why the vector channel and
|
|
624
|
+
* not this one is what closes the paraphrase gap - see docs/KNOWLEDGE_BASE.md.
|
|
625
|
+
*
|
|
626
|
+
* WHY IT FAILS OPEN. Every outcome that is not a clean parse returns the lexical order. A reranker
|
|
627
|
+
* that made things worse, hung, or answered with prose would degrade the feature invisibly, and a
|
|
628
|
+
* retrieval layer that is allowed to get worse by being clever is worse than one that is not.
|
|
629
|
+
*/
|
|
630
|
+
async rerank(conversation, query, candidates, signal) {
|
|
631
|
+
if (!conversation || !this.runtime || candidates.length < RERANK_MIN_CANDIDATES || candidates.length > MAX_RESULT_LIMIT)
|
|
632
|
+
return null;
|
|
633
|
+
if (process.env.SUPER_PI_KNOWLEDGE_RERANK === "0")
|
|
634
|
+
return null;
|
|
635
|
+
const pool = candidates.slice(0, RERANK_POOL);
|
|
636
|
+
const workspace = this.store.snapshot().workspaces.find(item => item.id === conversation.workspaceId);
|
|
637
|
+
if (!workspace)
|
|
638
|
+
return null;
|
|
639
|
+
const controller = new AbortController();
|
|
640
|
+
const cancel = () => controller.abort();
|
|
641
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
642
|
+
const timer = setTimeout(cancel, RERANK_TIMEOUT_MS);
|
|
643
|
+
timer.unref();
|
|
644
|
+
try {
|
|
645
|
+
// A FRESH session id, and one that no capability is minted for: this is the server asking the
|
|
646
|
+
// model a question, not a user turn, and it must never appear in a transcript or inherit a
|
|
647
|
+
// conversation's tools.
|
|
648
|
+
const isolated = {
|
|
649
|
+
...structuredClone(conversation), id: `knowledge-rerank-${conversation.id}-${randomUUID()}`,
|
|
650
|
+
agentId: undefined, messages: [], status: "idle",
|
|
651
|
+
};
|
|
652
|
+
const listing = pool.map((hit, position) => `[${position + 1}] 《${hit.title}》${hit.heading ? ` > ${hit.heading}` : ""}\n${hit.text.slice(0, RERANK_SNIPPET_CHARS).replace(/\n+/g, " ")}`).join("\n\n");
|
|
653
|
+
const prompt = `你是检索排序器。用户提出了一个问题,下面是知识库检索出的候选片段。请按「能否回答这个问题」从高到低排序,只输出最相关的前几个片段编号,输出 JSON 数组,例如 [3,1]。不要输出解释文字,不要输出编号以外的内容。\n\n问题:${query}\n\n候选片段:\n${listing}`;
|
|
654
|
+
let output = "";
|
|
655
|
+
for await (const piece of this.runtime.answer({ conversation: isolated, prompt, cwd: workspace.path, signal: controller.signal, knowledgeRerank: true })) {
|
|
656
|
+
output += piece;
|
|
657
|
+
if (controller.signal.aborted)
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
const parsed = parseRanking(output);
|
|
661
|
+
if (!parsed?.length)
|
|
662
|
+
return null;
|
|
663
|
+
const picked = parsed.map((index) => pool[index - 1]).filter((hit) => Boolean(hit));
|
|
664
|
+
if (!picked.length)
|
|
665
|
+
return null;
|
|
666
|
+
// Anything the model left out keeps its lexical position after the picks, so a partial or
|
|
667
|
+
// truncated answer still returns the full pool rather than silently losing candidates.
|
|
668
|
+
const chosen = new Set(picked);
|
|
669
|
+
return [...picked, ...pool.filter(hit => !chosen.has(hit))];
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
clearTimeout(timer);
|
|
676
|
+
signal?.removeEventListener("abort", cancel);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Mints a read-only token for one turn. The agent can retrieve, never write: everything a person
|
|
681
|
+
* puts in a knowledge base is theirs to curate from the settings window.
|
|
682
|
+
*/
|
|
683
|
+
issue(conversationId) {
|
|
684
|
+
const conversation = this.store.snapshot().conversations.find(item => item.id === conversationId);
|
|
685
|
+
if (!conversation)
|
|
686
|
+
throw new Error("知识库源会话不存在");
|
|
687
|
+
const token = randomUUID();
|
|
688
|
+
this.capabilities.set(token, { conversationId, workspaceId: conversation.workspaceId });
|
|
689
|
+
return token;
|
|
690
|
+
}
|
|
691
|
+
release(token) { this.capabilities.delete(token); }
|
|
692
|
+
identity(token) {
|
|
693
|
+
const identity = this.capabilities.get(token);
|
|
694
|
+
// The workspace is re-checked against the store, so a token minted before a workspace was deleted
|
|
695
|
+
// cannot keep serving its documents afterwards.
|
|
696
|
+
if (!identity || !this.store.snapshot().workspaces.some(workspace => workspace.id === identity.workspaceId))
|
|
697
|
+
throw new Error("知识库授权已失效");
|
|
698
|
+
return identity;
|
|
699
|
+
}
|
|
700
|
+
/** The agent-facing endpoint: a title catalogue and a search. Nothing that writes. */
|
|
701
|
+
async tool(token, body, signal) {
|
|
702
|
+
const identity = this.identity(token);
|
|
703
|
+
if (!this.enabled())
|
|
704
|
+
throw new Error("知识库已关闭");
|
|
705
|
+
if (body.kind === "catalogue")
|
|
706
|
+
return { documents: (await this.list(identity.workspaceId)).map(document => ({ title: document.title, source: document.source, characters: document.characters })) };
|
|
707
|
+
if (body.kind !== "search")
|
|
708
|
+
throw new Error("未知知识库操作");
|
|
709
|
+
// The reranker needs the conversation it is ranking FOR: that is whose model answers, whose
|
|
710
|
+
// workspace is being searched, and whose turn this latency is charged to.
|
|
711
|
+
const conversation = this.store.snapshot().conversations.find(item => item.id === identity.conversationId);
|
|
712
|
+
const hits = await this.search(identity.workspaceId, body.query ?? "", { limit: body.limit, rerank: true, conversation, signal });
|
|
713
|
+
return { hits, reranked: hits.length > 0 && conversation !== undefined };
|
|
714
|
+
}
|
|
715
|
+
/** The browser-facing endpoint, behind the ordinary access token and same-origin check. */
|
|
716
|
+
async manage(workspaceId, body) {
|
|
717
|
+
this.requireWorkspace(workspaceId);
|
|
718
|
+
if (body.action === "add") {
|
|
719
|
+
await this.add(workspaceId, body);
|
|
720
|
+
return { ...await this.overview(workspaceId), hits: [] };
|
|
721
|
+
}
|
|
722
|
+
if (body.action === "remove") {
|
|
723
|
+
await this.remove(workspaceId, String(body.documentId ?? ""));
|
|
724
|
+
return { ...await this.overview(workspaceId), hits: [] };
|
|
725
|
+
}
|
|
726
|
+
// No `rerank` here: the settings preview is a peek at what is indexed, and it must answer while
|
|
727
|
+
// the person is still typing rather than spend a model call on a query they are about to change.
|
|
728
|
+
if (body.action === "search")
|
|
729
|
+
return { ...await this.overview(workspaceId), hits: await this.search(workspaceId, String(body.query ?? ""), { limit: body.limit }) };
|
|
730
|
+
return { ...await this.overview(workspaceId), hits: [] };
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* What the panel shows, including whether semantic retrieval is actually working.
|
|
734
|
+
*
|
|
735
|
+
* WHY THE STATE IS WORTH SHOWING. The vector half depends on an optional package and a download from
|
|
736
|
+
* a host the machine may not reach, and both fail quietly by design. Without this on screen, "the
|
|
737
|
+
* feature is off" is indistinguishable from "the feature is broken here" - which is the difference
|
|
738
|
+
* between not wanting a feature and not being able to have it.
|
|
739
|
+
*/
|
|
740
|
+
async overview(workspaceId) {
|
|
741
|
+
return { documents: await this.list(workspaceId), semantic: this.vectors ? this.vectors.status(await this.vectors.count(workspaceId)) : null };
|
|
742
|
+
}
|
|
743
|
+
}
|