tinywebui 0.1.0
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/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Local ONNX sentence embeddings for document retrieval, done the way
|
|
7
|
+
* TinySearch and TinyContext do it: the same model bundles (ONNX graph plus
|
|
8
|
+
* tokenizer files from the same Hugging Face repos), CPU inference, the same
|
|
9
|
+
* pooling and normalisation per model, and a model key stored with every
|
|
10
|
+
* vector so a model change re-embeds instead of mixing vectors.
|
|
11
|
+
*
|
|
12
|
+
* One deliberate difference: those services fetch a missing bundle on first
|
|
13
|
+
* start. The TinyWebUI server never downloads anything -- a bundle is a
|
|
14
|
+
* deployment artifact, fetched by `tinywebui models pull` / `models ensure`
|
|
15
|
+
* (which `npm start` runs first) or baked into the image, and optionally
|
|
16
|
+
* pinned by checksum.
|
|
17
|
+
*
|
|
18
|
+
* onnxruntime-node and @huggingface/tokenizers are optional dependencies,
|
|
19
|
+
* imported only when an embedder is loaded. Without them (or without the
|
|
20
|
+
* bundle) the default mode 'auto' runs lexical -- see resolveRetrieval.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// fast/balanced/quality mirror TinySearch's _PRESET_MODELS
|
|
24
|
+
// (services/embedding_service.py); all three are English-only.
|
|
25
|
+
export const PRESETS = {
|
|
26
|
+
fast: {
|
|
27
|
+
repoId: 'onnx-models/all-MiniLM-L6-v2-onnx',
|
|
28
|
+
localDir: 'all-minilm-l6-v2-onnx',
|
|
29
|
+
onnxPaths: ['model.onnx'],
|
|
30
|
+
pooling: 'auto',
|
|
31
|
+
normalize: false,
|
|
32
|
+
maxLength: 256,
|
|
33
|
+
files: ['model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt']
|
|
34
|
+
},
|
|
35
|
+
balanced: {
|
|
36
|
+
repoId: 'BAAI/bge-small-en-v1.5',
|
|
37
|
+
localDir: 'bge-small-en-v1.5-onnx',
|
|
38
|
+
onnxPaths: ['onnx/model.onnx', 'model.onnx'],
|
|
39
|
+
pooling: 'cls',
|
|
40
|
+
normalize: true,
|
|
41
|
+
maxLength: 512,
|
|
42
|
+
files: ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt', 'config.json']
|
|
43
|
+
},
|
|
44
|
+
quality: {
|
|
45
|
+
repoId: 'BAAI/bge-base-en-v1.5',
|
|
46
|
+
localDir: 'bge-base-en-v1.5-onnx',
|
|
47
|
+
onnxPaths: ['onnx/model.onnx', 'model.onnx'],
|
|
48
|
+
pooling: 'cls',
|
|
49
|
+
normalize: true,
|
|
50
|
+
maxLength: 512,
|
|
51
|
+
files: ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt', 'config.json']
|
|
52
|
+
},
|
|
53
|
+
// About as fast as `fast` (same 384 dims), but matches across languages:
|
|
54
|
+
// an English question finds a German passage. No query/passage prefixes.
|
|
55
|
+
// CLS pooling per the model card -- the export's other 2-D output is the
|
|
56
|
+
// tanh pooler, not the sentence embedding.
|
|
57
|
+
multilingual: {
|
|
58
|
+
repoId: 'ibm-granite/granite-embedding-107m-multilingual',
|
|
59
|
+
localDir: 'granite-embedding-107m-multilingual-onnx',
|
|
60
|
+
onnxPaths: ['model.onnx'],
|
|
61
|
+
pooling: 'cls',
|
|
62
|
+
normalize: true,
|
|
63
|
+
maxLength: 512,
|
|
64
|
+
files: ['model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'config.json']
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Where bundles live unless retrieval.modelDir says otherwise. */
|
|
69
|
+
export function defaultModelsDir(configPath) {
|
|
70
|
+
if (process.env.TINYWEBUI_MODELS_DIR) return resolve(process.env.TINYWEBUI_MODELS_DIR);
|
|
71
|
+
return resolve(configPath ? join(configPath, '..') : process.cwd(), 'models');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The model a retrieval config names: a preset (by name) with its bundle
|
|
76
|
+
* directory, or a custom bundle given by modelDir (pooling 'auto', no
|
|
77
|
+
* normalisation, 512 tokens -- TinySearch's defaults for custom models).
|
|
78
|
+
*/
|
|
79
|
+
export function resolveModel(retrieval, modelsDir) {
|
|
80
|
+
const name = retrieval.model || 'fast';
|
|
81
|
+
const preset = PRESETS[name];
|
|
82
|
+
if (preset) return { name, ...preset, dir: retrieval.modelDir ? resolve(retrieval.modelDir) : join(modelsDir, preset.localDir) };
|
|
83
|
+
if (!retrieval.modelDir) throw new Error(`retrieval.model "${name}" is not a preset (${Object.keys(PRESETS).join(', ')}); set retrieval.modelDir for a custom bundle`);
|
|
84
|
+
return {
|
|
85
|
+
name, repoId: name, localDir: null, dir: resolve(retrieval.modelDir),
|
|
86
|
+
onnxPaths: ['model.onnx', 'onnx/model.onnx'], pooling: 'auto', normalize: false, maxLength: 512, files: []
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findOnnxFile(spec) {
|
|
91
|
+
for (const rel of spec.onnxPaths) {
|
|
92
|
+
const p = join(spec.dir, rel);
|
|
93
|
+
if (existsSync(p) && statSync(p).isFile()) return p;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function sha256File(path) {
|
|
99
|
+
const hash = createHash('sha256');
|
|
100
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
101
|
+
return hash.digest('hex');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identifies the vectors a model produces. Like TinyContext's
|
|
106
|
+
* embedding_model_key: model identity plus a digest of the document prefix,
|
|
107
|
+
* and here the file checksum too, so a swapped model file never reuses old
|
|
108
|
+
* vectors.
|
|
109
|
+
*/
|
|
110
|
+
export function modelKey(spec, onnxSha256, documentPrefix = '', chunkOverlap = 32) {
|
|
111
|
+
const prefix = createHash('sha256').update(documentPrefix).digest('hex').slice(0, 12);
|
|
112
|
+
// Chunking is part of what a stored vector means: a different overlap
|
|
113
|
+
// cuts different chunks, so it re-embeds rather than mixing.
|
|
114
|
+
return `onnx:${spec.repoId}:${onnxSha256.slice(0, 16)}:document-prefix:${prefix}:chunks:${spec.maxLength}/${chunkOverlap}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Marks "not installed here" (packages or bundle), as opposed to a broken or
|
|
118
|
+
// mismatched install: only the former lets mode 'auto' fall back to lexical.
|
|
119
|
+
const UNAVAILABLE = 'EMBEDDING_UNAVAILABLE';
|
|
120
|
+
const unavailable = (message) => Object.assign(new Error(message), { code: UNAVAILABLE });
|
|
121
|
+
|
|
122
|
+
async function importPeer(name) {
|
|
123
|
+
try { return await import(name); } catch (err) {
|
|
124
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(err?.message || '')) {
|
|
125
|
+
throw unavailable(`dense/hybrid retrieval needs the optional packages onnxruntime-node and @huggingface/tokenizers (missing ${name}; npm install without --omit=optional)`);
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What retrieval actually runs as: { mode, embedder, fallback }. 'auto' (the
|
|
133
|
+
* default) is hybrid when the model and packages are installed, and lexical
|
|
134
|
+
* otherwise, with `fallback` saying why. An explicit 'dense' or 'hybrid'
|
|
135
|
+
* never falls back -- a missing model stops startup, as asked. Anything
|
|
136
|
+
* wrong with an installed model (a checksum mismatch, a broken file) fails
|
|
137
|
+
* in every mode rather than being papered over.
|
|
138
|
+
*/
|
|
139
|
+
export async function resolveRetrieval(retrieval, modelsDir, load = loadEmbedder) {
|
|
140
|
+
if (retrieval.mode === 'lexical') return { mode: 'lexical', embedder: null, fallback: null };
|
|
141
|
+
const mode = retrieval.mode === 'auto' ? 'hybrid' : retrieval.mode;
|
|
142
|
+
try {
|
|
143
|
+
return { mode, embedder: await load(retrieval, modelsDir), fallback: null };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (retrieval.mode !== 'auto' || err.code !== UNAVAILABLE) throw err;
|
|
146
|
+
return { mode: 'lexical', embedder: null, fallback: err.message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const l2normalize = (v) => {
|
|
151
|
+
let n = 0;
|
|
152
|
+
for (const x of v) n += x * x;
|
|
153
|
+
n = Math.sqrt(n) || 1e-12;
|
|
154
|
+
for (let i = 0; i < v.length; i++) v[i] /= n;
|
|
155
|
+
return v;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Loads a bundle and returns { key, dim, embed(texts) -> Float32Array[] }.
|
|
160
|
+
* Fails clearly -- missing packages, missing bundle, checksum mismatch --
|
|
161
|
+
* and never reaches the network.
|
|
162
|
+
*/
|
|
163
|
+
export async function loadEmbedder(retrieval, modelsDir) {
|
|
164
|
+
const spec = resolveModel(retrieval, modelsDir);
|
|
165
|
+
const onnxPath = findOnnxFile(spec);
|
|
166
|
+
const tokenizerPath = join(spec.dir, 'tokenizer.json');
|
|
167
|
+
if (!onnxPath || !existsSync(tokenizerPath)) {
|
|
168
|
+
const hint = PRESETS[spec.name] ? `run: tinywebui models pull ${spec.name}` : 'the bundle needs an ONNX model and tokenizer.json';
|
|
169
|
+
throw unavailable(`no embedding bundle at ${spec.dir} (${hint}); the server never downloads models itself`);
|
|
170
|
+
}
|
|
171
|
+
const actual = await sha256File(onnxPath);
|
|
172
|
+
if (retrieval.modelSha256 && retrieval.modelSha256.toLowerCase() !== actual) {
|
|
173
|
+
throw new Error(`embedding model checksum mismatch for ${onnxPath}: expected ${retrieval.modelSha256}, got ${actual}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ONNX Runtime ships telemetry; a local, private deployment keeps it off
|
|
177
|
+
// unless the operator explicitly says otherwise.
|
|
178
|
+
process.env.ORT_DISABLE_TELEMETRY ??= '1';
|
|
179
|
+
const ort = await importPeer('onnxruntime-node');
|
|
180
|
+
const { Tokenizer } = await importPeer('@huggingface/tokenizers');
|
|
181
|
+
const configPath = join(spec.dir, 'tokenizer_config.json');
|
|
182
|
+
const tokenizer = new Tokenizer(
|
|
183
|
+
JSON.parse(readFileSync(tokenizerPath, 'utf8')),
|
|
184
|
+
existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : {}
|
|
185
|
+
);
|
|
186
|
+
const session = await ort.InferenceSession.create(onnxPath, { executionProviders: ['cpu'] });
|
|
187
|
+
const inputNames = new Set(session.inputNames);
|
|
188
|
+
const pooledOutput = session.outputNames.includes('sentence_embedding') ? 'sentence_embedding' : null;
|
|
189
|
+
|
|
190
|
+
const encode = (text) => {
|
|
191
|
+
let { ids } = tokenizer.encode(text);
|
|
192
|
+
// Truncate like TinySearch's enable_truncation: keep the closing special token.
|
|
193
|
+
if (ids.length > spec.maxLength) ids = [...ids.slice(0, spec.maxLength - 1), ids[ids.length - 1]];
|
|
194
|
+
return ids;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// The model's own wrapper tokens ([CLS] … [SEP] for the BERT-family presets).
|
|
198
|
+
const wrapper = tokenizer.encode('').ids;
|
|
199
|
+
const open = wrapper.length >= 1 ? wrapper.slice(0, 1) : [];
|
|
200
|
+
const close = wrapper.length >= 2 ? wrapper.slice(-1) : [];
|
|
201
|
+
const chunkOverlap = retrieval.chunkOverlap ?? 32;
|
|
202
|
+
/**
|
|
203
|
+
* Small-to-big: a passage (what read_document returns) is cut into chunks
|
|
204
|
+
* (what gets embedded). Chunk length is the model's own token limit --
|
|
205
|
+
* 256 for `fast`, 512 for the others -- so every token of a passage
|
|
206
|
+
* is embedded somewhere; neighbouring chunks overlap by chunkOverlap
|
|
207
|
+
* tokens so a sentence cut at a boundary still lands whole in one of them.
|
|
208
|
+
*/
|
|
209
|
+
const chunksOf = (text) => {
|
|
210
|
+
const body = tokenizer.encode(text, { add_special_tokens: false }).ids;
|
|
211
|
+
const room = spec.maxLength - open.length - close.length;
|
|
212
|
+
if (body.length <= room) return [[...open, ...body, ...close]];
|
|
213
|
+
const out = [];
|
|
214
|
+
for (let start = 0; start < body.length; start += room - chunkOverlap) {
|
|
215
|
+
out.push([...open, ...body.slice(start, start + room), ...close]);
|
|
216
|
+
if (start + room >= body.length) break;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// One run at a time, like TinySearch's _EMBED_LOCK: ORT sessions are not
|
|
222
|
+
// meant for overlapping runs, and CPU inference gains nothing from it.
|
|
223
|
+
let queue = Promise.resolve();
|
|
224
|
+
const runLocked = (fn) => { const next = queue.then(fn, fn); queue = next.catch(() => {}); return next; };
|
|
225
|
+
|
|
226
|
+
async function embedBatch(encoded) {
|
|
227
|
+
const texts = encoded;
|
|
228
|
+
const maxLen = Math.max(1, ...encoded.map((ids) => ids.length));
|
|
229
|
+
const n = texts.length;
|
|
230
|
+
const ids = new BigInt64Array(n * maxLen);
|
|
231
|
+
const mask = new BigInt64Array(n * maxLen);
|
|
232
|
+
encoded.forEach((row, r) => row.forEach((id, c) => { ids[r * maxLen + c] = BigInt(id); mask[r * maxLen + c] = 1n; }));
|
|
233
|
+
const feeds = {};
|
|
234
|
+
const dims = [n, maxLen];
|
|
235
|
+
if (inputNames.has('input_ids')) feeds.input_ids = new ort.Tensor('int64', ids, dims);
|
|
236
|
+
if (inputNames.has('attention_mask')) feeds.attention_mask = new ort.Tensor('int64', mask, dims);
|
|
237
|
+
if (inputNames.has('token_type_ids')) feeds.token_type_ids = new ort.Tensor('int64', new BigInt64Array(n * maxLen), dims);
|
|
238
|
+
|
|
239
|
+
const results = await session.run(feeds);
|
|
240
|
+
let rows;
|
|
241
|
+
const pick = (rank) => Object.values(results).find((t) => t.dims.length === rank);
|
|
242
|
+
const pooled = pooledOutput ? results[pooledOutput] : spec.pooling === 'cls' ? null : pick(2);
|
|
243
|
+
if (pooled) {
|
|
244
|
+
const dim = pooled.dims[1];
|
|
245
|
+
rows = Array.from({ length: n }, (_, r) => Float32Array.from(pooled.data.subarray(r * dim, (r + 1) * dim)));
|
|
246
|
+
} else {
|
|
247
|
+
// CLS pooling: the first token's hidden state.
|
|
248
|
+
const tokens = pick(3);
|
|
249
|
+
if (!tokens) throw new Error(`ONNX model ${spec.repoId} has no usable embedding output`);
|
|
250
|
+
const [, seq, dim] = tokens.dims;
|
|
251
|
+
rows = Array.from({ length: n }, (_, r) => Float32Array.from(tokens.data.subarray(r * seq * dim, r * seq * dim + dim)));
|
|
252
|
+
}
|
|
253
|
+
return spec.normalize ? rows.map(l2normalize) : rows;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function embedIds(idLists) {
|
|
257
|
+
const out = [];
|
|
258
|
+
for (let i = 0; i < idLists.length; i += 32) out.push(...await runLocked(() => embedBatch(idLists.slice(i, i + 32))));
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** One vector per text, truncated to the model's length (queries). */
|
|
263
|
+
const embed = (texts) => embedIds(texts.map(encode));
|
|
264
|
+
|
|
265
|
+
/** Each passage as its chunks' vectors: Float32Array[][] (passage -> chunks). */
|
|
266
|
+
async function embedPassages(texts) {
|
|
267
|
+
const chunks = texts.map(chunksOf);
|
|
268
|
+
const flat = await embedIds(chunks.flat());
|
|
269
|
+
let i = 0;
|
|
270
|
+
return chunks.map((c) => c.map(() => flat[i++]));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const [probe] = await embed(['probe']);
|
|
274
|
+
return {
|
|
275
|
+
key: modelKey(spec, actual, retrieval.documentPrefix || '', chunkOverlap),
|
|
276
|
+
chunkTokens: spec.maxLength,
|
|
277
|
+
dim: probe.length,
|
|
278
|
+
spec,
|
|
279
|
+
sha256: actual,
|
|
280
|
+
embed,
|
|
281
|
+
embedPassages,
|
|
282
|
+
close: () => session.release?.()
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Files a preset bundle consists of, for `tinywebui models pull`. */
|
|
287
|
+
export function bundleListing(dir) {
|
|
288
|
+
const out = [];
|
|
289
|
+
const walk = (d, rel = '') => {
|
|
290
|
+
for (const name of readdirSync(d)) {
|
|
291
|
+
const p = join(d, name);
|
|
292
|
+
if (statSync(p).isDirectory()) walk(p, `${rel}${name}/`);
|
|
293
|
+
else out.push(`${rel}${name}`);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
if (existsSync(dir)) walk(dir);
|
|
297
|
+
return out.sort();
|
|
298
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { CORPUS } from '../store/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Search for everything the model can look things up in: lexical (FTS5 BM25,
|
|
6
|
+
* the default), dense (local embeddings), or hybrid (both, fused). One engine,
|
|
7
|
+
* any number of CORPORA -- documents and past chat turns ship built in.
|
|
8
|
+
*
|
|
9
|
+
* A corpus is a set of searchable UNITS (a document passage, a chat turn),
|
|
10
|
+
* each belonging to an OWNER (the document, the chat). It tells the engine:
|
|
11
|
+
*
|
|
12
|
+
* units(owner) [{ id, text }] -- what to embed for an owner
|
|
13
|
+
* ownersMissing(modelKey) owners with units not yet embedded (backfill)
|
|
14
|
+
* candidates(scope) unit ids one search ranks
|
|
15
|
+
* lexical(scope, query, {any}) Map(unit id -> BM25, higher is better)
|
|
16
|
+
* hydrate(hits, scope) the result rows, from [{ id, rank, dense, bm25 }]
|
|
17
|
+
* ownersOf?(scope) owners to embed before searching (else rely on ingest/backfill)
|
|
18
|
+
* mutable? true when a unit's text can change under the same id
|
|
19
|
+
* maintain?(engine, log) one-time upkeep at backfill (e.g. re-splitting)
|
|
20
|
+
*
|
|
21
|
+
* Small-to-big: a unit is cut into CHUNKS sized to the embedding model, every
|
|
22
|
+
* chunk is embedded, a unit scores by its best chunk, and the caller gets the
|
|
23
|
+
* whole unit. BM25 scores units directly.
|
|
24
|
+
*
|
|
25
|
+
* The fusion is TinySearch's weighted Reciprocal Rank Fusion
|
|
26
|
+
* (hybrid_embed_search_service.py): each side ranks every candidate, which
|
|
27
|
+
* scores
|
|
28
|
+
*
|
|
29
|
+
* (1 - denseWeight) / (k + bm25Rank) + denseWeight / (k + denseRank)
|
|
30
|
+
*
|
|
31
|
+
* with k = 60 and denseWeight = 0.5 by default, and ties break on the dense
|
|
32
|
+
* score, then the BM25 score, then candidate order -- fully deterministic.
|
|
33
|
+
*
|
|
34
|
+
* Vectors are computed once per unit, stored in SQLite (`embeddings`), and
|
|
35
|
+
* reused by every query; only the query itself is embedded at search time.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const RETRIEVAL_MODES = ['lexical', 'dense', 'hybrid'];
|
|
39
|
+
|
|
40
|
+
const cosine = (a, b) => {
|
|
41
|
+
let dot = 0, na = 0, nb = 0;
|
|
42
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
43
|
+
return na && nb ? dot / Math.sqrt(na * nb) : 0;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const digestOf = (text) => createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 1-based competition ranks by descending score: equal scores share a rank
|
|
50
|
+
* (1, 2, 2, 4). One deliberate departure from TinySearch's _rank_by_score,
|
|
51
|
+
* which numbers ties in input order -- there, a query with no BM25 match
|
|
52
|
+
* at all still hands the first candidate a lexical lead. Shared ranks make a
|
|
53
|
+
* side that cannot tell candidates apart stay neutral.
|
|
54
|
+
*/
|
|
55
|
+
function ranksOf(scores) {
|
|
56
|
+
const order = scores.map((s, i) => i).sort((a, b) => scores[b] - scores[a]);
|
|
57
|
+
const ranks = new Array(scores.length);
|
|
58
|
+
order.forEach((idx, r) => {
|
|
59
|
+
const prev = order[r - 1];
|
|
60
|
+
ranks[idx] = r > 0 && scores[prev] === scores[idx] ? ranks[prev] : r + 1;
|
|
61
|
+
});
|
|
62
|
+
return ranks;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Weighted RRF over parallel score lists. Returns candidate indices, best
|
|
67
|
+
* first, with the scores that decided them.
|
|
68
|
+
*/
|
|
69
|
+
export function fuse(bm25Scores, denseScores, { denseWeight = 0.5, k = 60 } = {}) {
|
|
70
|
+
const sparseWeight = 1 - denseWeight;
|
|
71
|
+
const bm25Ranks = ranksOf(bm25Scores);
|
|
72
|
+
const denseRanks = ranksOf(denseScores);
|
|
73
|
+
const rows = bm25Scores.map((_, i) => ({
|
|
74
|
+
index: i,
|
|
75
|
+
bm25: bm25Scores[i],
|
|
76
|
+
dense: denseScores[i],
|
|
77
|
+
rrf: (sparseWeight > 0 ? sparseWeight / (k + bm25Ranks[i]) : 0) + (denseWeight > 0 ? denseWeight / (k + denseRanks[i]) : 0)
|
|
78
|
+
}));
|
|
79
|
+
rows.sort((a, b) => b.rrf - a.rrf || b.dense - a.dense || b.bm25 - a.bm25 || a.index - b.index);
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Attached documents: one unit per passage. Scope: { docId }. */
|
|
84
|
+
export function documentsCorpus(store) {
|
|
85
|
+
return {
|
|
86
|
+
units: (docId) => store.documents.passages(docId).map((p) => ({ id: p.rowid, text: p.body })),
|
|
87
|
+
ownersMissing: (key) => store.documents.missingVectors(key),
|
|
88
|
+
ownersOf: ({ docId }) => [docId],
|
|
89
|
+
candidates: ({ docId }) => store.documents.passages(docId).map((p) => p.rowid),
|
|
90
|
+
lexical: ({ docId }, query, { any }) => store.documents.lexicalScores(docId, query, { any }),
|
|
91
|
+
hydrate(hits, { docId }) {
|
|
92
|
+
const byId = new Map(store.documents.passages(docId).map((p) => [p.rowid, p]));
|
|
93
|
+
return hits.map((h) => {
|
|
94
|
+
const p = byId.get(h.id);
|
|
95
|
+
return { passageIdx: p.passageIdx, charStart: p.charStart, body: p.body, rank: h.rank, dense: h.dense, bm25: h.bm25 };
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
maintain(engine, log) {
|
|
99
|
+
const { passageSize, passageOverlap } = engine.passageSettings();
|
|
100
|
+
const stale = store.documents.withOtherPassages(passageSize, passageOverlap);
|
|
101
|
+
for (const id of stale) store.documents.repassage(id, passageSize, passageOverlap);
|
|
102
|
+
if (stale.length) log(`re-split ${stale.length} document(s) into ${passageSize}-character passages`);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What of a turn gets embedded: the question and the start of the answer.
|
|
108
|
+
const TURN_QUESTION_CHARS = 2000;
|
|
109
|
+
const TURN_ANSWER_CHARS = 6000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Past conversations: one unit per turn -- a question and the answer it
|
|
113
|
+
* finally got. Scope: { userId, excludeChatId }. A turn's answer can still
|
|
114
|
+
* change (the run was going when it was last embedded), so it is mutable.
|
|
115
|
+
*/
|
|
116
|
+
export function chatsCorpus(store) {
|
|
117
|
+
const turnText = (t) => `${t.question.slice(0, TURN_QUESTION_CHARS)}\n\n${t.answer.slice(0, TURN_ANSWER_CHARS)}`.trim();
|
|
118
|
+
return {
|
|
119
|
+
mutable: true,
|
|
120
|
+
units: (chatId) => store.turns.forChat(chatId).map((t) => ({ id: t.id, text: turnText(t) })).filter((u) => u.text),
|
|
121
|
+
ownersMissing: (key) => store.turns.chatsMissingVectors(key),
|
|
122
|
+
candidates: ({ userId, excludeChatId }) => store.turns.ids(userId, { excludeChatId }),
|
|
123
|
+
lexical: ({ userId, excludeChatId }, query, { any }) => store.turns.lexicalScores(userId, query, { excludeChatId, any }),
|
|
124
|
+
hydrate(hits) {
|
|
125
|
+
const turns = store.turns.byIds(hits.map((h) => h.id));
|
|
126
|
+
return hits.filter((h) => turns.has(h.id)).map((h) => ({ ...turns.get(h.id), rank: h.rank, dense: h.dense, bm25: h.bm25 }));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The retrieval engine for one running instance. `embedder` is null in
|
|
133
|
+
* lexical mode; otherwise it is what loadEmbedder() returned (or a stand-in
|
|
134
|
+
* with the same { key, embed } shape, which is how the tests stay offline).
|
|
135
|
+
* Documents and chats are registered; `register` adds more.
|
|
136
|
+
*/
|
|
137
|
+
export class Retrieval {
|
|
138
|
+
constructor(store, retrieval, embedder = null) {
|
|
139
|
+
this.store = store;
|
|
140
|
+
this.cfg = retrieval;
|
|
141
|
+
// 'auto' means whatever the embedder allows (resolveRetrieval decides at startup).
|
|
142
|
+
const mode = retrieval.mode || 'lexical';
|
|
143
|
+
this.mode = mode === 'auto' ? (embedder ? 'hybrid' : 'lexical') : mode;
|
|
144
|
+
this.embedder = this.mode === 'lexical' ? null : embedder;
|
|
145
|
+
if (this.mode !== 'lexical' && !this.embedder) throw new Error(`retrieval.mode '${this.mode}' needs an embedding model`);
|
|
146
|
+
this.corpora = new Map();
|
|
147
|
+
this.pending = new Map(); // "corpus:owner" -> in-flight ingestion, so a query can wait for it
|
|
148
|
+
this.register(CORPUS.DOCUMENTS, documentsCorpus(store));
|
|
149
|
+
this.register(CORPUS.CHATS, chatsCorpus(store));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
register(name, corpus) {
|
|
153
|
+
this.corpora.set(name, corpus);
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#corpus(name) {
|
|
158
|
+
const c = this.corpora.get(name);
|
|
159
|
+
if (!c) throw new Error(`retrieval: no corpus "${name}"`);
|
|
160
|
+
return c;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Embeds whatever of one owner's units has no vectors for the current model
|
|
165
|
+
* (or, for a mutable corpus, vectors of text that has since changed), and
|
|
166
|
+
* drops vectors of units the owner no longer has. Idempotent: an owner that
|
|
167
|
+
* is up to date costs nothing to ingest again.
|
|
168
|
+
*/
|
|
169
|
+
ingest(name, ownerId) {
|
|
170
|
+
if (!this.embedder) return Promise.resolve(0);
|
|
171
|
+
const corpus = this.#corpus(name);
|
|
172
|
+
const tag = `${name}:${ownerId}`;
|
|
173
|
+
if (this.pending.has(tag)) return this.pending.get(tag);
|
|
174
|
+
const job = (async () => {
|
|
175
|
+
const key = this.embedder.key;
|
|
176
|
+
const units = corpus.units(ownerId).map((u) => ({ ...u, digest: corpus.mutable ? digestOf(u.text) : '' }));
|
|
177
|
+
const have = this.store.embeddings.digests(name, ownerId, key);
|
|
178
|
+
if ([...have.keys()].some((id) => !units.some((u) => u.id === id))) this.store.embeddings.dropStale(name, ownerId, units.map((u) => u.id));
|
|
179
|
+
const missing = units.filter((u) => have.get(u.id) !== u.digest);
|
|
180
|
+
if (!missing.length) return 0;
|
|
181
|
+
const prefix = this.cfg.documentPrefix || '';
|
|
182
|
+
const texts = missing.map((u) => prefix + u.text);
|
|
183
|
+
const vecs = this.embedder.embedPassages
|
|
184
|
+
? await this.embedder.embedPassages(texts)
|
|
185
|
+
: (await this.embedder.embed(texts)).map((v) => [v]);
|
|
186
|
+
// The owner may have been deleted while it was being embedded.
|
|
187
|
+
const still = new Set(corpus.units(ownerId).map((u) => u.id));
|
|
188
|
+
const rows = missing.map((u, i) => ({ unitId: u.id, digest: u.digest, vecs: vecs[i] })).filter((r) => still.has(r.unitId));
|
|
189
|
+
if (rows.length) this.store.embeddings.put(name, ownerId, key, rows);
|
|
190
|
+
return rows.length;
|
|
191
|
+
})().finally(() => this.pending.delete(tag));
|
|
192
|
+
this.pending.set(tag, job);
|
|
193
|
+
return job;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Brings every corpus up to the current settings: each corpus's own upkeep
|
|
198
|
+
* (every mode), then embeds whatever the current model has no vectors for,
|
|
199
|
+
* dropping vectors from old models.
|
|
200
|
+
*/
|
|
201
|
+
async backfill(log = () => {}) {
|
|
202
|
+
for (const corpus of this.corpora.values()) corpus.maintain?.(this, log);
|
|
203
|
+
if (!this.embedder) return 0;
|
|
204
|
+
const pruned = this.store.embeddings.prune(this.embedder.key);
|
|
205
|
+
if (pruned) log(`dropped ${pruned} chunk vector(s) from a previous model or chunking`);
|
|
206
|
+
let total = 0;
|
|
207
|
+
for (const [name, corpus] of this.corpora) {
|
|
208
|
+
let n = 0;
|
|
209
|
+
for (const owner of corpus.ownersMissing(this.embedder.key)) n += await this.ingest(name, owner);
|
|
210
|
+
if (n) log(`embedded ${n} ${name} unit(s) for ${this.mode} retrieval`);
|
|
211
|
+
total += n;
|
|
212
|
+
}
|
|
213
|
+
return total;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The passage split new documents get. */
|
|
217
|
+
passageSettings() {
|
|
218
|
+
return { passageSize: this.cfg.passageSize ?? 1800, passageOverlap: this.cfg.passageOverlap ?? 200 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The best units of a corpus for a query, as that corpus hydrates them,
|
|
223
|
+
* each with the scores that ranked it. Same call in every mode.
|
|
224
|
+
*/
|
|
225
|
+
async search(name, scope, query, limit = 5) {
|
|
226
|
+
const corpus = this.#corpus(name);
|
|
227
|
+
if (!String(query ?? '').trim()) return [];
|
|
228
|
+
if (this.mode === 'lexical') {
|
|
229
|
+
// Every query word must match, like a search box; best BM25 first.
|
|
230
|
+
const hits = [...corpus.lexical(scope, query, { any: false })]
|
|
231
|
+
.sort((a, b) => b[1] - a[1])
|
|
232
|
+
.slice(0, limit)
|
|
233
|
+
.map(([id, s]) => ({ id, rank: s, dense: null, bm25: s }));
|
|
234
|
+
return corpus.hydrate(hits, scope);
|
|
235
|
+
}
|
|
236
|
+
for (const owner of corpus.ownersOf?.(scope) || []) await this.ingest(name, owner);
|
|
237
|
+
const ids = corpus.candidates(scope);
|
|
238
|
+
if (!ids.length) return [];
|
|
239
|
+
const vectors = this.store.embeddings.vectors(name, this.embedder.key, ids);
|
|
240
|
+
const [q] = await this.embedder.embed([(this.cfg.queryPrefix || '') + query]);
|
|
241
|
+
const dense = ids.map((id) => (vectors.has(id) ? Math.max(...vectors.get(id).map((v) => cosine(q, v))) : -1));
|
|
242
|
+
const lexical = corpus.lexical(scope, query, { any: true });
|
|
243
|
+
const bm25 = ids.map((id) => lexical.get(id) ?? 0);
|
|
244
|
+
const denseWeight = this.mode === 'dense' ? 1 : (this.cfg.denseWeight ?? 0.5);
|
|
245
|
+
const hits = fuse(bm25, dense, { denseWeight, k: this.cfg.rrfK ?? 60 })
|
|
246
|
+
.slice(0, limit)
|
|
247
|
+
.map(({ index, rrf, dense: d, bm25: b }) => ({ id: ids[index], rank: rrf, dense: d, bm25: b }));
|
|
248
|
+
return corpus.hydrate(hits, scope);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { json, readJson } from '../http.js';
|
|
2
|
+
import { ALL_USERS, toView } from '../store/index.js';
|
|
3
|
+
import { validateConfig, fingerprint, resolveAccess } from '../access/policy.js';
|
|
4
|
+
import { destroyUserSessions } from '../access/auth.js';
|
|
5
|
+
import { audit } from '../audit.js';
|
|
6
|
+
import { FEATURE } from '../../public/shared/features.js';
|
|
7
|
+
|
|
8
|
+
function adminUserView(u) {
|
|
9
|
+
return {
|
|
10
|
+
id: u.id, email: u.email, name: u.name ?? null, role: u.role, status: u.status,
|
|
11
|
+
createdAt: u.created_at, lastLoginAt: u.last_login_at, chatCount: u.chat_count ?? null
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* User management and oversight. They only mean something when there is more
|
|
17
|
+
* than one person; 'none' and 'single' are just you, and get admin_only.
|
|
18
|
+
*/
|
|
19
|
+
export function adminRoutes({ config, multiUser, refreshConfig, runs, source, store }) {
|
|
20
|
+
|
|
21
|
+
// Wraps a handler so it refuses outside multi-user deployments.
|
|
22
|
+
const multi = (handle) => (ctx) => (multiUser() ? handle(ctx) : json(ctx.res, 403, { error: 'admin_only' }));
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
{ method: 'GET', path: /^\/api\/admin\/users$/, feature: FEATURE.ADMIN, handle: multi(({ res }) => {
|
|
26
|
+
const pins = resolveAccess(config());
|
|
27
|
+
const code = source.codeAccessUsers();
|
|
28
|
+
const pinnedBy = (u) => (pins.bootstrapAdmins.includes(u.external_id) ? 'bootstrapAdmins'
|
|
29
|
+
: code[u.external_id] ? 'access.users' : null);
|
|
30
|
+
return json(res, 200, {
|
|
31
|
+
fingerprint: fingerprint(config(), source.loadMcpServers()),
|
|
32
|
+
users: store.users.list().map((u) => ({ ...adminUserView(u), pinnedInCode: pinnedBy(u) }))
|
|
33
|
+
});
|
|
34
|
+
}) },
|
|
35
|
+
|
|
36
|
+
{ method: 'PATCH', path: /^\/api\/admin\/users\/([\w-]+)$/, feature: FEATURE.ADMIN, handle: multi(async ({ req, res, auth, params: [id] }) => {
|
|
37
|
+
const target = store.users.get(id);
|
|
38
|
+
if (!target) return json(res, 404, { error: 'no such user' });
|
|
39
|
+
const { role, status } = await readJson(req);
|
|
40
|
+
if (role !== undefined && !['admin', 'user'].includes(role)) return json(res, 400, { error: 'role must be admin or user' });
|
|
41
|
+
if (status !== undefined && !['pending', 'approved', 'disabled'].includes(status)) {
|
|
42
|
+
return json(res, 400, { error: 'status must be pending, approved or disabled' });
|
|
43
|
+
}
|
|
44
|
+
if (target.id === auth.userId && ((role && role !== 'admin') || (status && status !== 'approved'))) {
|
|
45
|
+
return json(res, 400, { error: 'you cannot demote or disable your own account' });
|
|
46
|
+
}
|
|
47
|
+
const pins = resolveAccess(config());
|
|
48
|
+
if (target.external_id && pins.bootstrapAdmins.includes(target.external_id)) {
|
|
49
|
+
return json(res, 400, { error: 'this admin is declared in code (access.bootstrapAdmins)' });
|
|
50
|
+
}
|
|
51
|
+
// The file is the record: the decision goes into config.json's
|
|
52
|
+
// access.users first, and only then into the database.
|
|
53
|
+
if (target.external_id) {
|
|
54
|
+
const file = source.readFile();
|
|
55
|
+
const access = file.access && typeof file.access === 'object' ? file.access : {};
|
|
56
|
+
const users = { ...(access.users || {}) };
|
|
57
|
+
users[target.external_id] = {
|
|
58
|
+
...(users[target.external_id] || {}),
|
|
59
|
+
...(role !== undefined ? { role } : {}),
|
|
60
|
+
...(status !== undefined ? { status } : {})
|
|
61
|
+
};
|
|
62
|
+
const merged = { ...file, access: { ...access, users } };
|
|
63
|
+
const problems = validateConfig({ ...config(), access: merged.access });
|
|
64
|
+
if (problems.length) return json(res, 400, { error: problems.join('; ') });
|
|
65
|
+
if (source.codeAccessUsers()[target.external_id]) return json(res, 409, { error: 'this user is pinned in code (access.users)' });
|
|
66
|
+
source.writeFile(merged);
|
|
67
|
+
refreshConfig();
|
|
68
|
+
}
|
|
69
|
+
const updated = store.users.update(target.id, { role, status });
|
|
70
|
+
if (role !== undefined && role !== target.role) {
|
|
71
|
+
audit('user.role_changed', { userId: target.id, from: target.role, to: role, by: auth.userId });
|
|
72
|
+
}
|
|
73
|
+
if (status !== undefined && status !== target.status) {
|
|
74
|
+
audit('user.status_changed', { userId: target.id, from: target.status, to: status, by: auth.userId });
|
|
75
|
+
if (status !== 'approved') destroyUserSessions(store, target.id);
|
|
76
|
+
}
|
|
77
|
+
return json(res, 200, { user: adminUserView(updated) });
|
|
78
|
+
}) },
|
|
79
|
+
|
|
80
|
+
// Oversight: read-only, every view audited. Reached through ALL_USERS on
|
|
81
|
+
// purpose -- this is the one place a user's scope is crossed, and it only
|
|
82
|
+
// exists when there are users to cross between.
|
|
83
|
+
{ method: 'GET', path: /^\/api\/admin\/users\/([\w-]+)\/chats$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
84
|
+
const target = store.users.get(id);
|
|
85
|
+
if (!target) return json(res, 404, { error: 'no such user' });
|
|
86
|
+
audit('admin.view_user_chats', { by: auth.userId, userId: target.id });
|
|
87
|
+
const chats = store.chats.list(500, target.id).map((c) => ({ ...c, running: runs.isRunning(c.id) }));
|
|
88
|
+
return json(res, 200, { user: adminUserView(target), chats, summary: store.usage.statistics(target.id).summary });
|
|
89
|
+
}) },
|
|
90
|
+
|
|
91
|
+
{ method: 'GET', path: /^\/api\/admin\/chats\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
92
|
+
const found = store.chats.get(id, ALL_USERS);
|
|
93
|
+
if (!found) return json(res, 404, { error: 'no such chat' });
|
|
94
|
+
audit('admin.view_chat', { by: auth.userId, chatId: found.id, owner: found.user_id });
|
|
95
|
+
const owner = found.user_id ? store.users.get(found.user_id) : null;
|
|
96
|
+
return json(res, 200, {
|
|
97
|
+
id: found.id, title: found.title, updatedAt: found.updated_at,
|
|
98
|
+
owner: owner ? adminUserView(owner) : null,
|
|
99
|
+
running: runs.isRunning(found.id),
|
|
100
|
+
messages: store.messages.list(found.id).map(toView),
|
|
101
|
+
documents: store.documents.list(found.id)
|
|
102
|
+
});
|
|
103
|
+
}) },
|
|
104
|
+
|
|
105
|
+
{ method: 'GET', path: /^\/api\/admin\/documents\/([\w.-]+)$/, feature: FEATURE.OVERSIGHT, handle: multi(({ res, auth, params: [id] }) => {
|
|
106
|
+
const doc = store.documents.get(id, ALL_USERS);
|
|
107
|
+
if (!doc) return json(res, 404, { error: 'no such document' });
|
|
108
|
+
audit('admin.view_document', { by: auth.userId, documentId: doc.id, chatId: doc.chat_id });
|
|
109
|
+
return json(res, 200, { filename: doc.filename, mime: doc.mime, content: doc.content });
|
|
110
|
+
}) }
|
|
111
|
+
];
|
|
112
|
+
}
|