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,119 @@
|
|
|
1
|
+
import { scope, ownerOf } from './scope.js';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
// Scheduled automations and their runs.
|
|
5
|
+
// One area of the Store; see index.js.
|
|
6
|
+
|
|
7
|
+
function automationView(row) {
|
|
8
|
+
return {
|
|
9
|
+
id: row.id, userId: row.user_id, chatId: row.chat_id, chatTitle: row.chat_title,
|
|
10
|
+
name: row.name, prompt: row.prompt, cron: row.cron, timezone: row.timezone,
|
|
11
|
+
enabled: Boolean(row.enabled), nextRunAt: row.next_run_at, lastRunAt: row.last_run_at,
|
|
12
|
+
lastStatus: row.last_status, lastResult: row.last_result, source: row.source,
|
|
13
|
+
createdAt: row.created_at, updatedAt: row.updated_at
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AutomationStore {
|
|
18
|
+
constructor(db, deps = {}) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
Object.assign(this, deps);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
list(userId) {
|
|
24
|
+
const s = scope(userId, 'a.user_id');
|
|
25
|
+
return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
|
|
26
|
+
WHERE ${s.sql} ORDER BY a.updated_at DESC`).all(...s.params).map(automationView);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
listAll() {
|
|
30
|
+
return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
|
|
31
|
+
ORDER BY a.updated_at DESC`).all().map(automationView);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(id, userId) {
|
|
35
|
+
const s = scope(userId, 'a.user_id');
|
|
36
|
+
const row = this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
|
|
37
|
+
WHERE a.id=? AND ${s.sql}`).get(id, ...s.params);
|
|
38
|
+
return row ? automationView(row) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
create(data, userId) {
|
|
42
|
+
scope(userId);
|
|
43
|
+
const id = randomBytes(12).toString('hex');
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
this.db.prepare(`INSERT INTO automations
|
|
46
|
+
(id,user_id,chat_id,name,prompt,cron,timezone,enabled,next_run_at,source,created_at,updated_at)
|
|
47
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(id,ownerOf(userId),data.chatId,data.name,data.prompt,data.cron,data.timezone,
|
|
48
|
+
data.enabled === false ? 0 : 1,data.nextRunAt,data.source || 'user',now,now);
|
|
49
|
+
return this.get(id,userId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
update(id, patch, userId) {
|
|
53
|
+
const current = this.get(id,userId);
|
|
54
|
+
if (!current) return null;
|
|
55
|
+
const fields = { chatId:'chat_id', name:'name', prompt:'prompt', cron:'cron', timezone:'timezone', enabled:'enabled', nextRunAt:'next_run_at', lastRunAt:'last_run_at', lastStatus:'last_status', lastResult:'last_result' };
|
|
56
|
+
const sets = ['updated_at=?'];
|
|
57
|
+
const values = [Date.now()];
|
|
58
|
+
for (const [key,col] of Object.entries(fields)) if (patch[key] !== undefined) {
|
|
59
|
+
sets.push(`${col}=?`); values.push(key === 'enabled' ? (patch[key] ? 1 : 0) : patch[key]);
|
|
60
|
+
}
|
|
61
|
+
values.push(id);
|
|
62
|
+
this.db.prepare(`UPDATE automations SET ${sets.join(',')} WHERE id=?`).run(...values);
|
|
63
|
+
return this.get(id,userId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
delete(id, userId) {
|
|
67
|
+
const s = scope(userId);
|
|
68
|
+
return this.db.prepare(`DELETE FROM automations WHERE id=? AND ${s.sql}`).run(id, ...s.params).changes > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drops every automation that posts into a chat (the chat is going). */
|
|
72
|
+
deleteForChat(chatId) {
|
|
73
|
+
this.db.prepare('DELETE FROM automations WHERE chat_id = ?').run(chatId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** When the next enabled automation is due, or null when none is. */
|
|
77
|
+
nextDue() {
|
|
78
|
+
return this.db.prepare('SELECT MIN(next_run_at) AS due FROM automations WHERE enabled=1').get()?.due ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
due(now = Date.now()) {
|
|
82
|
+
return this.db.prepare(`SELECT a.*, c.title AS chat_title FROM automations a JOIN chats c ON c.id=a.chat_id
|
|
83
|
+
WHERE a.enabled=1 AND a.next_run_at<=? ORDER BY a.next_run_at`).all(now).map(automationView);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
addRun(automationId, scheduledAt, status = 'queued', triggerType = 'schedule') {
|
|
87
|
+
const id = randomBytes(12).toString('hex');
|
|
88
|
+
this.db.prepare(`INSERT INTO automation_runs(id,automation_id,scheduled_at,status,trigger_type) VALUES(?,?,?,?,?)`)
|
|
89
|
+
.run(id,automationId,scheduledAt,status,triggerType);
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
updateRun(id, patch) {
|
|
94
|
+
const fields = { status:'status', startedAt:'started_at', finishedAt:'finished_at', result:'result', error:'error' };
|
|
95
|
+
const sets = []; const values = [];
|
|
96
|
+
for (const [key,col] of Object.entries(fields)) if (patch[key] !== undefined) { sets.push(`${col}=?`); values.push(patch[key]); }
|
|
97
|
+
if (sets.length) this.db.prepare(`UPDATE automation_runs SET ${sets.join(',')} WHERE id=?`).run(...values,id);
|
|
98
|
+
const row = this.db.prepare('SELECT * FROM automation_runs WHERE id=?').get(id);
|
|
99
|
+
const owner = this.db.prepare('SELECT user_id FROM automations WHERE id=?').get(row?.automation_id)?.user_id ?? null;
|
|
100
|
+
if (row) this.update(row.automation_id, {
|
|
101
|
+
lastRunAt: row.started_at || row.scheduled_at, lastStatus: row.status,
|
|
102
|
+
...(row.result !== null ? { lastResult: row.result.slice(0, 4000) } : {})
|
|
103
|
+
}, owner);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
listRuns(automationId, userId, limit = 10) {
|
|
107
|
+
const owned = this.get(automationId,userId);
|
|
108
|
+
if (!owned) return null;
|
|
109
|
+
return this.db.prepare('SELECT id,scheduled_at,started_at,finished_at,status,trigger_type,result,error FROM automation_runs WHERE automation_id=? ORDER BY scheduled_at DESC LIMIT ?')
|
|
110
|
+
.all(automationId,Math.min(50,Math.max(1,limit)));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
recoverRuns(now = Date.now()) {
|
|
114
|
+
const rows = this.db.prepare("SELECT id FROM automation_runs WHERE status IN ('queued','running')").all();
|
|
115
|
+
for (const row of rows) this.updateRun(row.id, {
|
|
116
|
+
status: 'failed', finishedAt: now, error: 'Server restarted during this run.'
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { ALL_USERS, scope, ownerOf } from './scope.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { CORPUS } from './embeddings.js';
|
|
4
|
+
|
|
5
|
+
// Chats and what hangs off one: tasks, folders, queued input, ask_user questions, rewind and delete.
|
|
6
|
+
// One area of the Store; see index.js.
|
|
7
|
+
|
|
8
|
+
export class ChatStore {
|
|
9
|
+
constructor(db, deps = {}) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
Object.assign(this, deps);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
listTasks(chatId) {
|
|
15
|
+
return this.db.prepare('SELECT id, title, status, created_at, updated_at FROM tasks WHERE chat_id = ? ORDER BY created_at, rowid').all(chatId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Task writes are stamped with the seq of the latest saved message: the
|
|
20
|
+
* assistant message that made the call, which is saved before its tools run.
|
|
21
|
+
* That seq lies inside the turn, after its question, so rewinding to the
|
|
22
|
+
* question (or retrying its answer) undoes the write -- and it holds even if
|
|
23
|
+
* the turn dies before saving anything more. See undoTasksFrom.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The checklist as the user sees it: a task finished before the latest
|
|
27
|
+
* question is done with and drops out, the rest stay. A finish is stamped
|
|
28
|
+
* inside the turn that made it, so it compares against the question's seq
|
|
29
|
+
* the same way a rewind does; a rewind that reopens a task shows it again.
|
|
30
|
+
*/
|
|
31
|
+
visibleTasks(chatId) {
|
|
32
|
+
return this.db.prepare(`
|
|
33
|
+
SELECT id, title, status, created_at, updated_at FROM tasks t
|
|
34
|
+
WHERE chat_id = ? AND (status != 'completed'
|
|
35
|
+
OR COALESCE((SELECT MAX(seq) FROM task_changes c WHERE c.task_id = t.id), t.created_seq)
|
|
36
|
+
>= COALESCE((SELECT MAX(seq) FROM messages m WHERE m.chat_id = t.chat_id AND m.role = 'user'), -1))
|
|
37
|
+
ORDER BY created_at, rowid`).all(chatId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
addTask(chatId, title) {
|
|
41
|
+
const id = randomUUID();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
this.db.prepare('INSERT INTO tasks (id, chat_id, title, created_at, updated_at, created_seq) VALUES (?, ?, ?, ?, ?, ?)')
|
|
44
|
+
.run(id, chatId, title, now, now, this.#stamp(chatId));
|
|
45
|
+
return this.listTasks(chatId).find((task) => task.id === id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
updateTask(chatId, id, status) {
|
|
49
|
+
const prev = this.db.prepare('SELECT status FROM tasks WHERE chat_id = ? AND id = ?').get(chatId, id);
|
|
50
|
+
if (!prev) return null;
|
|
51
|
+
this.db.prepare('INSERT INTO task_changes (chat_id, task_id, seq, prev_status) VALUES (?, ?, ?, ?)')
|
|
52
|
+
.run(chatId, id, this.#stamp(chatId), prev.status);
|
|
53
|
+
this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?')
|
|
54
|
+
.run(status, Date.now(), chatId, id);
|
|
55
|
+
return this.listTasks(chatId).find((task) => task.id === id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#stamp(chatId) {
|
|
59
|
+
return this.messages.nextSeq(chatId) - 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Undoes the checklist writes made from `seq` on: changes newest first, then the tasks added. */
|
|
63
|
+
undoTasksFrom(chatId, seq) {
|
|
64
|
+
const changes = this.db.prepare(
|
|
65
|
+
'SELECT rowid, task_id, prev_status FROM task_changes WHERE chat_id = ? AND seq >= ? ORDER BY rowid DESC'
|
|
66
|
+
).all(chatId, seq);
|
|
67
|
+
const restore = this.db.prepare('UPDATE tasks SET status = ?, updated_at = ? WHERE chat_id = ? AND id = ?');
|
|
68
|
+
for (const c of changes) restore.run(c.prev_status, Date.now(), chatId, c.task_id);
|
|
69
|
+
this.db.prepare('DELETE FROM task_changes WHERE chat_id = ? AND seq >= ?').run(chatId, seq);
|
|
70
|
+
this.db.prepare('DELETE FROM tasks WHERE chat_id = ? AND created_seq >= ?').run(chatId, seq);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
create({ id, title = 'New chat', createdAt = Date.now() } = {}, userId = ALL_USERS) {
|
|
74
|
+
const chatId = id || randomUUID();
|
|
75
|
+
this.db.prepare(
|
|
76
|
+
'INSERT INTO chats (id, title, created_at, updated_at, user_id) VALUES (?, ?, ?, ?, ?)'
|
|
77
|
+
).run(chatId, title, createdAt, createdAt, ownerOf(userId));
|
|
78
|
+
return this.byId(chatId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Scoped lookup for anything reached from a request. See scope(). */
|
|
82
|
+
get(id, userId) {
|
|
83
|
+
const s = scope(userId);
|
|
84
|
+
return this.db.prepare(`SELECT * FROM chats WHERE id = ? AND ${s.sql}`).get(id, ...s.params) || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Unscoped lookup for internal plumbing that already holds a chat id it
|
|
88
|
+
* got from a scoped path (the chat loop, compaction, automation runs). */
|
|
89
|
+
byId(id) {
|
|
90
|
+
return this.db.prepare('SELECT * FROM chats WHERE id = ?').get(id) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
list(limit, userId) {
|
|
94
|
+
const s = scope(userId);
|
|
95
|
+
return this.db.prepare(
|
|
96
|
+
`SELECT id, title, updated_at, epoch, boundary_seq, folder FROM chats WHERE ${s.sql} ORDER BY updated_at DESC LIMIT ?`
|
|
97
|
+
).all(...s.params, limit);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
organize(id, { folder = null, tags } = {}, userId) {
|
|
101
|
+
if (!this.get(id, userId)) return null;
|
|
102
|
+
const clean = (items, max) => [...new Set((Array.isArray(items) ? items : [])
|
|
103
|
+
.map((v) => String(v).trim().slice(0, max)).filter(Boolean))].slice(0, 10);
|
|
104
|
+
const cleanFolder = folder == null ? null : String(folder).trim().slice(0, 40) || null;
|
|
105
|
+
const tagsJson = tags === undefined ? null : JSON.stringify(clean(tags, 32));
|
|
106
|
+
const result = this.db.prepare('UPDATE chats SET folder = ?, tags_json = COALESCE(?, tags_json) WHERE id = ?')
|
|
107
|
+
.run(cleanFolder, tagsJson, id);
|
|
108
|
+
return result.changes ? this.byId(id) : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Names only, sorted -- the create-then-move-chats-into-it workflow needs
|
|
112
|
+
* to list folders even when nothing has been organized into them yet. */
|
|
113
|
+
listFolders(userId) {
|
|
114
|
+
const s = scope(userId);
|
|
115
|
+
return this.db.prepare(`SELECT name FROM folders WHERE ${s.sql}`).all(...s.params).map((r) => r.name).sort((a, b) => a.localeCompare(b));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
createFolder(name, userId) {
|
|
119
|
+
const clean = String(name || '').trim().slice(0, 40);
|
|
120
|
+
if (!clean) return null;
|
|
121
|
+
const owner = ownerOf(userId);
|
|
122
|
+
const s = scope(owner);
|
|
123
|
+
const exists = this.db.prepare(`SELECT 1 FROM folders WHERE name = ? AND ${s.sql}`).get(clean, ...s.params);
|
|
124
|
+
if (!exists) {
|
|
125
|
+
this.db.prepare('INSERT INTO folders (name, user_id, created_at) VALUES (?, ?, ?)')
|
|
126
|
+
.run(clean, owner, Date.now());
|
|
127
|
+
}
|
|
128
|
+
return clean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rewinds a conversation, dropping everything from `seq` onward.
|
|
133
|
+
*
|
|
134
|
+
* This is the one place that breaks the append-only rule on purpose. Editing
|
|
135
|
+
* a question means the answers that followed it were answers to a question
|
|
136
|
+
* that no longer exists, so they go with it -- and the prompt cache from that
|
|
137
|
+
* point on goes too, which is correct rather than unfortunate: the model must
|
|
138
|
+
* genuinely reconsider everything after the edit.
|
|
139
|
+
*
|
|
140
|
+
* Artifacts are left behind. They are addressed by id from a message that no
|
|
141
|
+
* longer exists, so nothing can reach them, and deleting them would be the
|
|
142
|
+
* one way to lose tool output that the transcript promises to keep.
|
|
143
|
+
*/
|
|
144
|
+
/**
|
|
145
|
+
* Queues input for a running turn. Returns false when that id is already
|
|
146
|
+
* queued (a retry), true when it was added.
|
|
147
|
+
*/
|
|
148
|
+
addQueued(chatId, { id, kind, content }) {
|
|
149
|
+
return this.db.prepare(
|
|
150
|
+
'INSERT OR IGNORE INTO queued_messages (id, chat_id, kind, content, created_at) VALUES (?, ?, ?, ?, ?)'
|
|
151
|
+
).run(String(id), chatId, kind, String(content), Date.now()).changes > 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A chat's queue, oldest first. */
|
|
155
|
+
listQueued(chatId) {
|
|
156
|
+
return this.db.prepare(
|
|
157
|
+
'SELECT id, kind, content, created_at FROM queued_messages WHERE chat_id = ? ORDER BY pos'
|
|
158
|
+
).all(chatId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Removes and returns queued items for delivery, oldest first: every item
|
|
163
|
+
* of `kind`, or with `first` only the oldest item of any kind.
|
|
164
|
+
*/
|
|
165
|
+
takeQueued(chatId, { kind = null, first = false } = {}) {
|
|
166
|
+
const rows = this.db.prepare(
|
|
167
|
+
`SELECT pos, id, kind, content FROM queued_messages WHERE chat_id = ?${kind ? ' AND kind = ?' : ''} ORDER BY pos${first ? ' LIMIT 1' : ''}`
|
|
168
|
+
).all(...(kind ? [chatId, kind] : [chatId]));
|
|
169
|
+
const drop = this.db.prepare('DELETE FROM queued_messages WHERE pos = ?');
|
|
170
|
+
for (const r of rows) drop.run(r.pos);
|
|
171
|
+
return rows.map(({ id, kind: k, content }) => ({ id, kind: k, content }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Withdraws one queued item before it is delivered. */
|
|
175
|
+
deleteQueued(chatId, id) {
|
|
176
|
+
return this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ? AND id = ?').run(chatId, String(id)).changes > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Records a question as pending. `deadline` is epoch ms, or null for no timeout. */
|
|
180
|
+
addQuestion(chatId, { id, question, choices = [], allowFreeText = true, deadline = null }) {
|
|
181
|
+
this.db.prepare(
|
|
182
|
+
'INSERT INTO questions (id, chat_id, question, choices_json, allow_free_text, created_at, deadline) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
183
|
+
).run(String(id), chatId, question, JSON.stringify(choices), allowFreeText ? 1 : 0, Date.now(), deadline);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
getQuestion(id) {
|
|
187
|
+
const row = this.db.prepare('SELECT * FROM questions WHERE id = ?').get(String(id));
|
|
188
|
+
return row && {
|
|
189
|
+
id: row.id, chatId: row.chat_id, question: row.question, choices: JSON.parse(row.choices_json),
|
|
190
|
+
allowFreeText: Boolean(row.allow_free_text), status: row.status, answer: row.answer,
|
|
191
|
+
createdAt: row.created_at, deadline: row.deadline, settledAt: row.settled_at
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Settles a pending question. True for the one caller that settled it;
|
|
197
|
+
* false when it was already settled (or never existed).
|
|
198
|
+
*/
|
|
199
|
+
settleQuestion(id, status, answer = null) {
|
|
200
|
+
return this.db.prepare(
|
|
201
|
+
"UPDATE questions SET status = ?, answer = ?, settled_at = ? WHERE id = ? AND status = 'pending'"
|
|
202
|
+
).run(status, answer, Date.now(), String(id)).changes > 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Expires every pending question: at startup no run is left to answer one. */
|
|
206
|
+
expireQuestions() {
|
|
207
|
+
return this.db.prepare(
|
|
208
|
+
"UPDATE questions SET status = 'expired', settled_at = ? WHERE status = 'pending'"
|
|
209
|
+
).run(Date.now()).changes;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
truncateFrom(chatId, seq) {
|
|
213
|
+
// One transaction: a crash midway must not leave messages cut but their
|
|
214
|
+
// task writes (or cache markers) still standing.
|
|
215
|
+
this.db.exec('BEGIN');
|
|
216
|
+
try {
|
|
217
|
+
const removed = this.messages.deleteFrom(chatId, seq);
|
|
218
|
+
this.undoTasksFrom(chatId, seq);
|
|
219
|
+
// A frozen compaction boundary inside the cut no longer describes anything,
|
|
220
|
+
// so the pinned cache breakpoint it drives has to go with it.
|
|
221
|
+
const chat = this.byId(chatId);
|
|
222
|
+
if (chat && chat.boundary_seq >= seq) this.touch(chatId, { boundary_seq: -1 });
|
|
223
|
+
// Same for the hard window: a cut at or past the rewind point would hide
|
|
224
|
+
// the very question being asked again. A checkpoint summarises what that
|
|
225
|
+
// window cut off, so it goes too.
|
|
226
|
+
if (chat && chat.window_seq >= seq) this.touch(chatId, { window_seq: -1, checkpoint_json: null });
|
|
227
|
+
this.db.exec('COMMIT');
|
|
228
|
+
return removed;
|
|
229
|
+
} catch (err) { this.db.exec('ROLLBACK'); throw err; }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
delete(id, userId) {
|
|
233
|
+
if (!this.get(id, userId)) return false;
|
|
234
|
+
// Each area drops its own part of the chat; tasks go by foreign key.
|
|
235
|
+
this.automations.deleteForChat(id);
|
|
236
|
+
this.messages.deleteForChat(id);
|
|
237
|
+
this.documents.deleteForChat(id);
|
|
238
|
+
this.embeddings.deleteOwner(CORPUS.CHATS, id);
|
|
239
|
+
this.db.prepare('DELETE FROM queued_messages WHERE chat_id = ?').run(id);
|
|
240
|
+
this.db.prepare('DELETE FROM questions WHERE chat_id = ?').run(id);
|
|
241
|
+
this.db.prepare('DELETE FROM chats WHERE id = ?').run(id);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
touch(id, patch = {}) {
|
|
246
|
+
const sets = ['updated_at = ?'];
|
|
247
|
+
const vals = [Date.now()];
|
|
248
|
+
for (const key of ['title', 'epoch', 'boundary_seq', 'window_seq', 'checkpoint_json']) {
|
|
249
|
+
if (patch[key] !== undefined) { sets.push(`${key} = ?`); vals.push(patch[key]); }
|
|
250
|
+
}
|
|
251
|
+
vals.push(id);
|
|
252
|
+
this.db.prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { scope, ftsQuery } from './scope.js';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { CORPUS } from './embeddings.js';
|
|
4
|
+
|
|
5
|
+
// Attached documents, split into passages for read_document (BM25 and dense retrieval).
|
|
6
|
+
// One area of the Store; see index.js.
|
|
7
|
+
|
|
8
|
+
/** Default passage split: characters per passage, and how much neighbours overlap. */
|
|
9
|
+
export const PASSAGE_SIZE = 1800;
|
|
10
|
+
export const PASSAGE_OVERLAP = 200;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Splits text into overlapping passages -- the unit read_document returns.
|
|
14
|
+
* Character-based on purpose: passages are for reading and BM25, and the
|
|
15
|
+
* embedding side cuts its own model-sized chunks out of them. Breaks are
|
|
16
|
+
* nudged onto a paragraph or line boundary when one is nearby, so a
|
|
17
|
+
* passage doesn't open or close mid-sentence more than it has to.
|
|
18
|
+
*/
|
|
19
|
+
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;
|
|
29
|
+
}
|
|
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;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const md5Of = (bytes) => createHash('md5').update(bytes).digest('hex');
|
|
38
|
+
|
|
39
|
+
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(`
|
|
43
|
+
INSERT INTO document_passage_map (passage_rowid, doc_id, chat_id, passage_idx, char_start)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?)
|
|
45
|
+
`);
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
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(`
|
|
72
|
+
INSERT INTO documents (id, chat_id, filename, mime, char_len, created_at, content, passage_size, passage_overlap, md5)
|
|
73
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
74
|
+
`).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(`
|
|
108
|
+
SELECT d.id, d.filename, d.mime, d.char_len, d.created_at, c.title AS chatTitle
|
|
109
|
+
FROM documents d JOIN chats c ON c.id = d.chat_id
|
|
110
|
+
WHERE ${s.sql}
|
|
111
|
+
AND d.id = (
|
|
112
|
+
SELECT d2.id FROM documents d2 JOIN chats c2 ON c2.id = d2.chat_id
|
|
113
|
+
WHERE ${inner.sql} AND COALESCE(d2.md5, d2.id) = COALESCE(d.md5, d.id)
|
|
114
|
+
ORDER BY d2.created_at DESC, d2.rowid DESC LIMIT 1
|
|
115
|
+
)
|
|
116
|
+
ORDER BY d.created_at DESC
|
|
117
|
+
LIMIT ?
|
|
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
|
+
}
|
|
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(`
|
|
141
|
+
SELECT id FROM documents
|
|
142
|
+
WHERE COALESCE(passage_size, ${PASSAGE_SIZE}) != ? OR COALESCE(passage_overlap, ${PASSAGE_OVERLAP}) != ?
|
|
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(`
|
|
170
|
+
SELECT id, filename, mime, char_len, created_at
|
|
171
|
+
FROM documents WHERE chat_id = ? ORDER BY created_at ASC
|
|
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(`
|
|
200
|
+
DELETE FROM document_passages WHERE rowid IN (
|
|
201
|
+
SELECT passage_rowid FROM document_passage_map WHERE doc_id = ?
|
|
202
|
+
)
|
|
203
|
+
`).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(`
|
|
213
|
+
SELECT m.passage_idx AS passageIdx, m.char_start AS charStart,
|
|
214
|
+
document_passages.body AS body, bm25(document_passages) AS rank
|
|
215
|
+
FROM document_passages
|
|
216
|
+
JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
|
|
217
|
+
WHERE document_passages MATCH ? AND m.doc_id = ?
|
|
218
|
+
ORDER BY rank
|
|
219
|
+
LIMIT ?
|
|
220
|
+
`).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(`
|
|
226
|
+
SELECT m.passage_rowid AS rowid, m.passage_idx AS passageIdx, m.char_start AS charStart, document_passages.body AS body
|
|
227
|
+
FROM document_passage_map m JOIN document_passages ON document_passages.rowid = m.passage_rowid
|
|
228
|
+
WHERE m.doc_id = ? ORDER BY m.passage_idx
|
|
229
|
+
`).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(`
|
|
241
|
+
SELECT m.passage_rowid AS rowid, bm25(document_passages) AS rank
|
|
242
|
+
FROM document_passages JOIN document_passage_map m ON m.passage_rowid = document_passages.rowid
|
|
243
|
+
WHERE document_passages MATCH ? AND m.doc_id = ?
|
|
244
|
+
`).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(`
|
|
251
|
+
SELECT DISTINCT m.doc_id AS id FROM document_passage_map m
|
|
252
|
+
LEFT JOIN embeddings e ON e.corpus = ? AND e.unit_id = m.passage_rowid AND e.model_key = ? AND e.chunk = 0
|
|
253
|
+
WHERE e.unit_id IS NULL
|
|
254
|
+
`).all(CORPUS.DOCUMENTS, modelKey).map((r) => r.id);
|
|
255
|
+
}
|
|
256
|
+
}
|