tinywebui 0.1.3 → 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 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  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 +83 -20
  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 -138
  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 -451
  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 +98 -66
  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 -100
  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 +428 -392
  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,14 +1,11 @@
1
1
  import { scope, ftsQuery } from './scope.js';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { CORPUS } from './embeddings.js';
4
-
5
4
  // Attached documents, split into passages for read_document (BM25 and dense retrieval).
6
5
  // One area of the Store; see index.js.
7
-
8
6
  /** Default passage split: characters per passage, and how much neighbours overlap. */
9
7
  export const PASSAGE_SIZE = 1800;
10
8
  export const PASSAGE_OVERLAP = 200;
11
-
12
9
  /**
13
10
  * Splits text into overlapping passages -- the unit read_document returns.
14
11
  * Character-based on purpose: passages are for reading and BM25, and the
@@ -17,94 +14,97 @@ export const PASSAGE_OVERLAP = 200;
17
14
  * passage doesn't open or close mid-sentence more than it has to.
18
15
  */
19
16
  export function splitPassages(text, size = PASSAGE_SIZE, overlap = PASSAGE_OVERLAP) {
20
- const passages = [];
21
- let start = 0;
22
- while (start < text.length) {
23
- let end = Math.min(start + size, text.length);
24
- if (end < text.length) {
25
- const para = text.lastIndexOf('\n\n', end);
26
- const line = text.lastIndexOf('\n', end);
27
- const boundary = para > start + size * 0.5 ? para : (line > start + size * 0.5 ? line : -1);
28
- if (boundary !== -1) end = boundary;
17
+ const passages = [];
18
+ let start = 0;
19
+ while (start < text.length) {
20
+ let end = Math.min(start + size, text.length);
21
+ if (end < text.length) {
22
+ const para = text.lastIndexOf('\n\n', end);
23
+ const line = text.lastIndexOf('\n', end);
24
+ const boundary = para > start + size * 0.5 ? para : (line > start + size * 0.5 ? line : -1);
25
+ if (boundary !== -1)
26
+ end = boundary;
27
+ }
28
+ passages.push({ start, text: text.slice(start, end) });
29
+ if (end >= text.length)
30
+ break;
31
+ start = Math.max(end - overlap, start + 1);
29
32
  }
30
- passages.push({ start, text: text.slice(start, end) });
31
- if (end >= text.length) break;
32
- start = Math.max(end - overlap, start + 1);
33
- }
34
- return passages;
33
+ return passages;
35
34
  }
36
-
37
35
  const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
38
-
39
36
  function insertPassages(db, docId, chatId, content, size, overlap) {
40
- const passages = splitPassages(content, size, overlap);
41
- const insertPassage = db.prepare('INSERT INTO document_passages (body) VALUES (?)');
42
- const insertMap = db.prepare(`
37
+ const passages = splitPassages(content, size, overlap);
38
+ const insertPassage = db.prepare('INSERT INTO document_passages (body) VALUES (?)');
39
+ const insertMap = db.prepare(`
43
40
  INSERT INTO document_passage_map (passage_rowid, doc_id, chat_id, passage_idx, char_start)
44
41
  VALUES (?, ?, ?, ?, ?)
45
42
  `);
46
- passages.forEach((passage, idx) => {
47
- const { lastInsertRowid } = insertPassage.run(passage.text);
48
- insertMap.run(lastInsertRowid, docId, chatId, idx, passage.start);
49
- });
50
- return passages.length;
43
+ passages.forEach((passage, idx) => {
44
+ const { lastInsertRowid } = insertPassage.run(passage.text);
45
+ insertMap.run(lastInsertRowid, docId, chatId, idx, passage.start);
46
+ });
47
+ return passages.length;
51
48
  }
52
-
53
49
  export class DocumentStore {
54
- constructor(db, deps = {}) {
55
- this.db = db;
56
- Object.assign(this, deps);
57
- }
58
-
59
- /**
60
- * `original`: the uploaded bytes, kept (once per distinct file) so the file
61
- * itself can be opened later. `md5` names them when the caller already has
62
- * the fingerprint, e.g. copying a document into another chat.
63
- */
64
- add(chatId, { filename, mime, content, original = null, md5 = null }, { passageSize = PASSAGE_SIZE, passageOverlap = PASSAGE_OVERLAP } = {}) {
65
- const id = randomBytes(12).toString('hex');
66
- const createdAt = Date.now();
67
- const hash = original ? md5Of(original) : md5;
68
- this.db.exec('BEGIN');
69
- try {
70
- if (original) this.db.prepare('INSERT OR IGNORE INTO document_blobs (md5, data) VALUES (?, ?)').run(hash, original);
71
- this.db.prepare(`
50
+ constructor(db, deps) {
51
+ this.db = db;
52
+ Object.assign(this, deps);
53
+ }
54
+ claimUnowned(userId) {
55
+ this.db.prepare('UPDATE documents SET user_id = ? WHERE user_id IS NULL').run(userId);
56
+ }
57
+ /**
58
+ * `original`: the uploaded bytes, kept (once per distinct file) so the file
59
+ * itself can be opened later. `md5` names them when the caller already has
60
+ * the fingerprint, e.g. copying a document into another chat.
61
+ */
62
+ add(chatId, { filename, mime, content, original = null, md5 = null }, { passageSize = PASSAGE_SIZE, passageOverlap = PASSAGE_OVERLAP } = {}) {
63
+ const id = randomBytes(12).toString('hex');
64
+ const createdAt = Date.now();
65
+ const hash = original ? md5Of(original) : md5;
66
+ this.db.exec('BEGIN');
67
+ try {
68
+ if (original)
69
+ this.db.prepare('INSERT OR IGNORE INTO document_blobs (md5, data) VALUES (?, ?)').run(hash, original);
70
+ this.db.prepare(`
72
71
  INSERT INTO documents (id, chat_id, filename, mime, char_len, created_at, content, passage_size, passage_overlap, md5)
73
72
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
74
73
  `).run(id, chatId, filename, mime ?? null, content.length, createdAt, content, passageSize, passageOverlap, hash);
75
- const passages = insertPassages(this.db, id, chatId, content, passageSize, passageOverlap);
76
- this.db.exec('COMMIT');
77
- return { id, filename, mime: mime ?? null, char_len: content.length, created_at: createdAt, passages };
78
- } catch (err) { this.db.exec('ROLLBACK'); throw err; }
79
- }
80
-
81
- /** The chat's document with these bytes, if it already has one -- a file is attached to a chat once. */
82
- findInChat(chatId, original) {
83
- return this.#inChat(chatId, md5Of(original));
84
- }
85
-
86
- #inChat(chatId, md5) {
87
- return this.db.prepare('SELECT id, filename, mime, char_len, created_at FROM documents WHERE chat_id = ? AND md5 = ?')
88
- .get(chatId, md5) || null;
89
- }
90
-
91
- /** The uploaded bytes as a Buffer, or null (no such document, not yours, or stored before originals were kept). */
92
- original(id, userId) {
93
- const doc = this.get(id, userId);
94
- if (!doc?.md5) return null;
95
- const row = this.db.prepare('SELECT data FROM document_blobs WHERE md5 = ?').get(doc.md5);
96
- return row ? Buffer.from(row.data) : null;
97
- }
98
-
99
- /**
100
- * What the user has attached before, across all their chats, newest first --
101
- * for picking a file again without uploading it. One entry per distinct file:
102
- * the same bytes in ten chats is one line, and it names the latest chat.
103
- */
104
- library(userId, limit = 200) {
105
- const s = scope(userId, 'c.user_id');
106
- const inner = scope(userId, 'c2.user_id'); // the "latest" must be the user's own copy, not someone else's
107
- return this.db.prepare(`
74
+ const passages = insertPassages(this.db, id, chatId, content, passageSize, passageOverlap);
75
+ this.db.exec('COMMIT');
76
+ return { id, filename, mime: mime ?? null, char_len: content.length, created_at: createdAt, passages };
77
+ }
78
+ catch (err) {
79
+ this.db.exec('ROLLBACK');
80
+ throw err;
81
+ }
82
+ }
83
+ /** The chat's document with these bytes, if it already has one -- a file is attached to a chat once. */
84
+ findInChat(chatId, original) {
85
+ return this.#inChat(chatId, md5Of(original));
86
+ }
87
+ #inChat(chatId, md5) {
88
+ return this.db.prepare('SELECT id, filename, mime, char_len, created_at FROM documents WHERE chat_id = ? AND md5 = ?')
89
+ .get(chatId, md5) || null;
90
+ }
91
+ /** The uploaded bytes as a Buffer, or null (no such document, not yours, or stored before originals were kept). */
92
+ original(id, userId) {
93
+ const doc = this.get(id, userId);
94
+ if (!doc?.md5)
95
+ return null;
96
+ const row = this.db.prepare('SELECT data FROM document_blobs WHERE md5 = ?').get(doc.md5);
97
+ return row ? Buffer.from(row.data) : null;
98
+ }
99
+ /**
100
+ * What the user has attached before, across all their chats, newest first --
101
+ * for picking a file again without uploading it. One entry per distinct file:
102
+ * the same bytes in ten chats is one line, and it names the latest chat.
103
+ */
104
+ library(userId, limit = 200) {
105
+ const s = scope(userId, 'c.user_id');
106
+ const inner = scope(userId, 'c2.user_id'); // the "latest" must be the user's own copy, not someone else's
107
+ return this.db.prepare(`
108
108
  SELECT d.id, d.filename, d.mime, d.char_len, d.created_at, c.title AS chatTitle
109
109
  FROM documents d JOIN chats c ON c.id = d.chat_id
110
110
  WHERE ${s.sql}
@@ -116,100 +116,98 @@ export class DocumentStore {
116
116
  ORDER BY d.created_at DESC
117
117
  LIMIT ?
118
118
  `).all(...s.params, ...inner.params, limit);
119
- }
120
-
121
- /**
122
- * Puts a copy of one of the user's documents into another chat of theirs.
123
- * Documents are chat-scoped (the chat's deletion takes them, its passages
124
- * carry its id), so this is a new row: same text, same file blob, its own
125
- * passages. Null when the source is not theirs; the chat's existing copy
126
- * when it already has this file.
127
- */
128
- copyToChat(sourceId, chatId, userId, passageSettings) {
129
- const src = this.get(sourceId, userId);
130
- if (!src) return null;
131
- if (src.md5) {
132
- const have = this.#inChat(chatId, src.md5);
133
- if (have) return { ...have, existing: true };
134
119
  }
135
- return this.add(chatId, { filename: src.filename, mime: src.mime, content: src.content, md5: src.md5 }, passageSettings);
136
- }
137
-
138
- /** Documents split with other passage settings than these (NULL = the original 1800/200). */
139
- withOtherPassages(size, overlap) {
140
- return this.db.prepare(`
120
+ /**
121
+ * Puts a copy of one of the user's documents into another chat of theirs.
122
+ * Documents are chat-scoped (the chat's deletion takes them, its passages
123
+ * carry its id), so this is a new row: same text, same file blob, its own
124
+ * passages. Null when the source is not theirs; the chat's existing copy
125
+ * when it already has this file.
126
+ */
127
+ copyToChat(sourceId, chatId, userId, passageSettings) {
128
+ const src = this.get(sourceId, userId);
129
+ if (!src)
130
+ return null;
131
+ if (src.md5) {
132
+ const have = this.#inChat(chatId, src.md5);
133
+ if (have)
134
+ return { ...have, existing: true };
135
+ }
136
+ return this.add(chatId, { filename: src.filename, mime: src.mime, content: src.content, md5: src.md5 }, passageSettings);
137
+ }
138
+ /** Documents split with other passage settings than these (NULL = the original 1800/200). */
139
+ withOtherPassages(size, overlap) {
140
+ return this.db.prepare(`
141
141
  SELECT id FROM documents
142
142
  WHERE COALESCE(passage_size, ${PASSAGE_SIZE}) != ? OR COALESCE(passage_overlap, ${PASSAGE_OVERLAP}) != ?
143
143
  `).all(size, overlap).map((r) => r.id);
144
- }
145
-
146
- /** Re-splits a stored document with new passage settings; its old vectors go with the old passages. */
147
- repassage(docId, size, overlap) {
148
- const doc = this.db.prepare('SELECT id, chat_id, content FROM documents WHERE id = ?').get(docId);
149
- if (!doc) return 0;
150
- this.db.exec('BEGIN');
151
- try {
152
- this.#deletePassages(docId);
153
- const n = insertPassages(this.db, doc.id, doc.chat_id, doc.content, size, overlap);
154
- this.db.prepare('UPDATE documents SET passage_size = ?, passage_overlap = ? WHERE id = ?').run(size, overlap, docId);
155
- this.db.exec('COMMIT');
156
- return n;
157
- } catch (err) { this.db.exec('ROLLBACK'); throw err; }
158
- }
159
-
160
- /** Scoped through the owning chat: a document is its chat's user's. */
161
- get(id, userId) {
162
- const s = scope(userId, 'c.user_id');
163
- return this.db.prepare(
164
- `SELECT d.* FROM documents d JOIN chats c ON c.id = d.chat_id WHERE d.id = ? AND ${s.sql}`
165
- ).get(id, ...s.params) || null;
166
- }
167
-
168
- list(chatId) {
169
- return this.db.prepare(`
144
+ }
145
+ /** Re-splits a stored document with new passage settings; its old vectors go with the old passages. */
146
+ repassage(docId, size, overlap) {
147
+ const doc = this.db.prepare('SELECT id, chat_id, content FROM documents WHERE id = ?').get(docId);
148
+ if (!doc)
149
+ return 0;
150
+ this.db.exec('BEGIN');
151
+ try {
152
+ this.#deletePassages(docId);
153
+ const n = insertPassages(this.db, doc.id, doc.chat_id, doc.content, size, overlap);
154
+ this.db.prepare('UPDATE documents SET passage_size = ?, passage_overlap = ? WHERE id = ?').run(size, overlap, docId);
155
+ this.db.exec('COMMIT');
156
+ return n;
157
+ }
158
+ catch (err) {
159
+ this.db.exec('ROLLBACK');
160
+ throw err;
161
+ }
162
+ }
163
+ /** Scoped through the owning chat: a document is its chat's user's. */
164
+ get(id, userId) {
165
+ const s = scope(userId, 'c.user_id');
166
+ return this.db.prepare(`SELECT d.* FROM documents d JOIN chats c ON c.id = d.chat_id WHERE d.id = ? AND ${s.sql}`).get(id, ...s.params) || null;
167
+ }
168
+ list(chatId) {
169
+ return this.db.prepare(`
170
170
  SELECT id, filename, mime, char_len, created_at
171
171
  FROM documents WHERE chat_id = ? ORDER BY created_at ASC
172
172
  `).all(chatId);
173
- }
174
-
175
- /** Drops one document, its passages and their chunk vectors. The rest of the chat is untouched. */
176
- delete(id, userId) {
177
- if (!this.get(id, userId)) return false;
178
- this.#deletePassages(id);
179
- const gone = this.db.prepare('DELETE FROM documents WHERE id = ?').run(id).changes > 0;
180
- this.#dropOrphanBlobs();
181
- return gone;
182
- }
183
-
184
- /** Drops every document of a chat, with its passages and vectors (the chat is going). */
185
- deleteForChat(chatId) {
186
- for (const { id } of this.db.prepare('SELECT id FROM documents WHERE chat_id = ?').all(chatId)) this.#deletePassages(id);
187
- this.db.prepare('DELETE FROM documents WHERE chat_id = ?').run(chatId);
188
- this.#dropOrphanBlobs();
189
- }
190
-
191
- /** A stored file goes when the last document naming it does. */
192
- #dropOrphanBlobs() {
193
- this.db.exec('DELETE FROM document_blobs WHERE md5 NOT IN (SELECT md5 FROM documents WHERE md5 IS NOT NULL)');
194
- }
195
-
196
- // FTS5 has no foreign keys of its own, so passage rows are dropped by rowid
197
- // before the map that names them.
198
- #deletePassages(docId) {
199
- this.db.prepare(`
173
+ }
174
+ /** Drops one document, its passages and their chunk vectors. The rest of the chat is untouched. */
175
+ delete(id, userId) {
176
+ if (!this.get(id, userId))
177
+ return false;
178
+ this.#deletePassages(id);
179
+ const gone = this.db.prepare('DELETE FROM documents WHERE id = ?').run(id).changes > 0;
180
+ this.#dropOrphanBlobs();
181
+ return gone;
182
+ }
183
+ /** Drops every document of a chat, with its passages and vectors (the chat is going). */
184
+ deleteForChat(chatId) {
185
+ for (const { id } of this.db.prepare('SELECT id FROM documents WHERE chat_id = ?').all(chatId))
186
+ this.#deletePassages(id);
187
+ this.db.prepare('DELETE FROM documents WHERE chat_id = ?').run(chatId);
188
+ this.#dropOrphanBlobs();
189
+ }
190
+ /** A stored file goes when the last document naming it does. */
191
+ #dropOrphanBlobs() {
192
+ this.db.exec('DELETE FROM document_blobs WHERE md5 NOT IN (SELECT md5 FROM documents WHERE md5 IS NOT NULL)');
193
+ }
194
+ // FTS5 has no foreign keys of its own, so passage rows are dropped by rowid
195
+ // before the map that names them.
196
+ #deletePassages(docId) {
197
+ this.db.prepare(`
200
198
  DELETE FROM document_passages WHERE rowid IN (
201
199
  SELECT passage_rowid FROM document_passage_map WHERE doc_id = ?
202
200
  )
203
201
  `).run(docId);
204
- this.embeddings.deleteOwner(CORPUS.DOCUMENTS, docId);
205
- this.db.prepare('DELETE FROM document_passage_map WHERE doc_id = ?').run(docId);
206
- }
207
-
208
- /** Ranked passage search within one document, via FTS5 bm25() (lexical mode). */
209
- searchPassages(docId, query, limit = 5) {
210
- const q = ftsQuery(query);
211
- if (!q) return [];
212
- return this.db.prepare(`
202
+ this.embeddings.deleteOwner(CORPUS.DOCUMENTS, docId);
203
+ this.db.prepare('DELETE FROM document_passage_map WHERE doc_id = ?').run(docId);
204
+ }
205
+ /** Ranked passage search within one document, via FTS5 bm25() (lexical mode). */
206
+ searchPassages(docId, query, limit = 5) {
207
+ const q = ftsQuery(query);
208
+ if (!q)
209
+ return [];
210
+ return this.db.prepare(`
213
211
  SELECT m.passage_idx AS passageIdx, m.char_start AS charStart,
214
212
  document_passages.body AS body, bm25(document_passages) AS rank
215
213
  FROM document_passages
@@ -218,39 +216,37 @@ export class DocumentStore {
218
216
  ORDER BY rank
219
217
  LIMIT ?
220
218
  `).all(q, docId, limit);
221
- }
222
-
223
- /** Every passage of a document, in order -- the candidate set for dense and hybrid ranking. */
224
- passages(docId) {
225
- return this.db.prepare(`
219
+ }
220
+ /** Every passage of a document, in order -- the candidate set for dense and hybrid ranking. */
221
+ passages(docId) {
222
+ return this.db.prepare(`
226
223
  SELECT m.passage_rowid AS rowid, m.passage_idx AS passageIdx, m.char_start AS charStart, document_passages.body AS body
227
224
  FROM document_passage_map m JOIN document_passages ON document_passages.rowid = m.passage_rowid
228
225
  WHERE m.doc_id = ? ORDER BY m.passage_idx
229
226
  `).all(docId);
230
- }
231
-
232
- /**
233
- * BM25 scores per passage, higher is better. `any`: any query word may
234
- * match (the hybrid's lexical side, so partial matches still rank above
235
- * none -- dense ranking handles the rest); otherwise all of them must.
236
- */
237
- lexicalScores(docId, query, { any = true } = {}) {
238
- const q = ftsQuery(query, { any });
239
- if (!q) return new Map();
240
- const rows = this.db.prepare(`
227
+ }
228
+ /**
229
+ * BM25 scores per passage, higher is better. `any`: any query word may
230
+ * match (the hybrid's lexical side, so partial matches still rank above
231
+ * none -- dense ranking handles the rest); otherwise all of them must.
232
+ */
233
+ lexicalScores(docId, query, { any = true } = {}) {
234
+ const q = ftsQuery(query, { any });
235
+ if (!q)
236
+ return new Map();
237
+ const rows = this.db.prepare(`
241
238
  SELECT m.passage_rowid AS rowid, bm25(document_passages) AS rank
242
239
  FROM document_passages JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
243
240
  WHERE document_passages MATCH ? AND m.doc_id = ?
244
241
  `).all(q, docId);
245
- return new Map(rows.map((r) => [r.rowid, -r.rank]));
246
- }
247
-
248
- /** Documents with passages not yet embedded under this model (for backfill). */
249
- missingVectors(modelKey) {
250
- return this.db.prepare(`
242
+ return new Map(rows.map((r) => [r.rowid, -r.rank]));
243
+ }
244
+ /** Documents with passages not yet embedded under this model (for backfill). */
245
+ missingVectors(modelKey) {
246
+ return this.db.prepare(`
251
247
  SELECT DISTINCT m.doc_id AS id FROM document_passage_map m
252
248
  LEFT JOIN embeddings e ON e.corpus = ? AND e.unit_id = m.passage_rowid AND e.model_key = ? AND e.chunk = 0
253
249
  WHERE e.unit_id IS NULL
254
250
  `).all(CORPUS.DOCUMENTS, modelKey).map((r) => r.id);
255
- }
251
+ }
256
252
  }
@@ -1,67 +1,65 @@
1
1
  // Chunk vectors for every retrieval corpus (see retrieval/retrieval.js).
2
2
  // One area of the Store; see index.js.
3
-
4
3
  /** The retrieval corpora whose vectors live here (see retrieval/retrieval.js). */
5
4
  export const CORPUS = Object.freeze({ DOCUMENTS: 'documents', CHATS: 'chats' });
6
-
7
5
  export class EmbeddingStore {
8
- constructor(db, deps = {}) {
9
- this.db = db;
10
- Object.assign(this, deps);
11
- }
12
-
13
- /** Chunk vectors of the given units under one model: Map(unit id -> Float32Array[], one per chunk). */
14
- vectors(corpus, modelKey, unitIds) {
15
- const out = new Map();
16
- if (!unitIds.length) return out;
17
- const rows = this.db.prepare(`
6
+ db;
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+ /** Chunk vectors of the given units under one model: Map(unit id -> Float32Array[], one per chunk). */
11
+ vectors(corpus, modelKey, unitIds) {
12
+ const out = new Map();
13
+ if (!unitIds.length)
14
+ return out;
15
+ const rows = this.db.prepare(`
18
16
  SELECT unit_id, vec FROM embeddings
19
17
  WHERE corpus = ? AND model_key = ? AND unit_id IN (SELECT value FROM json_each(?))
20
18
  ORDER BY unit_id, chunk
21
19
  `).all(corpus, modelKey, JSON.stringify(unitIds));
22
- for (const r of rows) {
23
- const buf = r.vec;
24
- if (!out.has(r.unit_id)) out.set(r.unit_id, []);
25
- out.get(r.unit_id).push(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4).slice());
20
+ for (const r of rows) {
21
+ const buf = r.vec;
22
+ if (!out.has(r.unit_id))
23
+ out.set(r.unit_id, []);
24
+ out.get(r.unit_id).push(new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4).slice());
25
+ }
26
+ return out;
27
+ }
28
+ /** What each of an owner's units was embedded from: Map(unit id -> digest). */
29
+ digests(corpus, ownerId, modelKey) {
30
+ const rows = this.db.prepare('SELECT unit_id, digest FROM embeddings WHERE corpus = ? AND owner_id = ? AND model_key = ? AND chunk = 0')
31
+ .all(corpus, ownerId, modelKey);
32
+ return new Map(rows.map((r) => [r.unit_id, r.digest]));
33
+ }
34
+ /** rows: [{ unitId, digest, vecs: Float32Array[] (its chunks, in order) }], replacing what those units had. */
35
+ put(corpus, ownerId, modelKey, rows) {
36
+ const clear = this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND unit_id = ? AND model_key = ?');
37
+ const put = this.db.prepare('INSERT INTO embeddings (corpus, unit_id, owner_id, model_key, digest, chunk, vec) VALUES (?, ?, ?, ?, ?, ?, ?)');
38
+ this.db.exec('BEGIN');
39
+ try {
40
+ for (const { unitId, digest, vecs } of rows) {
41
+ clear.run(corpus, unitId, modelKey);
42
+ vecs.forEach((vec, chunk) => put.run(corpus, unitId, ownerId, modelKey, digest, chunk, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)));
43
+ }
44
+ this.db.exec('COMMIT');
45
+ }
46
+ catch (err) {
47
+ this.db.exec('ROLLBACK');
48
+ throw err;
49
+ }
26
50
  }
27
- return out;
28
- }
29
-
30
- /** What each of an owner's units was embedded from: Map(unit id -> digest). */
31
- digests(corpus, ownerId, modelKey) {
32
- const rows = this.db.prepare('SELECT unit_id, digest FROM embeddings WHERE corpus = ? AND owner_id = ? AND model_key = ? AND chunk = 0')
33
- .all(corpus, ownerId, modelKey);
34
- return new Map(rows.map((r) => [r.unit_id, r.digest]));
35
- }
36
-
37
- /** rows: [{ unitId, digest, vecs: Float32Array[] (its chunks, in order) }], replacing what those units had. */
38
- put(corpus, ownerId, modelKey, rows) {
39
- const clear = this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND unit_id = ? AND model_key = ?');
40
- const put = this.db.prepare('INSERT INTO embeddings (corpus, unit_id, owner_id, model_key, digest, chunk, vec) VALUES (?, ?, ?, ?, ?, ?, ?)');
41
- this.db.exec('BEGIN');
42
- try {
43
- for (const { unitId, digest, vecs } of rows) {
44
- clear.run(corpus, unitId, modelKey);
45
- vecs.forEach((vec, chunk) => put.run(corpus, unitId, ownerId, modelKey, digest, chunk, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)));
46
- }
47
- this.db.exec('COMMIT');
48
- } catch (err) { this.db.exec('ROLLBACK'); throw err; }
49
- }
50
-
51
- /** Drops an owner's vectors for units it no longer has. */
52
- dropStale(corpus, ownerId, keepUnitIds) {
53
- return this.db.prepare(`
51
+ /** Drops an owner's vectors for units it no longer has. */
52
+ dropStale(corpus, ownerId, keepUnitIds) {
53
+ return this.db.prepare(`
54
54
  DELETE FROM embeddings WHERE corpus = ? AND owner_id = ? AND unit_id NOT IN (SELECT value FROM json_each(?))
55
55
  `).run(corpus, ownerId, JSON.stringify(keepUnitIds)).changes;
56
- }
57
-
58
- /** Drops vectors from models (or chunkings) no longer configured. */
59
- prune(keepModelKey) {
60
- return this.db.prepare('DELETE FROM embeddings WHERE model_key != ?').run(keepModelKey).changes;
61
- }
62
-
63
- /** Drops every vector an owner (a document, a chat) has in a corpus. */
64
- deleteOwner(corpus, ownerId) {
65
- this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND owner_id = ?').run(corpus, ownerId);
66
- }
56
+ }
57
+ /** Drops vectors from models (or chunkings) no longer configured. */
58
+ prune(keepModelKey) {
59
+ return this.db.prepare('DELETE FROM embeddings WHERE model_key != ?').run(keepModelKey).changes;
60
+ }
61
+ /** Drops every vector an owner (a document, a chat) has in a corpus. */
62
+ deleteOwner(corpus, ownerId) {
63
+ this.db.prepare('DELETE FROM embeddings WHERE corpus = ? AND owner_id = ?').run(corpus, ownerId);
64
+ }
67
65
  }