@castlekit/castle 0.1.6 → 0.3.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/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +20 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +49 -17
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
import { eq, desc, asc, and, lt, gt, sql, inArray } from "drizzle-orm";
|
|
2
|
+
import { v4 as uuid } from "uuid";
|
|
3
|
+
import { getDb } from "./index";
|
|
4
|
+
import {
|
|
5
|
+
channels,
|
|
6
|
+
channelAgents,
|
|
7
|
+
sessions,
|
|
8
|
+
messages,
|
|
9
|
+
messageAttachments,
|
|
10
|
+
messageReactions,
|
|
11
|
+
recentSearches,
|
|
12
|
+
settings,
|
|
13
|
+
agentStatuses,
|
|
14
|
+
} from "./schema";
|
|
15
|
+
import type {
|
|
16
|
+
Channel,
|
|
17
|
+
ChatMessage,
|
|
18
|
+
ChannelSession,
|
|
19
|
+
MessageAttachment,
|
|
20
|
+
MessageReaction,
|
|
21
|
+
} from "@/lib/types/chat";
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Channels
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export function createChannel(
|
|
28
|
+
name: string,
|
|
29
|
+
defaultAgentId: string,
|
|
30
|
+
agentIds: string[] = []
|
|
31
|
+
): Channel {
|
|
32
|
+
const db = getDb();
|
|
33
|
+
const id = uuid();
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
|
|
36
|
+
db.insert(channels).values({
|
|
37
|
+
id,
|
|
38
|
+
name,
|
|
39
|
+
defaultAgentId,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
updatedAt: now,
|
|
42
|
+
}).run();
|
|
43
|
+
|
|
44
|
+
// Add agents to junction table
|
|
45
|
+
const agents = agentIds.length > 0 ? agentIds : [defaultAgentId];
|
|
46
|
+
for (const agentId of agents) {
|
|
47
|
+
db.insert(channelAgents)
|
|
48
|
+
.values({ channelId: id, agentId })
|
|
49
|
+
.run();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
name,
|
|
55
|
+
defaultAgentId,
|
|
56
|
+
agents,
|
|
57
|
+
createdAt: now,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getChannels(includeArchived = false): Channel[] {
|
|
62
|
+
const db = getDb();
|
|
63
|
+
|
|
64
|
+
const query = includeArchived
|
|
65
|
+
? db.select().from(channels).where(sql`${channels.archivedAt} IS NOT NULL`).orderBy(desc(channels.archivedAt))
|
|
66
|
+
: db.select().from(channels).where(sql`${channels.archivedAt} IS NULL`).orderBy(desc(channels.createdAt));
|
|
67
|
+
|
|
68
|
+
const rows = query.all();
|
|
69
|
+
if (rows.length === 0) return [];
|
|
70
|
+
|
|
71
|
+
// Batch-load all channel agents in one query (avoids N+1)
|
|
72
|
+
const channelIds = rows.map((r) => r.id);
|
|
73
|
+
const allAgentRows = db
|
|
74
|
+
.select()
|
|
75
|
+
.from(channelAgents)
|
|
76
|
+
.where(inArray(channelAgents.channelId, channelIds))
|
|
77
|
+
.all();
|
|
78
|
+
|
|
79
|
+
const agentsByChannel = new Map<string, string[]>();
|
|
80
|
+
for (const a of allAgentRows) {
|
|
81
|
+
const list = agentsByChannel.get(a.channelId) || [];
|
|
82
|
+
list.push(a.agentId);
|
|
83
|
+
agentsByChannel.set(a.channelId, list);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return rows.map((row) => ({
|
|
87
|
+
id: row.id,
|
|
88
|
+
name: row.name,
|
|
89
|
+
defaultAgentId: row.defaultAgentId,
|
|
90
|
+
agents: agentsByChannel.get(row.id) || [],
|
|
91
|
+
createdAt: row.createdAt,
|
|
92
|
+
archivedAt: row.archivedAt ?? null,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getChannel(id: string): Channel | null {
|
|
97
|
+
const db = getDb();
|
|
98
|
+
const row = db.select().from(channels).where(eq(channels.id, id)).get();
|
|
99
|
+
if (!row) return null;
|
|
100
|
+
|
|
101
|
+
const agentRows = db
|
|
102
|
+
.select()
|
|
103
|
+
.from(channelAgents)
|
|
104
|
+
.where(eq(channelAgents.channelId, id))
|
|
105
|
+
.all();
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: row.id,
|
|
109
|
+
name: row.name,
|
|
110
|
+
defaultAgentId: row.defaultAgentId,
|
|
111
|
+
agents: agentRows.map((a) => a.agentId),
|
|
112
|
+
createdAt: row.createdAt,
|
|
113
|
+
archivedAt: row.archivedAt ?? null,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function updateChannel(
|
|
118
|
+
id: string,
|
|
119
|
+
updates: { name?: string; defaultAgentId?: string }
|
|
120
|
+
): boolean {
|
|
121
|
+
const db = getDb();
|
|
122
|
+
const result = db
|
|
123
|
+
.update(channels)
|
|
124
|
+
.set({ ...updates, updatedAt: Date.now() })
|
|
125
|
+
.where(eq(channels.id, id))
|
|
126
|
+
.run();
|
|
127
|
+
return result.changes > 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function deleteChannel(id: string): boolean {
|
|
131
|
+
const db = getDb();
|
|
132
|
+
|
|
133
|
+
// Access underlying better-sqlite3 for raw SQL operations
|
|
134
|
+
type SqliteClient = {
|
|
135
|
+
prepare: (sql: string) => { run: (...params: unknown[]) => { changes: number }; all: (...params: unknown[]) => Record<string, unknown>[] };
|
|
136
|
+
exec: (sql: string) => void;
|
|
137
|
+
};
|
|
138
|
+
type DrizzleDb = { session: { client: SqliteClient } };
|
|
139
|
+
const sqlite = (db as unknown as DrizzleDb).session.client;
|
|
140
|
+
|
|
141
|
+
// The FTS5 delete trigger can fail with "SQL logic error" if the FTS5 index
|
|
142
|
+
// is out of sync with the messages table. To avoid this:
|
|
143
|
+
// 1. Drop the FTS5 delete trigger
|
|
144
|
+
// 2. Delete all dependent records and the channel
|
|
145
|
+
// 3. Recreate the trigger
|
|
146
|
+
// 4. Rebuild the FTS5 index to stay in sync
|
|
147
|
+
sqlite.exec("DROP TRIGGER IF EXISTS messages_fts_delete");
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// Delete dependent records: attachments/reactions → messages → sessions → agents → channel
|
|
151
|
+
const msgIds = db
|
|
152
|
+
.select({ id: messages.id })
|
|
153
|
+
.from(messages)
|
|
154
|
+
.where(eq(messages.channelId, id))
|
|
155
|
+
.all()
|
|
156
|
+
.map((r) => r.id);
|
|
157
|
+
|
|
158
|
+
if (msgIds.length > 0) {
|
|
159
|
+
db.delete(messageAttachments)
|
|
160
|
+
.where(inArray(messageAttachments.messageId, msgIds))
|
|
161
|
+
.run();
|
|
162
|
+
db.delete(messageReactions)
|
|
163
|
+
.where(inArray(messageReactions.messageId, msgIds))
|
|
164
|
+
.run();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
db.delete(messages).where(eq(messages.channelId, id)).run();
|
|
168
|
+
db.delete(sessions).where(eq(sessions.channelId, id)).run();
|
|
169
|
+
db.delete(channelAgents).where(eq(channelAgents.channelId, id)).run();
|
|
170
|
+
|
|
171
|
+
const result = db.delete(channels).where(eq(channels.id, id)).run();
|
|
172
|
+
return result.changes > 0;
|
|
173
|
+
} finally {
|
|
174
|
+
// Always recreate the trigger and rebuild FTS5 index
|
|
175
|
+
sqlite.exec(`
|
|
176
|
+
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
|
|
177
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', OLD.rowid, OLD.content);
|
|
178
|
+
END;
|
|
179
|
+
`);
|
|
180
|
+
// Rebuild FTS5 to stay in sync after bulk deletion
|
|
181
|
+
sqlite.exec("INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function archiveChannel(id: string): boolean {
|
|
186
|
+
const db = getDb();
|
|
187
|
+
const result = db
|
|
188
|
+
.update(channels)
|
|
189
|
+
.set({ archivedAt: Date.now() })
|
|
190
|
+
.where(eq(channels.id, id))
|
|
191
|
+
.run();
|
|
192
|
+
return result.changes > 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function restoreChannel(id: string): boolean {
|
|
196
|
+
const db = getDb();
|
|
197
|
+
const result = db
|
|
198
|
+
.update(channels)
|
|
199
|
+
.set({ archivedAt: null })
|
|
200
|
+
.where(eq(channels.id, id))
|
|
201
|
+
.run();
|
|
202
|
+
return result.changes > 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Mark a channel as accessed (updates last_accessed_at to now).
|
|
207
|
+
*/
|
|
208
|
+
export function touchChannel(id: string): void {
|
|
209
|
+
const db = getDb();
|
|
210
|
+
db.update(channels)
|
|
211
|
+
.set({ lastAccessedAt: Date.now() })
|
|
212
|
+
.where(eq(channels.id, id))
|
|
213
|
+
.run();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the most recently accessed channel ID, or null if none.
|
|
218
|
+
*/
|
|
219
|
+
export function getLastAccessedChannelId(): string | null {
|
|
220
|
+
const db = getDb();
|
|
221
|
+
const row = db
|
|
222
|
+
.select({ id: channels.id })
|
|
223
|
+
.from(channels)
|
|
224
|
+
.where(and(
|
|
225
|
+
sql`${channels.lastAccessedAt} IS NOT NULL`,
|
|
226
|
+
sql`${channels.archivedAt} IS NULL`
|
|
227
|
+
))
|
|
228
|
+
.orderBy(desc(channels.lastAccessedAt))
|
|
229
|
+
.limit(1)
|
|
230
|
+
.get();
|
|
231
|
+
return row?.id ?? null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ============================================================================
|
|
235
|
+
// Messages
|
|
236
|
+
// ============================================================================
|
|
237
|
+
|
|
238
|
+
export function createMessage(params: {
|
|
239
|
+
channelId: string;
|
|
240
|
+
sessionId?: string;
|
|
241
|
+
senderType: "user" | "agent";
|
|
242
|
+
senderId: string;
|
|
243
|
+
senderName?: string;
|
|
244
|
+
content: string;
|
|
245
|
+
status?: "complete" | "interrupted" | "aborted";
|
|
246
|
+
mentionedAgentId?: string;
|
|
247
|
+
runId?: string;
|
|
248
|
+
sessionKey?: string;
|
|
249
|
+
inputTokens?: number;
|
|
250
|
+
outputTokens?: number;
|
|
251
|
+
}): ChatMessage {
|
|
252
|
+
const db = getDb();
|
|
253
|
+
const id = uuid();
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
|
|
256
|
+
db.insert(messages)
|
|
257
|
+
.values({
|
|
258
|
+
id,
|
|
259
|
+
channelId: params.channelId,
|
|
260
|
+
sessionId: params.sessionId ?? null,
|
|
261
|
+
senderType: params.senderType,
|
|
262
|
+
senderId: params.senderId,
|
|
263
|
+
senderName: params.senderName ?? null,
|
|
264
|
+
content: params.content,
|
|
265
|
+
status: params.status ?? "complete",
|
|
266
|
+
mentionedAgentId: params.mentionedAgentId ?? null,
|
|
267
|
+
runId: params.runId ?? null,
|
|
268
|
+
sessionKey: params.sessionKey ?? null,
|
|
269
|
+
inputTokens: params.inputTokens ?? null,
|
|
270
|
+
outputTokens: params.outputTokens ?? null,
|
|
271
|
+
createdAt: now,
|
|
272
|
+
})
|
|
273
|
+
.run();
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
id,
|
|
277
|
+
channelId: params.channelId,
|
|
278
|
+
sessionId: params.sessionId ?? null,
|
|
279
|
+
senderType: params.senderType,
|
|
280
|
+
senderId: params.senderId,
|
|
281
|
+
senderName: params.senderName ?? null,
|
|
282
|
+
content: params.content,
|
|
283
|
+
status: params.status ?? "complete",
|
|
284
|
+
mentionedAgentId: params.mentionedAgentId ?? null,
|
|
285
|
+
runId: params.runId ?? null,
|
|
286
|
+
sessionKey: params.sessionKey ?? null,
|
|
287
|
+
inputTokens: params.inputTokens ?? null,
|
|
288
|
+
outputTokens: params.outputTokens ?? null,
|
|
289
|
+
createdAt: now,
|
|
290
|
+
attachments: [],
|
|
291
|
+
reactions: [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function updateMessage(
|
|
296
|
+
id: string,
|
|
297
|
+
updates: {
|
|
298
|
+
content?: string;
|
|
299
|
+
status?: "complete" | "interrupted" | "aborted";
|
|
300
|
+
runId?: string;
|
|
301
|
+
sessionKey?: string;
|
|
302
|
+
inputTokens?: number;
|
|
303
|
+
outputTokens?: number;
|
|
304
|
+
}
|
|
305
|
+
): boolean {
|
|
306
|
+
const db = getDb();
|
|
307
|
+
const result = db
|
|
308
|
+
.update(messages)
|
|
309
|
+
.set(updates)
|
|
310
|
+
.where(eq(messages.id, id))
|
|
311
|
+
.run();
|
|
312
|
+
return result.changes > 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function deleteMessage(id: string): boolean {
|
|
316
|
+
const db = getDb();
|
|
317
|
+
const result = db.delete(messages).where(eq(messages.id, id)).run();
|
|
318
|
+
return result.changes > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Hydrate raw message rows with attachments and reactions.
|
|
323
|
+
* Shared by getMessagesByChannel, getMessagesAfter, and getMessagesAround.
|
|
324
|
+
*/
|
|
325
|
+
function hydrateMessages(rows: typeof messages.$inferSelect[]): ChatMessage[] {
|
|
326
|
+
const db = getDb();
|
|
327
|
+
const messageIds = rows.map((r) => r.id);
|
|
328
|
+
|
|
329
|
+
const attachmentRows = messageIds.length > 0
|
|
330
|
+
? db.select().from(messageAttachments).where(inArray(messageAttachments.messageId, messageIds)).all()
|
|
331
|
+
: [];
|
|
332
|
+
const reactionRows = messageIds.length > 0
|
|
333
|
+
? db.select().from(messageReactions).where(inArray(messageReactions.messageId, messageIds)).all()
|
|
334
|
+
: [];
|
|
335
|
+
|
|
336
|
+
const attachmentsByMsg = new Map<string, MessageAttachment[]>();
|
|
337
|
+
for (const a of attachmentRows) {
|
|
338
|
+
const list = attachmentsByMsg.get(a.messageId) || [];
|
|
339
|
+
list.push({
|
|
340
|
+
id: a.id,
|
|
341
|
+
messageId: a.messageId,
|
|
342
|
+
attachmentType: a.attachmentType as "image" | "audio",
|
|
343
|
+
filePath: a.filePath,
|
|
344
|
+
mimeType: a.mimeType,
|
|
345
|
+
fileSize: a.fileSize,
|
|
346
|
+
originalName: a.originalName,
|
|
347
|
+
createdAt: a.createdAt,
|
|
348
|
+
});
|
|
349
|
+
attachmentsByMsg.set(a.messageId, list);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const reactionsByMsg = new Map<string, MessageReaction[]>();
|
|
353
|
+
for (const r of reactionRows) {
|
|
354
|
+
const list = reactionsByMsg.get(r.messageId) || [];
|
|
355
|
+
list.push({
|
|
356
|
+
id: r.id,
|
|
357
|
+
messageId: r.messageId,
|
|
358
|
+
agentId: r.agentId,
|
|
359
|
+
emoji: r.emoji,
|
|
360
|
+
emojiChar: r.emojiChar,
|
|
361
|
+
createdAt: r.createdAt,
|
|
362
|
+
});
|
|
363
|
+
reactionsByMsg.set(r.messageId, list);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return rows.map((row) => ({
|
|
367
|
+
id: row.id,
|
|
368
|
+
channelId: row.channelId,
|
|
369
|
+
sessionId: row.sessionId,
|
|
370
|
+
senderType: row.senderType as "user" | "agent",
|
|
371
|
+
senderId: row.senderId,
|
|
372
|
+
senderName: row.senderName,
|
|
373
|
+
content: row.content,
|
|
374
|
+
status: row.status as "complete" | "interrupted" | "aborted",
|
|
375
|
+
mentionedAgentId: row.mentionedAgentId,
|
|
376
|
+
runId: row.runId,
|
|
377
|
+
sessionKey: row.sessionKey,
|
|
378
|
+
inputTokens: row.inputTokens,
|
|
379
|
+
outputTokens: row.outputTokens,
|
|
380
|
+
createdAt: row.createdAt,
|
|
381
|
+
attachments: attachmentsByMsg.get(row.id) || [],
|
|
382
|
+
reactions: reactionsByMsg.get(row.id) || [],
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get messages for a channel with cursor-based pagination.
|
|
388
|
+
* @param channelId - Channel to load messages for
|
|
389
|
+
* @param limit - Max messages to return (default 50)
|
|
390
|
+
* @param before - Message ID cursor — returns messages older than this
|
|
391
|
+
*/
|
|
392
|
+
export function getMessagesByChannel(
|
|
393
|
+
channelId: string,
|
|
394
|
+
limit = 50,
|
|
395
|
+
before?: string
|
|
396
|
+
): ChatMessage[] {
|
|
397
|
+
const db = getDb();
|
|
398
|
+
|
|
399
|
+
let query;
|
|
400
|
+
if (before) {
|
|
401
|
+
const cursor = db
|
|
402
|
+
.select({ createdAt: messages.createdAt, id: messages.id })
|
|
403
|
+
.from(messages)
|
|
404
|
+
.where(eq(messages.id, before))
|
|
405
|
+
.get();
|
|
406
|
+
|
|
407
|
+
if (!cursor) return [];
|
|
408
|
+
|
|
409
|
+
// Composite cursor (createdAt, id) to avoid skipping messages with identical timestamps
|
|
410
|
+
query = db
|
|
411
|
+
.select()
|
|
412
|
+
.from(messages)
|
|
413
|
+
.where(
|
|
414
|
+
and(
|
|
415
|
+
eq(messages.channelId, channelId),
|
|
416
|
+
sql`(${messages.createdAt}, ${messages.id}) < (${cursor.createdAt}, ${cursor.id})`
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
.orderBy(desc(messages.createdAt))
|
|
420
|
+
.limit(limit)
|
|
421
|
+
.all();
|
|
422
|
+
} else {
|
|
423
|
+
query = db
|
|
424
|
+
.select()
|
|
425
|
+
.from(messages)
|
|
426
|
+
.where(eq(messages.channelId, channelId))
|
|
427
|
+
.orderBy(desc(messages.createdAt))
|
|
428
|
+
.limit(limit)
|
|
429
|
+
.all();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Reverse to chronological order
|
|
433
|
+
return hydrateMessages(query.reverse());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Get messages for a channel AFTER a cursor (forward pagination).
|
|
438
|
+
* Returns messages newer than the cursor, in chronological order.
|
|
439
|
+
*/
|
|
440
|
+
export function getMessagesAfter(
|
|
441
|
+
channelId: string,
|
|
442
|
+
afterId: string,
|
|
443
|
+
limit = 50
|
|
444
|
+
): ChatMessage[] {
|
|
445
|
+
const db = getDb();
|
|
446
|
+
|
|
447
|
+
const cursor = db
|
|
448
|
+
.select({ createdAt: messages.createdAt, id: messages.id })
|
|
449
|
+
.from(messages)
|
|
450
|
+
.where(eq(messages.id, afterId))
|
|
451
|
+
.get();
|
|
452
|
+
|
|
453
|
+
if (!cursor) return [];
|
|
454
|
+
|
|
455
|
+
// Composite cursor (createdAt, id) to avoid skipping messages with identical timestamps
|
|
456
|
+
const rows = db
|
|
457
|
+
.select()
|
|
458
|
+
.from(messages)
|
|
459
|
+
.where(
|
|
460
|
+
and(
|
|
461
|
+
eq(messages.channelId, channelId),
|
|
462
|
+
sql`(${messages.createdAt}, ${messages.id}) > (${cursor.createdAt}, ${cursor.id})`
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
.orderBy(asc(messages.createdAt))
|
|
466
|
+
.limit(limit)
|
|
467
|
+
.all();
|
|
468
|
+
|
|
469
|
+
return hydrateMessages(rows);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get a window of messages around an anchor message.
|
|
474
|
+
* Returns ~limit messages centered on the anchor, plus hasMoreBefore/hasMoreAfter flags.
|
|
475
|
+
*/
|
|
476
|
+
export function getMessagesAround(
|
|
477
|
+
channelId: string,
|
|
478
|
+
anchorMessageId: string,
|
|
479
|
+
limit = 50
|
|
480
|
+
): { messages: ChatMessage[]; hasMoreBefore: boolean; hasMoreAfter: boolean } | null {
|
|
481
|
+
const db = getDb();
|
|
482
|
+
|
|
483
|
+
// Look up the anchor message
|
|
484
|
+
const anchor = db
|
|
485
|
+
.select()
|
|
486
|
+
.from(messages)
|
|
487
|
+
.where(and(eq(messages.id, anchorMessageId), eq(messages.channelId, channelId)))
|
|
488
|
+
.get();
|
|
489
|
+
|
|
490
|
+
if (!anchor) return null;
|
|
491
|
+
|
|
492
|
+
const half = Math.floor(limit / 2);
|
|
493
|
+
|
|
494
|
+
// Messages before the anchor (composite cursor, DESC then reverse)
|
|
495
|
+
const beforeRows = db
|
|
496
|
+
.select()
|
|
497
|
+
.from(messages)
|
|
498
|
+
.where(
|
|
499
|
+
and(
|
|
500
|
+
eq(messages.channelId, channelId),
|
|
501
|
+
sql`(${messages.createdAt}, ${messages.id}) < (${anchor.createdAt}, ${anchor.id})`
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
.orderBy(desc(messages.createdAt))
|
|
505
|
+
.limit(half)
|
|
506
|
+
.all()
|
|
507
|
+
.reverse();
|
|
508
|
+
|
|
509
|
+
// Messages after the anchor (composite cursor, ASC)
|
|
510
|
+
const afterRows = db
|
|
511
|
+
.select()
|
|
512
|
+
.from(messages)
|
|
513
|
+
.where(
|
|
514
|
+
and(
|
|
515
|
+
eq(messages.channelId, channelId),
|
|
516
|
+
sql`(${messages.createdAt}, ${messages.id}) > (${anchor.createdAt}, ${anchor.id})`
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
.orderBy(asc(messages.createdAt))
|
|
520
|
+
.limit(half)
|
|
521
|
+
.all();
|
|
522
|
+
|
|
523
|
+
// Combine: before + anchor + after (chronological order)
|
|
524
|
+
const allRows = [...beforeRows, anchor, ...afterRows];
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
messages: hydrateMessages(allRows),
|
|
528
|
+
hasMoreBefore: beforeRows.length === half,
|
|
529
|
+
hasMoreAfter: afterRows.length === half,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Find an agent message by runId (for updating partial saves).
|
|
535
|
+
* Only matches agent messages — user messages also carry the runId as a reference
|
|
536
|
+
* to the run they triggered, but should never be overwritten with agent content.
|
|
537
|
+
*/
|
|
538
|
+
export function getMessageByRunId(runId: string) {
|
|
539
|
+
const db = getDb();
|
|
540
|
+
return db
|
|
541
|
+
.select()
|
|
542
|
+
.from(messages)
|
|
543
|
+
.where(and(eq(messages.runId, runId), eq(messages.senderType, "agent")))
|
|
544
|
+
.get() ?? null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============================================================================
|
|
548
|
+
// Sessions
|
|
549
|
+
// ============================================================================
|
|
550
|
+
|
|
551
|
+
export function createSession(params: {
|
|
552
|
+
channelId: string;
|
|
553
|
+
sessionKey?: string;
|
|
554
|
+
}): ChannelSession {
|
|
555
|
+
const db = getDb();
|
|
556
|
+
const id = uuid();
|
|
557
|
+
const now = Date.now();
|
|
558
|
+
|
|
559
|
+
db.insert(sessions)
|
|
560
|
+
.values({
|
|
561
|
+
id,
|
|
562
|
+
channelId: params.channelId,
|
|
563
|
+
sessionKey: params.sessionKey ?? null,
|
|
564
|
+
startedAt: now,
|
|
565
|
+
})
|
|
566
|
+
.run();
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
id,
|
|
570
|
+
channelId: params.channelId,
|
|
571
|
+
sessionKey: params.sessionKey ?? null,
|
|
572
|
+
startedAt: now,
|
|
573
|
+
endedAt: null,
|
|
574
|
+
summary: null,
|
|
575
|
+
totalInputTokens: 0,
|
|
576
|
+
totalOutputTokens: 0,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function updateSession(
|
|
581
|
+
id: string,
|
|
582
|
+
updates: {
|
|
583
|
+
sessionKey?: string;
|
|
584
|
+
endedAt?: number;
|
|
585
|
+
summary?: string;
|
|
586
|
+
totalInputTokens?: number;
|
|
587
|
+
totalOutputTokens?: number;
|
|
588
|
+
}
|
|
589
|
+
): boolean {
|
|
590
|
+
const db = getDb();
|
|
591
|
+
const result = db
|
|
592
|
+
.update(sessions)
|
|
593
|
+
.set(updates)
|
|
594
|
+
.where(eq(sessions.id, id))
|
|
595
|
+
.run();
|
|
596
|
+
return result.changes > 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function getSessionsByChannel(channelId: string): ChannelSession[] {
|
|
600
|
+
const db = getDb();
|
|
601
|
+
return db
|
|
602
|
+
.select()
|
|
603
|
+
.from(sessions)
|
|
604
|
+
.where(eq(sessions.channelId, channelId))
|
|
605
|
+
.orderBy(desc(sessions.startedAt))
|
|
606
|
+
.all()
|
|
607
|
+
.map((row) => ({
|
|
608
|
+
id: row.id,
|
|
609
|
+
channelId: row.channelId,
|
|
610
|
+
sessionKey: row.sessionKey,
|
|
611
|
+
startedAt: row.startedAt,
|
|
612
|
+
endedAt: row.endedAt,
|
|
613
|
+
summary: row.summary,
|
|
614
|
+
totalInputTokens: row.totalInputTokens ?? 0,
|
|
615
|
+
totalOutputTokens: row.totalOutputTokens ?? 0,
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function getLatestSessionKey(channelId: string): string | null {
|
|
620
|
+
const db = getDb();
|
|
621
|
+
const row = db
|
|
622
|
+
.select({ sessionKey: sessions.sessionKey })
|
|
623
|
+
.from(sessions)
|
|
624
|
+
.where(eq(sessions.channelId, channelId))
|
|
625
|
+
.orderBy(desc(sessions.startedAt))
|
|
626
|
+
.limit(1)
|
|
627
|
+
.get();
|
|
628
|
+
return row?.sessionKey ?? null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ============================================================================
|
|
632
|
+
// Attachments
|
|
633
|
+
// ============================================================================
|
|
634
|
+
|
|
635
|
+
export function createAttachment(params: {
|
|
636
|
+
messageId: string;
|
|
637
|
+
attachmentType: "image" | "audio";
|
|
638
|
+
filePath: string;
|
|
639
|
+
mimeType?: string;
|
|
640
|
+
fileSize?: number;
|
|
641
|
+
originalName?: string;
|
|
642
|
+
}): MessageAttachment {
|
|
643
|
+
const db = getDb();
|
|
644
|
+
const id = uuid();
|
|
645
|
+
const now = Date.now();
|
|
646
|
+
|
|
647
|
+
db.insert(messageAttachments)
|
|
648
|
+
.values({
|
|
649
|
+
id,
|
|
650
|
+
messageId: params.messageId,
|
|
651
|
+
attachmentType: params.attachmentType,
|
|
652
|
+
filePath: params.filePath,
|
|
653
|
+
mimeType: params.mimeType ?? null,
|
|
654
|
+
fileSize: params.fileSize ?? null,
|
|
655
|
+
originalName: params.originalName ?? null,
|
|
656
|
+
createdAt: now,
|
|
657
|
+
})
|
|
658
|
+
.run();
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
id,
|
|
662
|
+
messageId: params.messageId,
|
|
663
|
+
attachmentType: params.attachmentType,
|
|
664
|
+
filePath: params.filePath,
|
|
665
|
+
mimeType: params.mimeType ?? null,
|
|
666
|
+
fileSize: params.fileSize ?? null,
|
|
667
|
+
originalName: params.originalName ?? null,
|
|
668
|
+
createdAt: now,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function getAttachmentsByMessage(messageId: string): MessageAttachment[] {
|
|
673
|
+
const db = getDb();
|
|
674
|
+
return db
|
|
675
|
+
.select()
|
|
676
|
+
.from(messageAttachments)
|
|
677
|
+
.where(eq(messageAttachments.messageId, messageId))
|
|
678
|
+
.all()
|
|
679
|
+
.map((row) => ({
|
|
680
|
+
id: row.id,
|
|
681
|
+
messageId: row.messageId,
|
|
682
|
+
attachmentType: row.attachmentType as "image" | "audio",
|
|
683
|
+
filePath: row.filePath,
|
|
684
|
+
mimeType: row.mimeType,
|
|
685
|
+
fileSize: row.fileSize,
|
|
686
|
+
originalName: row.originalName,
|
|
687
|
+
createdAt: row.createdAt,
|
|
688
|
+
}));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ============================================================================
|
|
692
|
+
// Reactions
|
|
693
|
+
// ============================================================================
|
|
694
|
+
|
|
695
|
+
export function createReaction(params: {
|
|
696
|
+
messageId: string;
|
|
697
|
+
agentId?: string;
|
|
698
|
+
emoji: string;
|
|
699
|
+
emojiChar: string;
|
|
700
|
+
}): MessageReaction {
|
|
701
|
+
const db = getDb();
|
|
702
|
+
const id = uuid();
|
|
703
|
+
const now = Date.now();
|
|
704
|
+
|
|
705
|
+
db.insert(messageReactions)
|
|
706
|
+
.values({
|
|
707
|
+
id,
|
|
708
|
+
messageId: params.messageId,
|
|
709
|
+
agentId: params.agentId ?? null,
|
|
710
|
+
emoji: params.emoji,
|
|
711
|
+
emojiChar: params.emojiChar,
|
|
712
|
+
createdAt: now,
|
|
713
|
+
})
|
|
714
|
+
.run();
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
id,
|
|
718
|
+
messageId: params.messageId,
|
|
719
|
+
agentId: params.agentId ?? null,
|
|
720
|
+
emoji: params.emoji,
|
|
721
|
+
emojiChar: params.emojiChar,
|
|
722
|
+
createdAt: now,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function getReactionsByMessage(messageId: string): MessageReaction[] {
|
|
727
|
+
const db = getDb();
|
|
728
|
+
return db
|
|
729
|
+
.select()
|
|
730
|
+
.from(messageReactions)
|
|
731
|
+
.where(eq(messageReactions.messageId, messageId))
|
|
732
|
+
.all()
|
|
733
|
+
.map((row) => ({
|
|
734
|
+
id: row.id,
|
|
735
|
+
messageId: row.messageId,
|
|
736
|
+
agentId: row.agentId,
|
|
737
|
+
emoji: row.emoji,
|
|
738
|
+
emojiChar: row.emojiChar,
|
|
739
|
+
createdAt: row.createdAt,
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ============================================================================
|
|
744
|
+
// Search (FTS5)
|
|
745
|
+
// ============================================================================
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Full-text search across messages using FTS5.
|
|
749
|
+
* Uses parameterized MATCH queries for safety.
|
|
750
|
+
*/
|
|
751
|
+
export function searchMessages(
|
|
752
|
+
query: string,
|
|
753
|
+
channelId?: string,
|
|
754
|
+
limit = 30
|
|
755
|
+
): ChatMessage[] {
|
|
756
|
+
const db = getDb();
|
|
757
|
+
|
|
758
|
+
// Safety: reject overly long queries
|
|
759
|
+
if (!query || query.length > 500) return [];
|
|
760
|
+
|
|
761
|
+
// Sanitize FTS5 query: wrap each word in double quotes to prevent FTS5 operator injection.
|
|
762
|
+
// FTS5 supports operators like AND, OR, NOT, NEAR, and column filters — quoting each
|
|
763
|
+
// term treats them as literal strings instead of operators.
|
|
764
|
+
const sanitizedQuery = query
|
|
765
|
+
.replace(/"/g, "") // Remove existing quotes to prevent breaking out
|
|
766
|
+
.split(/\s+/)
|
|
767
|
+
.filter(Boolean)
|
|
768
|
+
.map((term) => `"${term}"`)
|
|
769
|
+
.join(" ");
|
|
770
|
+
|
|
771
|
+
if (!sanitizedQuery) return [];
|
|
772
|
+
|
|
773
|
+
// Use raw SQL for FTS5 MATCH — Drizzle doesn't support virtual tables
|
|
774
|
+
const dbInstance = db as unknown as { all: (query: unknown) => unknown[] };
|
|
775
|
+
|
|
776
|
+
// Access the underlying better-sqlite3 instance
|
|
777
|
+
type DrizzleInternals = { session: { client: { prepare: (sql: string) => { all: (...params: unknown[]) => Record<string, unknown>[] } } } };
|
|
778
|
+
const sqlite = (db as unknown as DrizzleInternals).session.client;
|
|
779
|
+
|
|
780
|
+
let rows: Record<string, unknown>[];
|
|
781
|
+
|
|
782
|
+
if (channelId) {
|
|
783
|
+
const stmt = sqlite.prepare(`
|
|
784
|
+
SELECT m.*
|
|
785
|
+
FROM messages m
|
|
786
|
+
JOIN messages_fts fts ON m.rowid = fts.rowid
|
|
787
|
+
WHERE messages_fts MATCH ?
|
|
788
|
+
AND m.channel_id = ?
|
|
789
|
+
ORDER BY m.created_at DESC
|
|
790
|
+
LIMIT ?
|
|
791
|
+
`);
|
|
792
|
+
rows = stmt.all(sanitizedQuery, channelId, limit);
|
|
793
|
+
} else {
|
|
794
|
+
const stmt = sqlite.prepare(`
|
|
795
|
+
SELECT m.*
|
|
796
|
+
FROM messages m
|
|
797
|
+
JOIN messages_fts fts ON m.rowid = fts.rowid
|
|
798
|
+
WHERE messages_fts MATCH ?
|
|
799
|
+
ORDER BY m.created_at DESC
|
|
800
|
+
LIMIT ?
|
|
801
|
+
`);
|
|
802
|
+
rows = stmt.all(sanitizedQuery, limit);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return rows.map((row) => ({
|
|
806
|
+
id: row.id as string,
|
|
807
|
+
channelId: row.channel_id as string,
|
|
808
|
+
sessionId: (row.session_id as string) ?? null,
|
|
809
|
+
senderType: row.sender_type as "user" | "agent",
|
|
810
|
+
senderId: row.sender_id as string,
|
|
811
|
+
senderName: (row.sender_name as string) ?? null,
|
|
812
|
+
content: row.content as string,
|
|
813
|
+
status: (row.status as "complete" | "interrupted" | "aborted") ?? "complete",
|
|
814
|
+
mentionedAgentId: (row.mentioned_agent_id as string) ?? null,
|
|
815
|
+
runId: (row.run_id as string) ?? null,
|
|
816
|
+
sessionKey: (row.session_key as string) ?? null,
|
|
817
|
+
inputTokens: (row.input_tokens as number) ?? null,
|
|
818
|
+
outputTokens: (row.output_tokens as number) ?? null,
|
|
819
|
+
createdAt: row.created_at as number,
|
|
820
|
+
attachments: [],
|
|
821
|
+
reactions: [],
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ============================================================================
|
|
826
|
+
// Storage Stats
|
|
827
|
+
// ============================================================================
|
|
828
|
+
|
|
829
|
+
export function getStorageStats() {
|
|
830
|
+
const db = getDb();
|
|
831
|
+
|
|
832
|
+
const messageCount = db
|
|
833
|
+
.select({ count: sql<number>`count(*)` })
|
|
834
|
+
.from(messages)
|
|
835
|
+
.get();
|
|
836
|
+
|
|
837
|
+
const channelCount = db
|
|
838
|
+
.select({ count: sql<number>`count(*)` })
|
|
839
|
+
.from(channels)
|
|
840
|
+
.get();
|
|
841
|
+
|
|
842
|
+
const attachmentCount = db
|
|
843
|
+
.select({ count: sql<number>`count(*)` })
|
|
844
|
+
.from(messageAttachments)
|
|
845
|
+
.get();
|
|
846
|
+
|
|
847
|
+
// Total attachment size from DB metadata
|
|
848
|
+
const totalAttachmentSize = db
|
|
849
|
+
.select({ total: sql<number>`COALESCE(SUM(file_size), 0)` })
|
|
850
|
+
.from(messageAttachments)
|
|
851
|
+
.get();
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
messages: messageCount?.count ?? 0,
|
|
855
|
+
channels: channelCount?.count ?? 0,
|
|
856
|
+
attachments: attachmentCount?.count ?? 0,
|
|
857
|
+
totalAttachmentBytes: totalAttachmentSize?.total ?? 0,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ============================================================================
|
|
862
|
+
// Settings
|
|
863
|
+
// ============================================================================
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Get a setting value by key. Returns null if not set.
|
|
867
|
+
*/
|
|
868
|
+
export function getSetting(key: string): string | null {
|
|
869
|
+
const db = getDb();
|
|
870
|
+
const row = db
|
|
871
|
+
.select({ value: settings.value })
|
|
872
|
+
.from(settings)
|
|
873
|
+
.where(eq(settings.key, key))
|
|
874
|
+
.get();
|
|
875
|
+
return row?.value ?? null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Get all settings as a key-value record.
|
|
880
|
+
*/
|
|
881
|
+
export function getAllSettings(): Record<string, string> {
|
|
882
|
+
const db = getDb();
|
|
883
|
+
const rows = db.select().from(settings).all();
|
|
884
|
+
const result: Record<string, string> = {};
|
|
885
|
+
for (const row of rows) {
|
|
886
|
+
result[row.key] = row.value;
|
|
887
|
+
}
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Set a setting value (upsert).
|
|
893
|
+
*/
|
|
894
|
+
export function setSetting(key: string, value: string): void {
|
|
895
|
+
const db = getDb();
|
|
896
|
+
const existing = db
|
|
897
|
+
.select({ key: settings.key })
|
|
898
|
+
.from(settings)
|
|
899
|
+
.where(eq(settings.key, key))
|
|
900
|
+
.get();
|
|
901
|
+
|
|
902
|
+
if (existing) {
|
|
903
|
+
db.update(settings)
|
|
904
|
+
.set({ value, updatedAt: Date.now() })
|
|
905
|
+
.where(eq(settings.key, key))
|
|
906
|
+
.run();
|
|
907
|
+
} else {
|
|
908
|
+
db.insert(settings)
|
|
909
|
+
.values({ key, value, updatedAt: Date.now() })
|
|
910
|
+
.run();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ============================================================================
|
|
915
|
+
// Agent Statuses
|
|
916
|
+
// ============================================================================
|
|
917
|
+
|
|
918
|
+
const ACTIVE_DURATION_MS = 2 * 60 * 1000; // 2 minutes
|
|
919
|
+
|
|
920
|
+
export type AgentStatusValue = "idle" | "thinking" | "active";
|
|
921
|
+
|
|
922
|
+
export interface AgentStatusRow {
|
|
923
|
+
agentId: string;
|
|
924
|
+
status: AgentStatusValue;
|
|
925
|
+
updatedAt: number;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Get all agent statuses. If an agent's status is "active" but updated_at
|
|
930
|
+
* is more than 2 minutes ago, it's returned as "idle" instead.
|
|
931
|
+
*/
|
|
932
|
+
export function getAgentStatuses(): AgentStatusRow[] {
|
|
933
|
+
const db = getDb();
|
|
934
|
+
const rows = db.select().from(agentStatuses).all();
|
|
935
|
+
const now = Date.now();
|
|
936
|
+
|
|
937
|
+
return rows.map((row) => {
|
|
938
|
+
const status = row.status as AgentStatusValue;
|
|
939
|
+
// Auto-expire "active" status after 2 minutes
|
|
940
|
+
if (status === "active" && now - row.updatedAt > ACTIVE_DURATION_MS) {
|
|
941
|
+
return { agentId: row.agentId, status: "idle" as AgentStatusValue, updatedAt: row.updatedAt };
|
|
942
|
+
}
|
|
943
|
+
return { agentId: row.agentId, status, updatedAt: row.updatedAt };
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Set an agent's status. Upserts the row.
|
|
949
|
+
*/
|
|
950
|
+
export function setAgentStatus(agentId: string, status: AgentStatusValue): void {
|
|
951
|
+
const db = getDb();
|
|
952
|
+
const now = Date.now();
|
|
953
|
+
|
|
954
|
+
const existing = db.select().from(agentStatuses).where(eq(agentStatuses.agentId, agentId)).get();
|
|
955
|
+
if (existing) {
|
|
956
|
+
db.update(agentStatuses)
|
|
957
|
+
.set({ status, updatedAt: now })
|
|
958
|
+
.where(eq(agentStatuses.agentId, agentId))
|
|
959
|
+
.run();
|
|
960
|
+
} else {
|
|
961
|
+
db.insert(agentStatuses)
|
|
962
|
+
.values({ agentId, status, updatedAt: now })
|
|
963
|
+
.run();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ============================================================================
|
|
968
|
+
// Recent Searches
|
|
969
|
+
// ============================================================================
|
|
970
|
+
|
|
971
|
+
const MAX_RECENT_SEARCHES = 15;
|
|
972
|
+
|
|
973
|
+
export function getRecentSearches(): string[] {
|
|
974
|
+
const db = getDb();
|
|
975
|
+
const rows = db
|
|
976
|
+
.select({ query: recentSearches.query })
|
|
977
|
+
.from(recentSearches)
|
|
978
|
+
.orderBy(desc(recentSearches.createdAt))
|
|
979
|
+
.limit(MAX_RECENT_SEARCHES)
|
|
980
|
+
.all();
|
|
981
|
+
return rows.map((r) => r.query);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function addRecentSearch(query: string): void {
|
|
985
|
+
const db = getDb();
|
|
986
|
+
const trimmed = query.trim();
|
|
987
|
+
if (!trimmed) return;
|
|
988
|
+
|
|
989
|
+
// Remove duplicate if it already exists
|
|
990
|
+
db.delete(recentSearches)
|
|
991
|
+
.where(eq(recentSearches.query, trimmed))
|
|
992
|
+
.run();
|
|
993
|
+
|
|
994
|
+
// Insert as most recent
|
|
995
|
+
db.insert(recentSearches)
|
|
996
|
+
.values({ query: trimmed, createdAt: Date.now() })
|
|
997
|
+
.run();
|
|
998
|
+
|
|
999
|
+
// Prune old entries beyond the limit
|
|
1000
|
+
const all = db
|
|
1001
|
+
.select({ id: recentSearches.id })
|
|
1002
|
+
.from(recentSearches)
|
|
1003
|
+
.orderBy(desc(recentSearches.createdAt))
|
|
1004
|
+
.all();
|
|
1005
|
+
|
|
1006
|
+
if (all.length > MAX_RECENT_SEARCHES) {
|
|
1007
|
+
const toDelete = all.slice(MAX_RECENT_SEARCHES).map((r) => r.id);
|
|
1008
|
+
db.delete(recentSearches)
|
|
1009
|
+
.where(inArray(recentSearches.id, toDelete))
|
|
1010
|
+
.run();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
export function clearRecentSearches(): void {
|
|
1015
|
+
const db = getDb();
|
|
1016
|
+
db.delete(recentSearches).run();
|
|
1017
|
+
}
|