@mono-agent/web 0.12.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 +674 -0
- package/README.md +169 -0
- package/dist/contracts.d.ts +151 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +11 -0
- package/dist/contracts.js.map +1 -0
- package/dist/discovery.d.ts +17 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +137 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-client.d.ts +42 -0
- package/dist/operator-client.d.ts.map +1 -0
- package/dist/operator-client.js +257 -0
- package/dist/operator-client.js.map +1 -0
- package/dist/server.d.ts +22 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +566 -0
- package/dist/server.js.map +1 -0
- package/dist/service.d.ts +89 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +644 -0
- package/dist/service.js.map +1 -0
- package/dist/state-paths.d.ts +25 -0
- package/dist/state-paths.d.ts.map +1 -0
- package/dist/state-paths.js +226 -0
- package/dist/state-paths.js.map +1 -0
- package/dist/store.d.ts +112 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +953 -0
- package/dist/store.js.map +1 -0
- package/package.json +48 -0
- package/webapp/dist/apple-touch-icon.png +0 -0
- package/webapp/dist/assets/assistant-ui-CU4gqU0g.js +70 -0
- package/webapp/dist/assets/index-Ck3BY0Ti.js +47 -0
- package/webapp/dist/assets/index-CoB6Xwh-.css +1 -0
- package/webapp/dist/assets/markdown-qFW9VWmB.js +18 -0
- package/webapp/dist/assets/workbox-window.prod.es5-BBnX5xw4.js +2 -0
- package/webapp/dist/favicon.ico +0 -0
- package/webapp/dist/icon-192.png +0 -0
- package/webapp/dist/icon-512.png +0 -0
- package/webapp/dist/icon.svg +22 -0
- package/webapp/dist/index.html +20 -0
- package/webapp/dist/manifest.webmanifest +1 -0
- package/webapp/dist/sw.js +1 -0
- package/webapp/dist/workbox-9c191d2f.js +1 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { WEB_MAX_FILES_PER_TURN, WEB_MAX_TURN_ATTACHMENT_BYTES, } from "./contracts.js";
|
|
6
|
+
import { WebConsoleError } from "./errors.js";
|
|
7
|
+
import { prepareWebStatePaths } from "./state-paths.js";
|
|
8
|
+
export class WebStore {
|
|
9
|
+
paths;
|
|
10
|
+
database;
|
|
11
|
+
clock;
|
|
12
|
+
closed = false;
|
|
13
|
+
constructor(database, paths, clock) {
|
|
14
|
+
this.database = database;
|
|
15
|
+
this.paths = paths;
|
|
16
|
+
this.clock = clock;
|
|
17
|
+
}
|
|
18
|
+
static async open(options = {}) {
|
|
19
|
+
const paths = await prepareWebStatePaths(options);
|
|
20
|
+
return WebStore.openPrepared(paths, options);
|
|
21
|
+
}
|
|
22
|
+
static async openPrepared(paths, options = {}) {
|
|
23
|
+
const existing = await lstat(paths.database).catch(() => undefined);
|
|
24
|
+
if (existing !== undefined && (!existing.isFile() || existing.isSymbolicLink())) {
|
|
25
|
+
throw new WebConsoleError("invalid_state_database", "Web state database must be a regular file.", 409);
|
|
26
|
+
}
|
|
27
|
+
const currentUid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
28
|
+
if (existing !== undefined && currentUid !== undefined && existing.uid !== currentUid) {
|
|
29
|
+
throw new WebConsoleError("invalid_state_owner", "Web state database is not owned by the current user.", 409);
|
|
30
|
+
}
|
|
31
|
+
const database = new DatabaseSync(paths.database, { timeout: 5_000 });
|
|
32
|
+
const store = new WebStore(database, paths, options.clock ?? (() => new Date()));
|
|
33
|
+
try {
|
|
34
|
+
store.initialize();
|
|
35
|
+
await Promise.all([
|
|
36
|
+
chmod(paths.database, 0o600),
|
|
37
|
+
chmod(`${paths.database}-wal`, 0o600).catch(ignoreMissing),
|
|
38
|
+
chmod(`${paths.database}-shm`, 0o600).catch(ignoreMissing),
|
|
39
|
+
]);
|
|
40
|
+
store.recoverInterruptedTurns();
|
|
41
|
+
return store;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
store.close();
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
close() {
|
|
49
|
+
if (this.closed)
|
|
50
|
+
return;
|
|
51
|
+
this.closed = true;
|
|
52
|
+
this.database.close();
|
|
53
|
+
}
|
|
54
|
+
replaceAgents(agents) {
|
|
55
|
+
this.transaction(() => {
|
|
56
|
+
this.database.prepare("UPDATE agents SET status = 'offline'").run();
|
|
57
|
+
const statement = this.database.prepare(`
|
|
58
|
+
INSERT INTO agents (
|
|
59
|
+
source_id, label, status, health, supports_attachments, models_json,
|
|
60
|
+
default_model, default_effort, efforts_json, model_options_json, updated_at
|
|
61
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT(source_id) DO UPDATE SET
|
|
63
|
+
label = excluded.label,
|
|
64
|
+
status = excluded.status,
|
|
65
|
+
health = excluded.health,
|
|
66
|
+
supports_attachments = excluded.supports_attachments,
|
|
67
|
+
models_json = excluded.models_json,
|
|
68
|
+
default_model = excluded.default_model,
|
|
69
|
+
default_effort = excluded.default_effort,
|
|
70
|
+
efforts_json = excluded.efforts_json,
|
|
71
|
+
model_options_json = excluded.model_options_json,
|
|
72
|
+
updated_at = excluded.updated_at
|
|
73
|
+
`);
|
|
74
|
+
for (const agent of agents) {
|
|
75
|
+
statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.updatedAt);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
listAgents() {
|
|
80
|
+
const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
|
|
81
|
+
return rows.map(mapAgent);
|
|
82
|
+
}
|
|
83
|
+
getAgent(sourceId) {
|
|
84
|
+
const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
|
|
85
|
+
return row === undefined ? undefined : mapAgent(row);
|
|
86
|
+
}
|
|
87
|
+
setAgentPinned(sourceId, pinned) {
|
|
88
|
+
if (this.getAgent(sourceId) === undefined) {
|
|
89
|
+
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
90
|
+
}
|
|
91
|
+
const key = agentPinSettingKey(sourceId);
|
|
92
|
+
if (pinned)
|
|
93
|
+
this.setSetting(key, "1");
|
|
94
|
+
else
|
|
95
|
+
this.database.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
|
96
|
+
const agent = this.getAgent(sourceId);
|
|
97
|
+
if (agent === undefined)
|
|
98
|
+
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
99
|
+
return agent;
|
|
100
|
+
}
|
|
101
|
+
createThread(sourceId) {
|
|
102
|
+
const agent = this.getAgent(sourceId);
|
|
103
|
+
if (agent === undefined) {
|
|
104
|
+
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
105
|
+
}
|
|
106
|
+
const id = randomUUID();
|
|
107
|
+
const now = this.now();
|
|
108
|
+
this.transaction(() => {
|
|
109
|
+
this.database.prepare(`
|
|
110
|
+
INSERT INTO threads (
|
|
111
|
+
id, source_id, conversation_id, title, title_manual, archived_at,
|
|
112
|
+
created_at, updated_at, revision
|
|
113
|
+
) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, 1)
|
|
114
|
+
`).run(id, sourceId, `web:${id}`, now, now);
|
|
115
|
+
this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'created', ?)")
|
|
116
|
+
.run(id, now);
|
|
117
|
+
this.setSetting("current_thread_id", id);
|
|
118
|
+
});
|
|
119
|
+
return this.requireThread(id);
|
|
120
|
+
}
|
|
121
|
+
listThreads() {
|
|
122
|
+
const rows = this.database.prepare(threadSelectSql("ORDER BY t.updated_at DESC, t.id")).all();
|
|
123
|
+
return rows.map((row) => this.mapThread(row));
|
|
124
|
+
}
|
|
125
|
+
getThread(id) {
|
|
126
|
+
const row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(id);
|
|
127
|
+
return row === undefined ? undefined : this.mapThread(row);
|
|
128
|
+
}
|
|
129
|
+
getThreadDetail(id) {
|
|
130
|
+
const thread = this.getThread(id);
|
|
131
|
+
if (thread === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
const rows = this.database.prepare("SELECT * FROM messages WHERE thread_id = ? ORDER BY created_at, rowid").all(id);
|
|
134
|
+
return { thread, messages: rows.map((row) => this.mapMessage(row)) };
|
|
135
|
+
}
|
|
136
|
+
currentThreadId() {
|
|
137
|
+
const row = this.database.prepare("SELECT value FROM settings WHERE key = 'current_thread_id'").get();
|
|
138
|
+
if (row === undefined || this.getThread(row.value) === undefined)
|
|
139
|
+
return undefined;
|
|
140
|
+
return row.value;
|
|
141
|
+
}
|
|
142
|
+
selectThread(id) {
|
|
143
|
+
this.requireThread(id);
|
|
144
|
+
this.setSetting("current_thread_id", id);
|
|
145
|
+
}
|
|
146
|
+
patchThread(id, patch) {
|
|
147
|
+
const current = this.requireThread(id);
|
|
148
|
+
const now = this.now();
|
|
149
|
+
const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
|
|
150
|
+
const archivedAt = patch.archived === undefined ? undefined : patch.archived ? now : null;
|
|
151
|
+
this.transaction(() => {
|
|
152
|
+
const sets = ["updated_at = ?", "revision = revision + 1"];
|
|
153
|
+
const values = [now];
|
|
154
|
+
if (title !== undefined) {
|
|
155
|
+
sets.push("title = ?", "title_manual = 1");
|
|
156
|
+
values.push(title);
|
|
157
|
+
}
|
|
158
|
+
if (archivedAt !== undefined) {
|
|
159
|
+
sets.push("archived_at = ?");
|
|
160
|
+
values.push(archivedAt);
|
|
161
|
+
}
|
|
162
|
+
values.push(id);
|
|
163
|
+
this.database.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
164
|
+
this.recordThreadRevision(id, title !== undefined ? "title_changed" : patch.archived ? "archived" : "unarchived", now);
|
|
165
|
+
if (patch.archived === true && this.currentThreadId() === id) {
|
|
166
|
+
this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return { ...this.requireThread(id), sourceId: current.sourceId };
|
|
170
|
+
}
|
|
171
|
+
createUpload(input) {
|
|
172
|
+
const id = randomUUID();
|
|
173
|
+
const now = this.now();
|
|
174
|
+
const storageName = `${id}.bin`;
|
|
175
|
+
this.database.prepare(`
|
|
176
|
+
INSERT INTO attachments (
|
|
177
|
+
id, thread_id, message_id, name, content_type, size_bytes, kind,
|
|
178
|
+
status, uploaded, storage_name, created_at, updated_at
|
|
179
|
+
) VALUES (?, NULL, NULL, ?, ?, ?, ?, 'staged', 0, ?, ?, ?)
|
|
180
|
+
`).run(id, input.name, input.contentType, input.declaredSize ?? 0, input.kind, storageName, now, now);
|
|
181
|
+
return this.requireStoredAttachment(id);
|
|
182
|
+
}
|
|
183
|
+
markUploadComplete(id, sizeBytes) {
|
|
184
|
+
const attachment = this.requireStoredAttachment(id);
|
|
185
|
+
if (attachment.status !== "staged" || attachment.threadId !== undefined) {
|
|
186
|
+
throw new WebConsoleError("attachment_committed", "A committed attachment cannot be replaced.", 409);
|
|
187
|
+
}
|
|
188
|
+
const now = this.now();
|
|
189
|
+
this.database.prepare("UPDATE attachments SET size_bytes = ?, uploaded = 1, updated_at = ? WHERE id = ?")
|
|
190
|
+
.run(sizeBytes, now, id);
|
|
191
|
+
return this.requireStoredAttachment(id);
|
|
192
|
+
}
|
|
193
|
+
getStoredAttachment(id) {
|
|
194
|
+
const row = this.database.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
|
|
195
|
+
return row === undefined ? undefined : mapStoredAttachment(row);
|
|
196
|
+
}
|
|
197
|
+
stagedUploadUsage() {
|
|
198
|
+
const row = this.database.prepare(`
|
|
199
|
+
SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes
|
|
200
|
+
FROM attachments WHERE status = 'staged' AND thread_id IS NULL
|
|
201
|
+
`).get();
|
|
202
|
+
return row;
|
|
203
|
+
}
|
|
204
|
+
attachmentPath(attachment) {
|
|
205
|
+
return resolve(this.paths.uploads, attachment.storageName);
|
|
206
|
+
}
|
|
207
|
+
async removeStagedAttachment(id) {
|
|
208
|
+
const attachment = this.requireStoredAttachment(id);
|
|
209
|
+
if (attachment.status !== "staged" || attachment.threadId !== undefined) {
|
|
210
|
+
throw new WebConsoleError("attachment_committed", "Committed attachments are retained with their conversation.", 409);
|
|
211
|
+
}
|
|
212
|
+
await unlink(this.attachmentPath(attachment)).catch((error) => {
|
|
213
|
+
if (error.code !== "ENOENT")
|
|
214
|
+
throw error;
|
|
215
|
+
});
|
|
216
|
+
this.database.prepare("DELETE FROM attachments WHERE id = ?").run(id);
|
|
217
|
+
}
|
|
218
|
+
async purgeStagedAttachments(before) {
|
|
219
|
+
const rows = this.database.prepare(`
|
|
220
|
+
SELECT * FROM attachments
|
|
221
|
+
WHERE status = 'staged' AND thread_id IS NULL AND created_at < ?
|
|
222
|
+
`).all(before);
|
|
223
|
+
if (rows.length === 0)
|
|
224
|
+
return 0;
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
await unlink(this.attachmentPath(mapStoredAttachment(row))).catch((error) => {
|
|
227
|
+
if (error.code !== "ENOENT")
|
|
228
|
+
throw error;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
this.transaction(() => {
|
|
232
|
+
const remove = this.database.prepare("DELETE FROM attachments WHERE id = ? AND status = 'staged' AND thread_id IS NULL");
|
|
233
|
+
for (const row of rows)
|
|
234
|
+
remove.run(row.id);
|
|
235
|
+
});
|
|
236
|
+
return rows.length;
|
|
237
|
+
}
|
|
238
|
+
async purgePartialUploadFiles(before) {
|
|
239
|
+
const entries = await readdir(this.paths.uploads, { withFileTypes: true });
|
|
240
|
+
let removed = 0;
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!/^[0-9a-f-]{36}\.bin\.partial-[0-9a-f-]{36}$/iu.test(entry.name))
|
|
243
|
+
continue;
|
|
244
|
+
const path = resolve(this.paths.uploads, entry.name);
|
|
245
|
+
const info = await lstat(path);
|
|
246
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
247
|
+
continue;
|
|
248
|
+
if (before !== undefined && info.mtime.toISOString() >= before)
|
|
249
|
+
continue;
|
|
250
|
+
await unlink(path);
|
|
251
|
+
removed += 1;
|
|
252
|
+
}
|
|
253
|
+
return removed;
|
|
254
|
+
}
|
|
255
|
+
beginTurn(input) {
|
|
256
|
+
const thread = this.requireThread(input.threadId);
|
|
257
|
+
if (thread.archivedAt !== null) {
|
|
258
|
+
throw new WebConsoleError("thread_archived", "Unarchive this conversation before sending another message.", 409);
|
|
259
|
+
}
|
|
260
|
+
if (!thread.canSend) {
|
|
261
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
262
|
+
}
|
|
263
|
+
const active = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(input.threadId);
|
|
264
|
+
if (active !== undefined) {
|
|
265
|
+
throw new WebConsoleError("turn_active", "This conversation already has an active turn.", 409);
|
|
266
|
+
}
|
|
267
|
+
const uniqueIds = [...new Set(input.attachmentIds)];
|
|
268
|
+
if (uniqueIds.length !== input.attachmentIds.length || uniqueIds.length > WEB_MAX_FILES_PER_TURN) {
|
|
269
|
+
throw new WebConsoleError("attachment_limit", `A turn accepts at most ${WEB_MAX_FILES_PER_TURN} distinct attachments.`, 400);
|
|
270
|
+
}
|
|
271
|
+
const attachments = uniqueIds.map((id) => this.requireStoredAttachment(id));
|
|
272
|
+
if (attachments.length > 0 && !thread.canUpload) {
|
|
273
|
+
throw new WebConsoleError("attachments_unsupported", "This agent does not advertise web attachment support.", 409);
|
|
274
|
+
}
|
|
275
|
+
for (const attachment of attachments) {
|
|
276
|
+
if (attachment.status !== "staged" || attachment.threadId !== undefined || !attachment.uploaded) {
|
|
277
|
+
throw new WebConsoleError("attachment_unavailable", `Attachment ${attachment.id} is not ready.`, 409);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const aggregateBytes = attachments.reduce((total, attachment) => total + attachment.sizeBytes, 0);
|
|
281
|
+
if (aggregateBytes > WEB_MAX_TURN_ATTACHMENT_BYTES) {
|
|
282
|
+
throw new WebConsoleError("attachment_aggregate_limit", "The turn's attachments exceed the 64 MiB aggregate limit.", 413);
|
|
283
|
+
}
|
|
284
|
+
if (input.text.trim().length === 0 && attachments.length === 0) {
|
|
285
|
+
throw new WebConsoleError("empty_turn", "Enter a message or attach at least one file.", 400);
|
|
286
|
+
}
|
|
287
|
+
const turnId = randomUUID();
|
|
288
|
+
const userMessageId = randomUUID();
|
|
289
|
+
const assistantMessageId = randomUUID();
|
|
290
|
+
const now = this.now();
|
|
291
|
+
this.transaction(() => {
|
|
292
|
+
this.database.prepare(`
|
|
293
|
+
INSERT INTO turns (
|
|
294
|
+
id, thread_id, status, text, model, effort, assistant_message_id,
|
|
295
|
+
started_at, finished_at, error_code, error_message
|
|
296
|
+
) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
|
|
297
|
+
`).run(turnId, input.threadId, input.text, input.model ?? null, input.effort ?? null, assistantMessageId, now);
|
|
298
|
+
const userParts = input.text.length === 0 ? [] : [{ type: "text", text: input.text }];
|
|
299
|
+
this.database.prepare(`
|
|
300
|
+
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
301
|
+
VALUES (?, ?, ?, 'user', ?, ?, ?, 'complete')
|
|
302
|
+
`).run(userMessageId, input.threadId, turnId, JSON.stringify(userParts), now, now);
|
|
303
|
+
this.database.prepare(`
|
|
304
|
+
INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
|
|
305
|
+
VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
|
|
306
|
+
`).run(assistantMessageId, input.threadId, turnId, now, now);
|
|
307
|
+
const commitAttachment = this.database.prepare(`
|
|
308
|
+
UPDATE attachments
|
|
309
|
+
SET thread_id = ?, message_id = ?, status = 'committed', updated_at = ?
|
|
310
|
+
WHERE id = ?
|
|
311
|
+
`);
|
|
312
|
+
for (const attachment of attachments) {
|
|
313
|
+
commitAttachment.run(input.threadId, userMessageId, now, attachment.id);
|
|
314
|
+
}
|
|
315
|
+
const title = deriveAutomaticTitle(input.text, attachments);
|
|
316
|
+
this.database.prepare(`
|
|
317
|
+
UPDATE threads
|
|
318
|
+
SET title = CASE WHEN title_manual = 0 AND title = 'New conversation' THEN ? ELSE title END,
|
|
319
|
+
updated_at = ?, revision = revision + 1
|
|
320
|
+
WHERE id = ?
|
|
321
|
+
`).run(title, now, input.threadId);
|
|
322
|
+
this.recordThreadRevision(input.threadId, "turn_started", now);
|
|
323
|
+
this.setSetting("current_thread_id", input.threadId);
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
turnId,
|
|
327
|
+
conversationId: `web:${input.threadId}`,
|
|
328
|
+
text: input.text,
|
|
329
|
+
userMessageId,
|
|
330
|
+
assistantMessageId,
|
|
331
|
+
attachments: attachments.map((attachment) => this.requireStoredAttachment(attachment.id)),
|
|
332
|
+
thread: this.requireThread(input.threadId),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
applyStreamFrame(turnId, frame) {
|
|
336
|
+
return this.applyStreamFrames(turnId, [frame]);
|
|
337
|
+
}
|
|
338
|
+
applyStreamFrames(turnId, frames) {
|
|
339
|
+
const turn = this.requireTurn(turnId);
|
|
340
|
+
if (turn.status !== "running")
|
|
341
|
+
return this.requireMessage(turn.assistant_message_id);
|
|
342
|
+
const message = this.requireMessage(turn.assistant_message_id);
|
|
343
|
+
const parts = [...message.parts];
|
|
344
|
+
let actualModel;
|
|
345
|
+
let actualEffort;
|
|
346
|
+
for (const frame of frames) {
|
|
347
|
+
if (frame.kind === "status") {
|
|
348
|
+
parts.push({ type: "telemetry", event: "status", data: { text: frame.text } });
|
|
349
|
+
}
|
|
350
|
+
else if (frame.kind === "append") {
|
|
351
|
+
appendTextPart(parts, "text", frame.delta);
|
|
352
|
+
}
|
|
353
|
+
else if (frame.kind === "replace") {
|
|
354
|
+
replaceWholeText(parts, frame.text);
|
|
355
|
+
}
|
|
356
|
+
else if (frame.kind === "event") {
|
|
357
|
+
applyEvent(parts, frame.event);
|
|
358
|
+
if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
|
|
359
|
+
if (typeof frame.event.data?.model === "string")
|
|
360
|
+
actualModel = frame.event.data.model;
|
|
361
|
+
if (typeof frame.event.data?.effort === "string")
|
|
362
|
+
actualEffort = frame.event.data.effort;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const now = this.now();
|
|
367
|
+
this.transaction(() => {
|
|
368
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
|
|
369
|
+
.run(JSON.stringify(parts), now, message.id);
|
|
370
|
+
if (actualModel !== undefined || actualEffort !== undefined) {
|
|
371
|
+
this.database.prepare(`
|
|
372
|
+
UPDATE turns SET
|
|
373
|
+
model = CASE WHEN ? IS NULL THEN model ELSE ? END,
|
|
374
|
+
effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
|
|
375
|
+
WHERE id = ?
|
|
376
|
+
`).run(actualModel ?? null, actualModel ?? null, actualEffort ?? null, actualEffort ?? null, turnId);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return this.requireMessage(message.id);
|
|
380
|
+
}
|
|
381
|
+
completeTurn(turnId, finalText, metadata) {
|
|
382
|
+
const runtime = runtimeMetadata(metadata);
|
|
383
|
+
return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime);
|
|
384
|
+
}
|
|
385
|
+
failTurn(turnId, error) {
|
|
386
|
+
return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
|
|
387
|
+
}
|
|
388
|
+
interruptTurn(turnId, message = "The web service stopped before this turn completed.") {
|
|
389
|
+
return this.finishTurn(turnId, "interrupted", undefined, "interrupted", message, undefined);
|
|
390
|
+
}
|
|
391
|
+
activeTurn(threadId) {
|
|
392
|
+
const row = this.database.prepare("SELECT id FROM turns WHERE thread_id = ? AND status = 'running'").get(threadId);
|
|
393
|
+
return row === undefined ? undefined : { id: row.id, conversationId: `web:${threadId}` };
|
|
394
|
+
}
|
|
395
|
+
listActiveTurnIds() {
|
|
396
|
+
const rows = this.database.prepare("SELECT id FROM turns WHERE status = 'running'").all();
|
|
397
|
+
return rows.map((row) => row.id);
|
|
398
|
+
}
|
|
399
|
+
threadIdForTurn(turnId) {
|
|
400
|
+
const row = this.database.prepare("SELECT thread_id FROM turns WHERE id = ?").get(turnId);
|
|
401
|
+
return row?.thread_id;
|
|
402
|
+
}
|
|
403
|
+
initialize() {
|
|
404
|
+
try {
|
|
405
|
+
this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
|
|
406
|
+
const versionRow = this.database.prepare("PRAGMA user_version").get();
|
|
407
|
+
if (versionRow.user_version > 1) {
|
|
408
|
+
throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema 1.`, 500);
|
|
409
|
+
}
|
|
410
|
+
if (versionRow.user_version < 0) {
|
|
411
|
+
throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
|
|
412
|
+
}
|
|
413
|
+
this.database.exec(`
|
|
414
|
+
CREATE TABLE IF NOT EXISTS agents (
|
|
415
|
+
source_id TEXT PRIMARY KEY,
|
|
416
|
+
label TEXT NOT NULL,
|
|
417
|
+
status TEXT NOT NULL,
|
|
418
|
+
health TEXT,
|
|
419
|
+
supports_attachments INTEGER NOT NULL DEFAULT 0,
|
|
420
|
+
models_json TEXT,
|
|
421
|
+
default_model TEXT,
|
|
422
|
+
default_effort TEXT,
|
|
423
|
+
efforts_json TEXT,
|
|
424
|
+
model_options_json TEXT,
|
|
425
|
+
updated_at TEXT NOT NULL
|
|
426
|
+
);
|
|
427
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
428
|
+
id TEXT PRIMARY KEY,
|
|
429
|
+
source_id TEXT NOT NULL REFERENCES agents(source_id),
|
|
430
|
+
conversation_id TEXT NOT NULL UNIQUE,
|
|
431
|
+
title TEXT NOT NULL,
|
|
432
|
+
title_manual INTEGER NOT NULL DEFAULT 0,
|
|
433
|
+
archived_at TEXT,
|
|
434
|
+
created_at TEXT NOT NULL,
|
|
435
|
+
updated_at TEXT NOT NULL,
|
|
436
|
+
revision INTEGER NOT NULL DEFAULT 1
|
|
437
|
+
);
|
|
438
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
439
|
+
id TEXT PRIMARY KEY,
|
|
440
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
441
|
+
status TEXT NOT NULL,
|
|
442
|
+
text TEXT NOT NULL,
|
|
443
|
+
model TEXT,
|
|
444
|
+
effort TEXT,
|
|
445
|
+
assistant_message_id TEXT NOT NULL,
|
|
446
|
+
started_at TEXT NOT NULL,
|
|
447
|
+
finished_at TEXT,
|
|
448
|
+
error_code TEXT,
|
|
449
|
+
error_message TEXT
|
|
450
|
+
);
|
|
451
|
+
CREATE UNIQUE INDEX IF NOT EXISTS turns_one_active_per_thread
|
|
452
|
+
ON turns(thread_id) WHERE status = 'running';
|
|
453
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
454
|
+
id TEXT PRIMARY KEY,
|
|
455
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
456
|
+
turn_id TEXT REFERENCES turns(id) ON DELETE CASCADE,
|
|
457
|
+
role TEXT NOT NULL,
|
|
458
|
+
parts_json TEXT NOT NULL,
|
|
459
|
+
created_at TEXT NOT NULL,
|
|
460
|
+
updated_at TEXT NOT NULL,
|
|
461
|
+
status TEXT NOT NULL
|
|
462
|
+
);
|
|
463
|
+
CREATE INDEX IF NOT EXISTS messages_by_thread ON messages(thread_id, created_at);
|
|
464
|
+
CREATE TABLE IF NOT EXISTS attachments (
|
|
465
|
+
id TEXT PRIMARY KEY,
|
|
466
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
|
|
467
|
+
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
468
|
+
name TEXT NOT NULL,
|
|
469
|
+
content_type TEXT NOT NULL,
|
|
470
|
+
size_bytes INTEGER NOT NULL,
|
|
471
|
+
kind TEXT NOT NULL,
|
|
472
|
+
status TEXT NOT NULL,
|
|
473
|
+
uploaded INTEGER NOT NULL DEFAULT 0,
|
|
474
|
+
storage_name TEXT NOT NULL UNIQUE,
|
|
475
|
+
created_at TEXT NOT NULL,
|
|
476
|
+
updated_at TEXT NOT NULL
|
|
477
|
+
);
|
|
478
|
+
CREATE INDEX IF NOT EXISTS attachments_by_message ON attachments(message_id, created_at);
|
|
479
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
480
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
481
|
+
entity_kind TEXT NOT NULL,
|
|
482
|
+
entity_id TEXT NOT NULL,
|
|
483
|
+
revision INTEGER NOT NULL,
|
|
484
|
+
event TEXT NOT NULL,
|
|
485
|
+
created_at TEXT NOT NULL
|
|
486
|
+
);
|
|
487
|
+
CREATE INDEX IF NOT EXISTS revisions_by_entity ON revisions(entity_kind, entity_id, revision);
|
|
488
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
489
|
+
key TEXT PRIMARY KEY,
|
|
490
|
+
value TEXT NOT NULL
|
|
491
|
+
);
|
|
492
|
+
`);
|
|
493
|
+
if (versionRow.user_version === 0)
|
|
494
|
+
this.database.exec("PRAGMA user_version = 1");
|
|
495
|
+
this.validateStorage();
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (error instanceof WebConsoleError)
|
|
499
|
+
throw error;
|
|
500
|
+
throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
validateStorage() {
|
|
504
|
+
const check = this.database.prepare("PRAGMA quick_check(1)").get();
|
|
505
|
+
if (check === undefined || !Object.values(check).includes("ok")) {
|
|
506
|
+
throw new WebConsoleError("storage_corrupt", "Web state failed SQLite integrity validation.", 500);
|
|
507
|
+
}
|
|
508
|
+
const requiredTables = new Set(["agents", "threads", "turns", "messages", "attachments", "revisions", "settings"]);
|
|
509
|
+
const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
510
|
+
for (const table of tables)
|
|
511
|
+
requiredTables.delete(table.name);
|
|
512
|
+
if (requiredTables.size > 0) {
|
|
513
|
+
throw new WebConsoleError("storage_corrupt", `Web state is missing tables: ${[...requiredTables].join(", ")}.`, 500);
|
|
514
|
+
}
|
|
515
|
+
const messages = this.database.prepare("SELECT id, parts_json FROM messages").all();
|
|
516
|
+
for (const message of messages) {
|
|
517
|
+
try {
|
|
518
|
+
parseParts(message.parts_json);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
recoverInterruptedTurns() {
|
|
526
|
+
const active = this.listActiveTurnIds();
|
|
527
|
+
for (const turnId of active) {
|
|
528
|
+
this.interruptTurn(turnId, "The web service restarted before this turn completed.");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
|
|
532
|
+
const turn = this.requireTurn(turnId);
|
|
533
|
+
const existing = this.requireMessage(turn.assistant_message_id);
|
|
534
|
+
if (turn.status !== "running") {
|
|
535
|
+
return this.requireThreadDetail(turn.thread_id);
|
|
536
|
+
}
|
|
537
|
+
const parts = [...existing.parts];
|
|
538
|
+
if (finalText !== undefined && finalText.length > 0)
|
|
539
|
+
reconcileFinalText(parts, finalText);
|
|
540
|
+
if (errorMessage !== undefined) {
|
|
541
|
+
parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
|
|
542
|
+
}
|
|
543
|
+
const now = this.now();
|
|
544
|
+
this.transaction(() => {
|
|
545
|
+
this.database.prepare(`
|
|
546
|
+
UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
|
|
547
|
+
model = CASE WHEN ? IS NULL THEN model ELSE ? END,
|
|
548
|
+
effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
|
|
549
|
+
WHERE id = ?
|
|
550
|
+
`).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
|
|
551
|
+
this.database.prepare("UPDATE messages SET parts_json = ?, status = ?, updated_at = ? WHERE id = ?")
|
|
552
|
+
.run(JSON.stringify(parts), status, now, existing.id);
|
|
553
|
+
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
554
|
+
.run(now, turn.thread_id);
|
|
555
|
+
this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
|
|
556
|
+
});
|
|
557
|
+
return this.requireThreadDetail(turn.thread_id);
|
|
558
|
+
}
|
|
559
|
+
mapThread(row) {
|
|
560
|
+
const runState = this.latestRunState(row.id);
|
|
561
|
+
const preview = this.lastMessagePreview(row.id);
|
|
562
|
+
return {
|
|
563
|
+
id: row.id,
|
|
564
|
+
sourceId: row.source_id,
|
|
565
|
+
title: row.title,
|
|
566
|
+
archivedAt: row.archived_at,
|
|
567
|
+
createdAt: row.created_at,
|
|
568
|
+
updatedAt: row.updated_at,
|
|
569
|
+
revision: row.revision,
|
|
570
|
+
...(preview === undefined ? {} : { lastMessagePreview: preview }),
|
|
571
|
+
messageCount: row.message_count,
|
|
572
|
+
runState,
|
|
573
|
+
canSend: row.can_send === 1,
|
|
574
|
+
canUpload: row.can_upload === 1,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
mapMessage(row) {
|
|
578
|
+
const attachments = this.database.prepare("SELECT * FROM attachments WHERE message_id = ? ORDER BY created_at, id")
|
|
579
|
+
.all(row.id);
|
|
580
|
+
return {
|
|
581
|
+
id: row.id,
|
|
582
|
+
threadId: row.thread_id,
|
|
583
|
+
...(row.turn_id === null ? {} : { turnId: row.turn_id }),
|
|
584
|
+
role: normalizeRole(row.role),
|
|
585
|
+
parts: parseParts(row.parts_json),
|
|
586
|
+
attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
|
|
587
|
+
createdAt: row.created_at,
|
|
588
|
+
updatedAt: row.updated_at,
|
|
589
|
+
status: normalizeMessageStatus(row.status),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
latestRunState(threadId) {
|
|
593
|
+
const row = this.database.prepare("SELECT * FROM turns WHERE thread_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1")
|
|
594
|
+
.get(threadId);
|
|
595
|
+
if (row === undefined)
|
|
596
|
+
return { status: "idle" };
|
|
597
|
+
const status = normalizeRunStatus(row.status);
|
|
598
|
+
return {
|
|
599
|
+
id: row.id,
|
|
600
|
+
status,
|
|
601
|
+
startedAt: row.started_at,
|
|
602
|
+
...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
|
|
603
|
+
...(row.error_message === null
|
|
604
|
+
? {}
|
|
605
|
+
: { error: { ...(row.error_code === null ? {} : { code: row.error_code }), message: row.error_message } }),
|
|
606
|
+
...(row.model === null ? {} : { model: row.model }),
|
|
607
|
+
...(row.effort === null ? {} : { effort: row.effort }),
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
lastMessagePreview(threadId) {
|
|
611
|
+
const row = this.database.prepare("SELECT parts_json FROM messages WHERE thread_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1")
|
|
612
|
+
.get(threadId);
|
|
613
|
+
if (row === undefined)
|
|
614
|
+
return undefined;
|
|
615
|
+
const text = parseParts(row.parts_json)
|
|
616
|
+
.filter((part) => part.type === "text" || part.type === "reasoning")
|
|
617
|
+
.map((part) => part.text)
|
|
618
|
+
.join(" ")
|
|
619
|
+
.replace(/\s+/gu, " ")
|
|
620
|
+
.trim();
|
|
621
|
+
return text.length === 0 ? undefined : text.slice(0, 160);
|
|
622
|
+
}
|
|
623
|
+
requireThread(id) {
|
|
624
|
+
const thread = this.getThread(id);
|
|
625
|
+
if (thread === undefined)
|
|
626
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
627
|
+
return thread;
|
|
628
|
+
}
|
|
629
|
+
requireThreadDetail(id) {
|
|
630
|
+
const detail = this.getThreadDetail(id);
|
|
631
|
+
if (detail === undefined)
|
|
632
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
633
|
+
return detail;
|
|
634
|
+
}
|
|
635
|
+
requireTurn(id) {
|
|
636
|
+
const row = this.database.prepare("SELECT * FROM turns WHERE id = ?").get(id);
|
|
637
|
+
if (row === undefined)
|
|
638
|
+
throw new WebConsoleError("turn_not_found", "Turn not found.", 404);
|
|
639
|
+
return row;
|
|
640
|
+
}
|
|
641
|
+
requireMessage(id) {
|
|
642
|
+
const row = this.database.prepare("SELECT * FROM messages WHERE id = ?").get(id);
|
|
643
|
+
if (row === undefined)
|
|
644
|
+
throw new WebConsoleError("message_not_found", "Message not found.", 404);
|
|
645
|
+
return this.mapMessage(row);
|
|
646
|
+
}
|
|
647
|
+
requireStoredAttachment(id) {
|
|
648
|
+
const attachment = this.getStoredAttachment(id);
|
|
649
|
+
if (attachment === undefined)
|
|
650
|
+
throw new WebConsoleError("attachment_not_found", "Attachment not found.", 404);
|
|
651
|
+
return attachment;
|
|
652
|
+
}
|
|
653
|
+
recordThreadRevision(threadId, event, now) {
|
|
654
|
+
const row = this.database.prepare("SELECT revision FROM threads WHERE id = ?").get(threadId);
|
|
655
|
+
this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, ?, ?, ?)")
|
|
656
|
+
.run(threadId, row.revision, event, now);
|
|
657
|
+
}
|
|
658
|
+
setSetting(key, value) {
|
|
659
|
+
this.database.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
660
|
+
.run(key, value);
|
|
661
|
+
}
|
|
662
|
+
transaction(operation) {
|
|
663
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
664
|
+
try {
|
|
665
|
+
const result = operation();
|
|
666
|
+
this.database.exec("COMMIT");
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
this.database.exec("ROLLBACK");
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
now() {
|
|
675
|
+
return this.clock().toISOString();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
function threadSelectSql(suffix) {
|
|
679
|
+
return `
|
|
680
|
+
SELECT t.id, t.source_id, t.title, t.archived_at, t.created_at, t.updated_at, t.revision,
|
|
681
|
+
CASE WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
|
|
682
|
+
CASE WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
|
|
683
|
+
(SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
|
|
684
|
+
FROM threads t JOIN agents a ON a.source_id = t.source_id
|
|
685
|
+
${suffix}
|
|
686
|
+
`;
|
|
687
|
+
}
|
|
688
|
+
function agentSelectSql(suffix) {
|
|
689
|
+
return `
|
|
690
|
+
SELECT a.*,
|
|
691
|
+
CASE WHEN EXISTS (
|
|
692
|
+
SELECT 1 FROM settings s
|
|
693
|
+
WHERE s.key = 'agent_pin:' || a.source_id AND s.value = '1'
|
|
694
|
+
) THEN 1 ELSE 0 END AS pinned
|
|
695
|
+
FROM agents a
|
|
696
|
+
${suffix}
|
|
697
|
+
`;
|
|
698
|
+
}
|
|
699
|
+
function agentPinSettingKey(sourceId) {
|
|
700
|
+
return `agent_pin:${sourceId}`;
|
|
701
|
+
}
|
|
702
|
+
function mapAgent(row) {
|
|
703
|
+
const models = parseStringArray(row.models_json);
|
|
704
|
+
const efforts = parseStringArray(row.efforts_json);
|
|
705
|
+
const modelOptions = parseRecord(row.model_options_json);
|
|
706
|
+
return {
|
|
707
|
+
sourceId: row.source_id,
|
|
708
|
+
label: row.label,
|
|
709
|
+
status: row.status === "online" || row.status === "degraded" ? row.status : "offline",
|
|
710
|
+
pinned: row.pinned === 1,
|
|
711
|
+
...(row.health === null ? {} : { health: row.health }),
|
|
712
|
+
supportsAttachments: row.supports_attachments === 1,
|
|
713
|
+
...(models === undefined ? {} : { models }),
|
|
714
|
+
...(row.default_model === null ? {} : { defaultModel: row.default_model }),
|
|
715
|
+
...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
|
|
716
|
+
...(efforts === undefined ? {} : { efforts }),
|
|
717
|
+
...(modelOptions === undefined ? {} : { modelOptions }),
|
|
718
|
+
updatedAt: row.updated_at,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function mapStoredAttachment(row) {
|
|
722
|
+
return {
|
|
723
|
+
id: row.id,
|
|
724
|
+
...(row.thread_id === null ? {} : { threadId: row.thread_id }),
|
|
725
|
+
...(row.message_id === null ? {} : { messageId: row.message_id }),
|
|
726
|
+
name: row.name,
|
|
727
|
+
contentType: row.content_type,
|
|
728
|
+
sizeBytes: row.size_bytes,
|
|
729
|
+
kind: row.kind === "image" ? "image" : "document",
|
|
730
|
+
status: row.status === "committed" ? "committed" : "staged",
|
|
731
|
+
uploaded: row.uploaded === 1,
|
|
732
|
+
storageName: row.storage_name,
|
|
733
|
+
createdAt: row.created_at,
|
|
734
|
+
updatedAt: row.updated_at,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
export function toWebAttachment(attachment) {
|
|
738
|
+
return {
|
|
739
|
+
id: attachment.id,
|
|
740
|
+
name: attachment.name,
|
|
741
|
+
contentType: attachment.contentType,
|
|
742
|
+
sizeBytes: attachment.sizeBytes,
|
|
743
|
+
kind: attachment.kind,
|
|
744
|
+
status: attachment.status,
|
|
745
|
+
uploaded: attachment.uploaded,
|
|
746
|
+
createdAt: attachment.createdAt,
|
|
747
|
+
...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function applyEvent(parts, event) {
|
|
751
|
+
if (event.type === "assistant_thought") {
|
|
752
|
+
appendTextPart(parts, "reasoning", event.text);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (event.type === "tool_call_started") {
|
|
756
|
+
upsertToolCall(parts, {
|
|
757
|
+
type: "tool-call",
|
|
758
|
+
toolCallId: event.id,
|
|
759
|
+
toolName: event.name,
|
|
760
|
+
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
761
|
+
status: "running",
|
|
762
|
+
});
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (event.type === "tool_call_progress") {
|
|
766
|
+
upsertToolCall(parts, {
|
|
767
|
+
type: "tool-call",
|
|
768
|
+
toolCallId: event.id,
|
|
769
|
+
toolName: event.name ?? existingToolName(parts, event.id) ?? "Tool",
|
|
770
|
+
...(event.partialResult === undefined ? {} : { result: event.partialResult }),
|
|
771
|
+
status: "running",
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (event.type === "tool_call_completed") {
|
|
776
|
+
upsertToolCall(parts, {
|
|
777
|
+
type: "tool-call",
|
|
778
|
+
toolCallId: event.id,
|
|
779
|
+
toolName: event.name ?? existingToolName(parts, event.id) ?? "Tool",
|
|
780
|
+
...(event.arguments === undefined ? {} : { args: event.arguments }),
|
|
781
|
+
...(event.content === undefined ? {} : { result: event.content }),
|
|
782
|
+
status: event.isError === true ? "failed" : "complete",
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
parts.push({ type: "telemetry", event: event.type, data: event });
|
|
787
|
+
}
|
|
788
|
+
function existingToolName(parts, id) {
|
|
789
|
+
const existing = parts.find((part) => part.type === "tool-call" && part.toolCallId === id);
|
|
790
|
+
return existing?.type === "tool-call" ? existing.toolName : undefined;
|
|
791
|
+
}
|
|
792
|
+
function upsertToolCall(parts, next) {
|
|
793
|
+
const index = parts.findIndex((part) => part.type === "tool-call" && part.toolCallId === next.toolCallId);
|
|
794
|
+
if (index < 0) {
|
|
795
|
+
parts.push(next);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const previous = parts[index];
|
|
799
|
+
if (previous?.type === "tool-call")
|
|
800
|
+
parts[index] = { ...previous, ...next };
|
|
801
|
+
}
|
|
802
|
+
function appendTextPart(parts, type, delta) {
|
|
803
|
+
const last = parts.at(-1);
|
|
804
|
+
if (last?.type === type) {
|
|
805
|
+
parts[parts.length - 1] = { type, text: `${last.text}${delta}` };
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
parts.push({ type, text: delta });
|
|
809
|
+
}
|
|
810
|
+
function reconcileFinalText(parts, finalText) {
|
|
811
|
+
const textIndexes = parts
|
|
812
|
+
.map((part, index) => part.type === "text" ? index : -1)
|
|
813
|
+
.filter((index) => index >= 0);
|
|
814
|
+
if (textIndexes.length === 0) {
|
|
815
|
+
parts.push({ type: "text", text: finalText });
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const streamed = textIndexes.map((index) => {
|
|
819
|
+
const part = parts[index];
|
|
820
|
+
return part?.type === "text" ? part.text : "";
|
|
821
|
+
}).join("");
|
|
822
|
+
if (streamed === finalText)
|
|
823
|
+
return;
|
|
824
|
+
let offset = 0;
|
|
825
|
+
for (let position = 0; position < textIndexes.length; position += 1) {
|
|
826
|
+
const index = textIndexes[position];
|
|
827
|
+
const previous = parts[index];
|
|
828
|
+
const finalSegment = position === textIndexes.length - 1
|
|
829
|
+
? finalText.slice(offset)
|
|
830
|
+
: finalText.slice(offset, offset + (previous?.type === "text" ? previous.text.length : 0));
|
|
831
|
+
parts[index] = { type: "text", text: finalSegment };
|
|
832
|
+
offset += finalSegment.length;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** Replace the whole assistant text while retaining non-text transcript parts. */
|
|
836
|
+
function replaceWholeText(parts, text) {
|
|
837
|
+
let lastTextIndex = -1;
|
|
838
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
839
|
+
if (parts[index]?.type === "text") {
|
|
840
|
+
lastTextIndex = index;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (lastTextIndex < 0) {
|
|
845
|
+
parts.push({ type: "text", text });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
849
|
+
if (parts[index]?.type !== "text")
|
|
850
|
+
continue;
|
|
851
|
+
if (index === lastTextIndex)
|
|
852
|
+
parts[index] = { type: "text", text };
|
|
853
|
+
else
|
|
854
|
+
parts.splice(index, 1);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function deriveAutomaticTitle(text, attachments) {
|
|
858
|
+
const candidate = text.trim().length > 0 ? text : attachments[0]?.name ?? "New conversation";
|
|
859
|
+
return normalizeTitle(candidate.replace(/\s+/gu, " ").slice(0, 80));
|
|
860
|
+
}
|
|
861
|
+
function normalizeTitle(value) {
|
|
862
|
+
const title = value.trim().replace(/\s+/gu, " ").slice(0, 120);
|
|
863
|
+
if (title.length === 0)
|
|
864
|
+
throw new WebConsoleError("invalid_title", "A conversation title cannot be empty.", 400);
|
|
865
|
+
return title;
|
|
866
|
+
}
|
|
867
|
+
function parseParts(value) {
|
|
868
|
+
let parsed;
|
|
869
|
+
try {
|
|
870
|
+
parsed = JSON.parse(value);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
throw new WebConsoleError("storage_corrupt", "Persisted message parts are not valid JSON.", 500);
|
|
874
|
+
}
|
|
875
|
+
if (!Array.isArray(parsed) || !parsed.every(isWebMessagePart)) {
|
|
876
|
+
throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
|
|
877
|
+
}
|
|
878
|
+
return parsed;
|
|
879
|
+
}
|
|
880
|
+
function isWebMessagePart(value) {
|
|
881
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
882
|
+
return false;
|
|
883
|
+
const part = value;
|
|
884
|
+
if (part.type === "text" || part.type === "reasoning")
|
|
885
|
+
return typeof part.text === "string";
|
|
886
|
+
if (part.type === "tool-call") {
|
|
887
|
+
return typeof part.toolCallId === "string"
|
|
888
|
+
&& typeof part.toolName === "string"
|
|
889
|
+
&& (part.status === "running" || part.status === "complete" || part.status === "failed");
|
|
890
|
+
}
|
|
891
|
+
if (part.type === "telemetry")
|
|
892
|
+
return typeof part.event === "string";
|
|
893
|
+
if (part.type === "error")
|
|
894
|
+
return typeof part.message === "string" && (part.code === undefined || typeof part.code === "string");
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
function parseStringArray(value) {
|
|
898
|
+
if (value === null)
|
|
899
|
+
return undefined;
|
|
900
|
+
try {
|
|
901
|
+
const parsed = JSON.parse(value);
|
|
902
|
+
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed : undefined;
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
function parseRecord(value) {
|
|
909
|
+
if (value === null)
|
|
910
|
+
return undefined;
|
|
911
|
+
try {
|
|
912
|
+
const parsed = JSON.parse(value);
|
|
913
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
914
|
+
? parsed
|
|
915
|
+
: undefined;
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function stringifyOptional(value) {
|
|
922
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
923
|
+
}
|
|
924
|
+
function normalizeRole(value) {
|
|
925
|
+
return value === "assistant" || value === "system" ? value : "user";
|
|
926
|
+
}
|
|
927
|
+
function normalizeMessageStatus(value) {
|
|
928
|
+
return value === "running" || value === "failed" || value === "cancelled" || value === "interrupted"
|
|
929
|
+
? value
|
|
930
|
+
: "complete";
|
|
931
|
+
}
|
|
932
|
+
function normalizeRunStatus(value) {
|
|
933
|
+
return value === "running" || value === "failed" || value === "cancelled" || value === "interrupted"
|
|
934
|
+
? value
|
|
935
|
+
: "complete";
|
|
936
|
+
}
|
|
937
|
+
function runtimeMetadata(metadata) {
|
|
938
|
+
const runtime = metadata?.runtime;
|
|
939
|
+
if (typeof runtime !== "object" || runtime === null || Array.isArray(runtime))
|
|
940
|
+
return undefined;
|
|
941
|
+
const record = runtime;
|
|
942
|
+
const model = typeof record.model === "string" ? record.model : undefined;
|
|
943
|
+
const effort = typeof record.effort === "string" ? record.effort : undefined;
|
|
944
|
+
return model === undefined && effort === undefined ? undefined : {
|
|
945
|
+
...(model === undefined ? {} : { model }),
|
|
946
|
+
...(effort === undefined ? {} : { effort }),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function ignoreMissing(error) {
|
|
950
|
+
if (error.code !== "ENOENT")
|
|
951
|
+
throw error;
|
|
952
|
+
}
|
|
953
|
+
//# sourceMappingURL=store.js.map
|