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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. 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
- 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
- }
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
- if (process.env.TINYWEBUI_MODELS_DIR) return resolve(process.env.TINYWEBUI_MODELS_DIR);
71
- return resolve(configPath ? join(configPath, '..') : process.cwd(), 'models');
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
- 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
- };
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
- 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;
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
- const hash = createHash('sha256');
100
- for await (const chunk of createReadStream(path)) hash.update(chunk);
101
- return hash.digest('hex');
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
- 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}`;
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
- 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)`);
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
- 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
- }
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
- 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;
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
- 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;
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
- 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)));
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
- 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
- };
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
- 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();
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
  }