tinywebui 0.1.4 → 0.1.5
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/README.md +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
|
@@ -1,106 +1,93 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { createHash } from 'node:crypto';
|
|
2
10
|
import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
11
|
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
12
|
export const PRESETS = {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
13
|
+
fast: {
|
|
14
|
+
repoId: 'onnx-models/all-MiniLM-L6-v2-onnx',
|
|
15
|
+
localDir: 'all-minilm-l6-v2-onnx',
|
|
16
|
+
onnxPaths: ['model.onnx'],
|
|
17
|
+
pooling: 'auto',
|
|
18
|
+
normalize: false,
|
|
19
|
+
maxLength: 256,
|
|
20
|
+
files: ['model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt']
|
|
21
|
+
},
|
|
22
|
+
balanced: {
|
|
23
|
+
repoId: 'BAAI/bge-small-en-v1.5',
|
|
24
|
+
localDir: 'bge-small-en-v1.5-onnx',
|
|
25
|
+
onnxPaths: ['onnx/model.onnx', 'model.onnx'],
|
|
26
|
+
pooling: 'cls',
|
|
27
|
+
normalize: true,
|
|
28
|
+
maxLength: 512,
|
|
29
|
+
files: ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt', 'config.json']
|
|
30
|
+
},
|
|
31
|
+
quality: {
|
|
32
|
+
repoId: 'BAAI/bge-base-en-v1.5',
|
|
33
|
+
localDir: 'bge-base-en-v1.5-onnx',
|
|
34
|
+
onnxPaths: ['onnx/model.onnx', 'model.onnx'],
|
|
35
|
+
pooling: 'cls',
|
|
36
|
+
normalize: true,
|
|
37
|
+
maxLength: 512,
|
|
38
|
+
files: ['onnx/model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'vocab.txt', 'config.json']
|
|
39
|
+
},
|
|
40
|
+
// About as fast as `fast` (same 384 dims), but matches across languages:
|
|
41
|
+
// an English question finds a German passage. No query/passage prefixes.
|
|
42
|
+
// CLS pooling per the model card -- the export's other 2-D output is the
|
|
43
|
+
// tanh pooler, not the sentence embedding.
|
|
44
|
+
multilingual: {
|
|
45
|
+
repoId: 'ibm-granite/granite-embedding-107m-multilingual',
|
|
46
|
+
localDir: 'granite-embedding-107m-multilingual-onnx',
|
|
47
|
+
onnxPaths: ['model.onnx'],
|
|
48
|
+
pooling: 'cls',
|
|
49
|
+
normalize: true,
|
|
50
|
+
maxLength: 512,
|
|
51
|
+
files: ['model.onnx', 'tokenizer.json', 'tokenizer_config.json', 'special_tokens_map.json', 'config.json']
|
|
52
|
+
}
|
|
66
53
|
};
|
|
67
|
-
|
|
68
54
|
/** Where bundles live unless retrieval.modelDir says otherwise. */
|
|
69
55
|
export function defaultModelsDir(configPath) {
|
|
70
|
-
|
|
71
|
-
|
|
56
|
+
if (process.env.TINYWEBUI_MODELS_DIR)
|
|
57
|
+
return resolve(process.env.TINYWEBUI_MODELS_DIR);
|
|
58
|
+
return resolve(configPath ? join(configPath, '..') : process.cwd(), 'models');
|
|
72
59
|
}
|
|
73
|
-
|
|
74
60
|
/**
|
|
75
61
|
* The model a retrieval config names: a preset (by name) with its bundle
|
|
76
62
|
* directory, or a custom bundle given by modelDir (pooling 'auto', no
|
|
77
63
|
* normalisation, 512 tokens -- TinySearch's defaults for custom models).
|
|
78
64
|
*/
|
|
79
65
|
export function resolveModel(retrieval, modelsDir) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
66
|
+
const name = retrieval.model || 'fast';
|
|
67
|
+
const preset = PRESETS[name];
|
|
68
|
+
if (preset)
|
|
69
|
+
return { name, ...preset, dir: retrieval.modelDir ? resolve(retrieval.modelDir) : join(modelsDir, preset.localDir) };
|
|
70
|
+
if (!retrieval.modelDir)
|
|
71
|
+
throw new Error(`retrieval.model "${name}" is not a preset (${Object.keys(PRESETS).join(', ')}); set retrieval.modelDir for a custom bundle`);
|
|
72
|
+
return {
|
|
73
|
+
name, repoId: name, localDir: null, dir: resolve(retrieval.modelDir),
|
|
74
|
+
onnxPaths: ['model.onnx', 'onnx/model.onnx'], pooling: 'auto', normalize: false, maxLength: 512, files: []
|
|
75
|
+
};
|
|
88
76
|
}
|
|
89
|
-
|
|
90
77
|
export function findOnnxFile(spec) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
78
|
+
for (const rel of spec.onnxPaths) {
|
|
79
|
+
const p = join(spec.dir, rel);
|
|
80
|
+
if (existsSync(p) && statSync(p).isFile())
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
96
84
|
}
|
|
97
|
-
|
|
98
85
|
export async function sha256File(path) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
86
|
+
const hash = createHash('sha256');
|
|
87
|
+
for await (const chunk of createReadStream(path))
|
|
88
|
+
hash.update(chunk);
|
|
89
|
+
return hash.digest('hex');
|
|
102
90
|
}
|
|
103
|
-
|
|
104
91
|
/**
|
|
105
92
|
* Identifies the vectors a model produces. Like TinyContext's
|
|
106
93
|
* embedding_model_key: model identity plus a digest of the document prefix,
|
|
@@ -108,26 +95,26 @@ export async function sha256File(path) {
|
|
|
108
95
|
* vectors.
|
|
109
96
|
*/
|
|
110
97
|
export function modelKey(spec, onnxSha256, documentPrefix = '', chunkOverlap = 32) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
98
|
+
const prefix = createHash('sha256').update(documentPrefix).digest('hex').slice(0, 12);
|
|
99
|
+
// Chunking is part of what a stored vector means: a different overlap
|
|
100
|
+
// cuts different chunks, so it re-embeds rather than mixing.
|
|
101
|
+
return `onnx:${spec.repoId}:${onnxSha256.slice(0, 16)}:document-prefix:${prefix}:chunks:${spec.maxLength}/${chunkOverlap}`;
|
|
115
102
|
}
|
|
116
|
-
|
|
117
103
|
// Marks "not installed here" (packages or bundle), as opposed to a broken or
|
|
118
104
|
// mismatched install: only the former lets mode 'auto' fall back to lexical.
|
|
119
105
|
const UNAVAILABLE = 'EMBEDDING_UNAVAILABLE';
|
|
120
106
|
const unavailable = (message) => Object.assign(new Error(message), { code: UNAVAILABLE });
|
|
121
|
-
|
|
122
107
|
async function importPeer(name) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
108
|
+
try {
|
|
109
|
+
return await import(__rewriteRelativeImportExtension(name));
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(err?.message || '')) {
|
|
113
|
+
throw unavailable(`dense/hybrid retrieval needs the optional packages onnxruntime-node and @huggingface/tokenizers (missing ${name}; npm install without --omit=optional)`);
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
126
116
|
}
|
|
127
|
-
throw err;
|
|
128
|
-
}
|
|
129
117
|
}
|
|
130
|
-
|
|
131
118
|
/**
|
|
132
119
|
* What retrieval actually runs as: { mode, embedder, fallback }. 'auto' (the
|
|
133
120
|
* default) is hybrid when the model and packages are installed, and lexical
|
|
@@ -137,162 +124,163 @@ async function importPeer(name) {
|
|
|
137
124
|
* in every mode rather than being papered over.
|
|
138
125
|
*/
|
|
139
126
|
export async function resolveRetrieval(retrieval, modelsDir, load = loadEmbedder) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
127
|
+
if (retrieval.mode === 'lexical')
|
|
128
|
+
return { mode: 'lexical', embedder: null, fallback: null };
|
|
129
|
+
const mode = retrieval.mode === 'auto' ? 'hybrid' : retrieval.mode;
|
|
130
|
+
try {
|
|
131
|
+
return { mode, embedder: await load(retrieval, modelsDir), fallback: null };
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (retrieval.mode !== 'auto' || err.code !== UNAVAILABLE)
|
|
135
|
+
throw err;
|
|
136
|
+
return { mode: 'lexical', embedder: null, fallback: err.message };
|
|
137
|
+
}
|
|
148
138
|
}
|
|
149
|
-
|
|
150
139
|
const l2normalize = (v) => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
140
|
+
let n = 0;
|
|
141
|
+
for (const x of v)
|
|
142
|
+
n += x * x;
|
|
143
|
+
n = Math.sqrt(n) || 1e-12;
|
|
144
|
+
for (let i = 0; i < v.length; i++)
|
|
145
|
+
v[i] /= n;
|
|
146
|
+
return v;
|
|
156
147
|
};
|
|
157
|
-
|
|
158
148
|
/**
|
|
159
149
|
* Loads a bundle and returns { key, dim, embed(texts) -> Float32Array[] }.
|
|
160
150
|
* Fails clearly -- missing packages, missing bundle, checksum mismatch --
|
|
161
151
|
* and never reaches the network.
|
|
162
152
|
*/
|
|
163
153
|
export async function loadEmbedder(retrieval, modelsDir) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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;
|
|
154
|
+
const spec = resolveModel(retrieval, modelsDir);
|
|
155
|
+
const onnxPath = findOnnxFile(spec);
|
|
156
|
+
const tokenizerPath = join(spec.dir, 'tokenizer.json');
|
|
157
|
+
if (!onnxPath || !existsSync(tokenizerPath)) {
|
|
158
|
+
const hint = PRESETS[spec.name] ? `run: tinywebui models pull ${spec.name}` : 'the bundle needs an ONNX model and tokenizer.json';
|
|
159
|
+
throw unavailable(`no embedding bundle at ${spec.dir} (${hint}); the server never downloads models itself`);
|
|
217
160
|
}
|
|
218
|
-
|
|
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)));
|
|
161
|
+
const actual = await sha256File(onnxPath);
|
|
162
|
+
if (retrieval.modelSha256 && retrieval.modelSha256.toLowerCase() !== actual) {
|
|
163
|
+
throw new Error(`embedding model checksum mismatch for ${onnxPath}: expected ${retrieval.modelSha256}, got ${actual}`);
|
|
252
164
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
165
|
+
// ONNX Runtime ships telemetry; a local, private deployment keeps it off
|
|
166
|
+
// unless the operator explicitly says otherwise.
|
|
167
|
+
process.env.ORT_DISABLE_TELEMETRY ??= '1';
|
|
168
|
+
const ort = await importPeer('onnxruntime-node');
|
|
169
|
+
const { Tokenizer } = await importPeer('@huggingface/tokenizers');
|
|
170
|
+
const configPath = join(spec.dir, 'tokenizer_config.json');
|
|
171
|
+
const tokenizer = new Tokenizer(JSON.parse(readFileSync(tokenizerPath, 'utf8')), existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : {});
|
|
172
|
+
const session = await ort.InferenceSession.create(onnxPath, { executionProviders: ['cpu'] });
|
|
173
|
+
const inputNames = new Set(session.inputNames);
|
|
174
|
+
const pooledOutput = session.outputNames.includes('sentence_embedding') ? 'sentence_embedding' : null;
|
|
175
|
+
const encode = (text) => {
|
|
176
|
+
let { ids } = tokenizer.encode(text);
|
|
177
|
+
// Truncate like TinySearch's enable_truncation: keep the closing special token.
|
|
178
|
+
if (ids.length > spec.maxLength)
|
|
179
|
+
ids = [...ids.slice(0, spec.maxLength - 1), ids[ids.length - 1]];
|
|
180
|
+
return ids;
|
|
181
|
+
};
|
|
182
|
+
// The model's own wrapper tokens ([CLS] … [SEP] for the BERT-family presets).
|
|
183
|
+
const wrapper = tokenizer.encode('').ids;
|
|
184
|
+
const open = wrapper.length >= 1 ? wrapper.slice(0, 1) : [];
|
|
185
|
+
const close = wrapper.length >= 2 ? wrapper.slice(-1) : [];
|
|
186
|
+
const chunkOverlap = retrieval.chunkOverlap ?? 32;
|
|
187
|
+
/**
|
|
188
|
+
* Small-to-big: a passage (what read_document returns) is cut into chunks
|
|
189
|
+
* (what gets embedded). Chunk length is the model's own token limit --
|
|
190
|
+
* 256 for `fast`, 512 for the others -- so every token of a passage
|
|
191
|
+
* is embedded somewhere; neighbouring chunks overlap by chunkOverlap
|
|
192
|
+
* tokens so a sentence cut at a boundary still lands whole in one of them.
|
|
193
|
+
*/
|
|
194
|
+
const chunksOf = (text) => {
|
|
195
|
+
const body = tokenizer.encode(text, { add_special_tokens: false }).ids;
|
|
196
|
+
const room = spec.maxLength - open.length - close.length;
|
|
197
|
+
if (body.length <= room)
|
|
198
|
+
return [[...open, ...body, ...close]];
|
|
199
|
+
const out = [];
|
|
200
|
+
for (let start = 0; start < body.length; start += room - chunkOverlap) {
|
|
201
|
+
out.push([...open, ...body.slice(start, start + room), ...close]);
|
|
202
|
+
if (start + room >= body.length)
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
};
|
|
207
|
+
// One run at a time, like TinySearch's _EMBED_LOCK: ORT sessions are not
|
|
208
|
+
// meant for overlapping runs, and CPU inference gains nothing from it.
|
|
209
|
+
let queue = Promise.resolve();
|
|
210
|
+
const runLocked = (fn) => { const next = queue.then(fn, fn); queue = next.catch(() => { }); return next; };
|
|
211
|
+
async function embedBatch(encoded) {
|
|
212
|
+
const texts = encoded;
|
|
213
|
+
const maxLen = Math.max(1, ...encoded.map((ids) => ids.length));
|
|
214
|
+
const n = texts.length;
|
|
215
|
+
const ids = new BigInt64Array(n * maxLen);
|
|
216
|
+
const mask = new BigInt64Array(n * maxLen);
|
|
217
|
+
encoded.forEach((row, r) => row.forEach((id, c) => { ids[r * maxLen + c] = BigInt(id); mask[r * maxLen + c] = 1n; }));
|
|
218
|
+
const feeds = {};
|
|
219
|
+
const dims = [n, maxLen];
|
|
220
|
+
if (inputNames.has('input_ids'))
|
|
221
|
+
feeds.input_ids = new ort.Tensor('int64', ids, dims);
|
|
222
|
+
if (inputNames.has('attention_mask'))
|
|
223
|
+
feeds.attention_mask = new ort.Tensor('int64', mask, dims);
|
|
224
|
+
if (inputNames.has('token_type_ids'))
|
|
225
|
+
feeds.token_type_ids = new ort.Tensor('int64', new BigInt64Array(n * maxLen), dims);
|
|
226
|
+
const results = await session.run(feeds);
|
|
227
|
+
let rows;
|
|
228
|
+
const pick = (rank) => Object.values(results).find((t) => t.dims.length === rank);
|
|
229
|
+
const pooled = pooledOutput ? results[pooledOutput] : spec.pooling === 'cls' ? null : pick(2);
|
|
230
|
+
if (pooled) {
|
|
231
|
+
const dim = pooled.dims[1];
|
|
232
|
+
rows = Array.from({ length: n }, (_, r) => Float32Array.from(pooled.data.subarray(r * dim, (r + 1) * dim)));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
// CLS pooling: the first token's hidden state.
|
|
236
|
+
const tokens = pick(3);
|
|
237
|
+
if (!tokens)
|
|
238
|
+
throw new Error(`ONNX model ${spec.repoId} has no usable embedding output`);
|
|
239
|
+
const [, seq, dim] = tokens.dims;
|
|
240
|
+
rows = Array.from({ length: n }, (_, r) => Float32Array.from(tokens.data.subarray(r * seq * dim, r * seq * dim + dim)));
|
|
241
|
+
}
|
|
242
|
+
return spec.normalize ? rows.map(l2normalize) : rows;
|
|
243
|
+
}
|
|
244
|
+
async function embedIds(idLists) {
|
|
245
|
+
const out = [];
|
|
246
|
+
for (let i = 0; i < idLists.length; i += 32)
|
|
247
|
+
out.push(...await runLocked(() => embedBatch(idLists.slice(i, i + 32))));
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/** One vector per text, truncated to the model's length (queries). */
|
|
251
|
+
const embed = (texts) => embedIds(texts.map(encode));
|
|
252
|
+
/** Each passage as its chunks' vectors: Float32Array[][] (passage -> chunks). */
|
|
253
|
+
async function embedPassages(texts) {
|
|
254
|
+
const chunks = texts.map(chunksOf);
|
|
255
|
+
const flat = await embedIds(chunks.flat());
|
|
256
|
+
let i = 0;
|
|
257
|
+
return chunks.map((c) => c.map(() => flat[i++]));
|
|
258
|
+
}
|
|
259
|
+
const [probe] = await embed(['probe']);
|
|
260
|
+
return {
|
|
261
|
+
key: modelKey(spec, actual, retrieval.documentPrefix || '', chunkOverlap),
|
|
262
|
+
chunkTokens: spec.maxLength,
|
|
263
|
+
dim: probe.length,
|
|
264
|
+
spec,
|
|
265
|
+
sha256: actual,
|
|
266
|
+
embed,
|
|
267
|
+
embedPassages,
|
|
268
|
+
close: () => session.release?.()
|
|
269
|
+
};
|
|
284
270
|
}
|
|
285
|
-
|
|
286
271
|
/** Files a preset bundle consists of, for `tinywebui models pull`. */
|
|
287
272
|
export function bundleListing(dir) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
273
|
+
const out = [];
|
|
274
|
+
const walk = (d, rel = '') => {
|
|
275
|
+
for (const name of readdirSync(d)) {
|
|
276
|
+
const p = join(d, name);
|
|
277
|
+
if (statSync(p).isDirectory())
|
|
278
|
+
walk(p, `${rel}${name}/`);
|
|
279
|
+
else
|
|
280
|
+
out.push(`${rel}${name}`);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
if (existsSync(dir))
|
|
284
|
+
walk(dir);
|
|
285
|
+
return out.sort();
|
|
298
286
|
}
|