iris-chatbot 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/iris.mjs +267 -0
  4. package/package.json +61 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +49 -0
  7. package/template/eslint.config.mjs +18 -0
  8. package/template/next.config.ts +7 -0
  9. package/template/package-lock.json +9193 -0
  10. package/template/package.json +46 -0
  11. package/template/postcss.config.mjs +7 -0
  12. package/template/public/file.svg +1 -0
  13. package/template/public/globe.svg +1 -0
  14. package/template/public/next.svg +1 -0
  15. package/template/public/vercel.svg +1 -0
  16. package/template/public/window.svg +1 -0
  17. package/template/src/app/api/chat/route.ts +2445 -0
  18. package/template/src/app/api/connections/models/route.ts +255 -0
  19. package/template/src/app/api/connections/test/route.ts +124 -0
  20. package/template/src/app/api/local-sync/route.ts +74 -0
  21. package/template/src/app/api/tool-approval/route.ts +47 -0
  22. package/template/src/app/favicon.ico +0 -0
  23. package/template/src/app/globals.css +808 -0
  24. package/template/src/app/layout.tsx +74 -0
  25. package/template/src/app/page.tsx +444 -0
  26. package/template/src/components/ChatView.tsx +1537 -0
  27. package/template/src/components/Composer.tsx +160 -0
  28. package/template/src/components/MapView.tsx +244 -0
  29. package/template/src/components/MessageCard.tsx +955 -0
  30. package/template/src/components/SearchModal.tsx +72 -0
  31. package/template/src/components/SettingsModal.tsx +1257 -0
  32. package/template/src/components/Sidebar.tsx +153 -0
  33. package/template/src/components/TopBar.tsx +164 -0
  34. package/template/src/lib/connections.ts +275 -0
  35. package/template/src/lib/data.ts +324 -0
  36. package/template/src/lib/db.ts +49 -0
  37. package/template/src/lib/hooks.ts +76 -0
  38. package/template/src/lib/local-sync.ts +192 -0
  39. package/template/src/lib/memory.ts +695 -0
  40. package/template/src/lib/model-presets.ts +251 -0
  41. package/template/src/lib/store.ts +36 -0
  42. package/template/src/lib/tooling/approvals.ts +78 -0
  43. package/template/src/lib/tooling/providers/anthropic.ts +155 -0
  44. package/template/src/lib/tooling/providers/ollama.ts +73 -0
  45. package/template/src/lib/tooling/providers/openai.ts +267 -0
  46. package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
  47. package/template/src/lib/tooling/providers/types.ts +44 -0
  48. package/template/src/lib/tooling/registry.ts +103 -0
  49. package/template/src/lib/tooling/runtime.ts +189 -0
  50. package/template/src/lib/tooling/safety.ts +165 -0
  51. package/template/src/lib/tooling/tools/apps.ts +108 -0
  52. package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
  53. package/template/src/lib/tooling/tools/communication.ts +883 -0
  54. package/template/src/lib/tooling/tools/files.ts +395 -0
  55. package/template/src/lib/tooling/tools/music.ts +988 -0
  56. package/template/src/lib/tooling/tools/notes.ts +461 -0
  57. package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
  58. package/template/src/lib/tooling/tools/numbers.ts +175 -0
  59. package/template/src/lib/tooling/tools/schedule.ts +579 -0
  60. package/template/src/lib/tooling/tools/system.ts +142 -0
  61. package/template/src/lib/tooling/tools/web.ts +212 -0
  62. package/template/src/lib/tooling/tools/workflow.ts +218 -0
  63. package/template/src/lib/tooling/types.ts +27 -0
  64. package/template/src/lib/types.ts +309 -0
  65. package/template/src/lib/utils.ts +108 -0
  66. package/template/tsconfig.json +34 -0
@@ -0,0 +1,324 @@
1
+ import { nanoid } from "nanoid";
2
+ import { db } from "./db";
3
+ import type {
4
+ LocalToolsSettings,
5
+ MemorySettings,
6
+ MessageNode,
7
+ Settings,
8
+ Thread,
9
+ } from "./types";
10
+ import { DEFAULT_LOCAL_TOOLS_SETTINGS, DEFAULT_MEMORY_SETTINGS } from "./types";
11
+ import { deriveTitle } from "./utils";
12
+ import { BUILTIN_CONNECTION_IDS, migrateLegacyConnections } from "./connections";
13
+
14
+ const DEFAULT_SETTINGS: Settings = {
15
+ id: "settings",
16
+ openaiKey: undefined,
17
+ anthropicKey: undefined,
18
+ geminiKey: undefined,
19
+ defaultProvider: "openai",
20
+ defaultModel: "gpt-5.2",
21
+ connections: [],
22
+ defaultConnectionId: BUILTIN_CONNECTION_IDS.openai,
23
+ defaultModelByConnection: {},
24
+ showExtendedOpenAIModels: false,
25
+ enableWebSources: true,
26
+ accentColor: "#66706e",
27
+ font: "manrope",
28
+ theme: "dark",
29
+ localTools: { ...DEFAULT_LOCAL_TOOLS_SETTINGS },
30
+ memory: { ...DEFAULT_MEMORY_SETTINGS },
31
+ };
32
+
33
+ const LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS: LocalToolsSettings = {
34
+ ...DEFAULT_LOCAL_TOOLS_SETTINGS,
35
+ approvalMode: "always_confirm_writes",
36
+ };
37
+
38
+ function sameStringArray(a: string[], b: readonly string[]): boolean {
39
+ if (a.length !== b.length) {
40
+ return false;
41
+ }
42
+ return a.every((value, index) => value === b[index]);
43
+ }
44
+
45
+ function isLegacyDefaultLocalToolsSettings(localTools: LocalToolsSettings): boolean {
46
+ return (
47
+ localTools.enabled === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enabled &&
48
+ localTools.approvalMode === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode &&
49
+ localTools.safetyProfile === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile &&
50
+ sameStringArray(localTools.allowedRoots, LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots) &&
51
+ localTools.enableNotes === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableNotes &&
52
+ localTools.enableApps === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableApps &&
53
+ localTools.enableNumbers === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableNumbers &&
54
+ localTools.enableWeb === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableWeb &&
55
+ localTools.enableMusic === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableMusic &&
56
+ localTools.enableCalendar === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableCalendar &&
57
+ localTools.enableMail === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableMail &&
58
+ localTools.enableWorkflow === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableWorkflow &&
59
+ localTools.enableSystem === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.enableSystem &&
60
+ localTools.webSearchBackend === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.webSearchBackend &&
61
+ localTools.dryRun === LEGACY_DEFAULT_LOCAL_TOOLS_SETTINGS.dryRun
62
+ );
63
+ }
64
+
65
+ function normalizeLocalToolsSettings(
66
+ localTools: LocalToolsSettings | undefined,
67
+ ): LocalToolsSettings {
68
+ if (!localTools) {
69
+ return { ...DEFAULT_LOCAL_TOOLS_SETTINGS };
70
+ }
71
+
72
+ const normalized: LocalToolsSettings = {
73
+ enabled:
74
+ typeof localTools.enabled === "boolean"
75
+ ? localTools.enabled
76
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enabled,
77
+ approvalMode:
78
+ localTools.approvalMode ?? DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode,
79
+ safetyProfile:
80
+ localTools.safetyProfile ?? DEFAULT_LOCAL_TOOLS_SETTINGS.safetyProfile,
81
+ allowedRoots:
82
+ Array.isArray(localTools.allowedRoots) && localTools.allowedRoots.length > 0
83
+ ? localTools.allowedRoots.filter((root) => typeof root === "string" && root.trim())
84
+ : [...DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots],
85
+ enableNotes:
86
+ typeof localTools.enableNotes === "boolean"
87
+ ? localTools.enableNotes
88
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableNotes,
89
+ enableApps:
90
+ typeof localTools.enableApps === "boolean"
91
+ ? localTools.enableApps
92
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableApps,
93
+ enableNumbers:
94
+ typeof localTools.enableNumbers === "boolean"
95
+ ? localTools.enableNumbers
96
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableNumbers,
97
+ enableWeb:
98
+ typeof localTools.enableWeb === "boolean"
99
+ ? localTools.enableWeb
100
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableWeb,
101
+ enableMusic:
102
+ typeof localTools.enableMusic === "boolean"
103
+ ? localTools.enableMusic
104
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableMusic,
105
+ enableCalendar:
106
+ typeof localTools.enableCalendar === "boolean"
107
+ ? localTools.enableCalendar
108
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableCalendar,
109
+ enableMail:
110
+ typeof localTools.enableMail === "boolean"
111
+ ? localTools.enableMail
112
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableMail,
113
+ enableWorkflow:
114
+ typeof localTools.enableWorkflow === "boolean"
115
+ ? localTools.enableWorkflow
116
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableWorkflow,
117
+ enableSystem:
118
+ typeof localTools.enableSystem === "boolean"
119
+ ? localTools.enableSystem
120
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.enableSystem,
121
+ webSearchBackend:
122
+ localTools.webSearchBackend ?? DEFAULT_LOCAL_TOOLS_SETTINGS.webSearchBackend,
123
+ dryRun:
124
+ typeof localTools.dryRun === "boolean"
125
+ ? localTools.dryRun
126
+ : DEFAULT_LOCAL_TOOLS_SETTINGS.dryRun,
127
+ };
128
+
129
+ // Migrate legacy defaults so approvals are opt-in by mode instead of always-on.
130
+ if (isLegacyDefaultLocalToolsSettings(normalized)) {
131
+ normalized.approvalMode = DEFAULT_LOCAL_TOOLS_SETTINGS.approvalMode;
132
+ }
133
+
134
+ return normalized;
135
+ }
136
+
137
+ function normalizeMemorySettings(memory: MemorySettings | undefined): MemorySettings {
138
+ return {
139
+ enabled:
140
+ typeof memory?.enabled === "boolean"
141
+ ? memory.enabled
142
+ : DEFAULT_MEMORY_SETTINGS.enabled,
143
+ autoCapture:
144
+ typeof memory?.autoCapture === "boolean"
145
+ ? memory.autoCapture
146
+ : DEFAULT_MEMORY_SETTINGS.autoCapture,
147
+ toolInfluence:
148
+ typeof memory?.toolInfluence === "boolean"
149
+ ? memory.toolInfluence
150
+ : DEFAULT_MEMORY_SETTINGS.toolInfluence,
151
+ extractionMode: "conservative",
152
+ };
153
+ }
154
+
155
+ export async function ensureDefaults() {
156
+ const settings = await db.settings.get("settings");
157
+ if (!settings) {
158
+ const migrated = migrateLegacyConnections(DEFAULT_SETTINGS);
159
+ await db.settings.put({
160
+ ...DEFAULT_SETTINGS,
161
+ ...migrated,
162
+ });
163
+ } else {
164
+ const updates: Partial<Settings> = {};
165
+ if (!settings.accentColor) {
166
+ updates.accentColor = DEFAULT_SETTINGS.accentColor;
167
+ }
168
+ if (!settings.font) {
169
+ updates.font = DEFAULT_SETTINGS.font;
170
+ }
171
+ if (!settings.theme) {
172
+ updates.theme = DEFAULT_SETTINGS.theme;
173
+ }
174
+
175
+ const normalizedLocalTools = normalizeLocalToolsSettings(settings.localTools);
176
+ const localToolsChanged = JSON.stringify(normalizedLocalTools) !== JSON.stringify(settings.localTools);
177
+ if (localToolsChanged) {
178
+ updates.localTools = normalizedLocalTools;
179
+ }
180
+
181
+ const normalizedMemory = normalizeMemorySettings(settings.memory);
182
+ const memoryChanged = JSON.stringify(normalizedMemory) !== JSON.stringify(settings.memory);
183
+ if (memoryChanged) {
184
+ updates.memory = normalizedMemory;
185
+ }
186
+
187
+ const migratedConnections = migrateLegacyConnections(settings);
188
+ if (JSON.stringify(settings.connections ?? []) !== JSON.stringify(migratedConnections.connections)) {
189
+ updates.connections = migratedConnections.connections;
190
+ }
191
+ if (settings.defaultConnectionId !== migratedConnections.defaultConnectionId) {
192
+ updates.defaultConnectionId = migratedConnections.defaultConnectionId;
193
+ }
194
+ if (
195
+ JSON.stringify(settings.defaultModelByConnection ?? {}) !==
196
+ JSON.stringify(migratedConnections.defaultModelByConnection)
197
+ ) {
198
+ updates.defaultModelByConnection = migratedConnections.defaultModelByConnection;
199
+ }
200
+ if (!settings.defaultProvider) {
201
+ updates.defaultProvider = "openai";
202
+ }
203
+ if (!settings.defaultModel) {
204
+ updates.defaultModel = "gpt-5.2";
205
+ }
206
+ if (typeof settings.showExtendedOpenAIModels !== "boolean") {
207
+ updates.showExtendedOpenAIModels = false;
208
+ }
209
+ if (typeof settings.enableWebSources !== "boolean") {
210
+ updates.enableWebSources = true;
211
+ }
212
+
213
+ if (Object.keys(updates).length > 0) {
214
+ await db.settings.update("settings", updates);
215
+ }
216
+ }
217
+
218
+ const threadCount = await db.threads.count();
219
+ if (threadCount === 0) {
220
+ await createNewThread();
221
+ }
222
+ }
223
+
224
+ export async function createNewThread() {
225
+ const now = Date.now();
226
+ const conversationId = nanoid();
227
+ const thread: Thread = {
228
+ id: nanoid(),
229
+ conversationId,
230
+ headMessageId: null,
231
+ title: "New chat",
232
+ forkedFromMessageId: null,
233
+ updatedAt: now,
234
+ };
235
+ await db.threads.add(thread);
236
+ return thread;
237
+ }
238
+
239
+ export async function createThreadFromMessage(messageId: string, conversationId: string) {
240
+ const existing = await db.threads
241
+ .where("forkedFromMessageId")
242
+ .equals(messageId)
243
+ .toArray();
244
+ const count = existing.filter(
245
+ (thread) => thread.conversationId === conversationId
246
+ ).length;
247
+ // Base path is Thread 1, so only 4 additional forked threads are allowed.
248
+ if (count >= 4) {
249
+ throw new Error("Maximum of 5 threads reached for this output.");
250
+ }
251
+
252
+ const now = Date.now();
253
+ const thread: Thread = {
254
+ id: nanoid(),
255
+ conversationId,
256
+ headMessageId: messageId,
257
+ title: `Thread ${count + 2}`,
258
+ forkedFromMessageId: messageId,
259
+ updatedAt: now,
260
+ };
261
+ await db.threads.add(thread);
262
+ return thread;
263
+ }
264
+
265
+ export async function deleteThread(threadId: string) {
266
+ await db.threads.delete(threadId);
267
+ }
268
+
269
+ export async function deleteConversation(conversationId: string) {
270
+ await db.transaction(
271
+ "rw",
272
+ [db.threads, db.messages, db.toolEvents, db.toolApprovals, db.memories],
273
+ async () => {
274
+ await db.threads.where("conversationId").equals(conversationId).delete();
275
+ await db.messages.where("conversationId").equals(conversationId).delete();
276
+ await db.toolEvents.where("conversationId").equals(conversationId).delete();
277
+ await db.toolApprovals.where("conversationId").equals(conversationId).delete();
278
+ await db.memories.where("conversationId").equals(conversationId).delete();
279
+ },
280
+ );
281
+ }
282
+
283
+ export async function appendUserAndAssistant(params: {
284
+ thread: Thread;
285
+ content: string;
286
+ provider: string;
287
+ model: string;
288
+ }) {
289
+ const { thread, content, provider, model } = params;
290
+ const now = Date.now();
291
+ const userId = nanoid();
292
+ const assistantId = nanoid();
293
+
294
+ const userMessage: MessageNode = {
295
+ id: userId,
296
+ conversationId: thread.conversationId,
297
+ parentId: thread.headMessageId,
298
+ role: "user",
299
+ content,
300
+ createdAt: now,
301
+ };
302
+
303
+ const assistantMessage: MessageNode = {
304
+ id: assistantId,
305
+ conversationId: thread.conversationId,
306
+ parentId: userId,
307
+ role: "assistant",
308
+ content: "",
309
+ createdAt: now + 1,
310
+ provider,
311
+ model,
312
+ };
313
+
314
+ await db.messages.bulkAdd([userMessage, assistantMessage]);
315
+
316
+ const title = thread.title === "New chat" ? deriveTitle(content) : thread.title;
317
+ await db.threads.update(thread.id, {
318
+ headMessageId: assistantId,
319
+ updatedAt: Date.now(),
320
+ title,
321
+ });
322
+
323
+ return { userMessage, assistantMessage };
324
+ }
@@ -0,0 +1,49 @@
1
+ import Dexie, { Table } from "dexie";
2
+ import type { MemoryEntry, MessageNode, Settings, Thread, ToolApproval, ToolEvent } from "./types";
3
+
4
+ class ZenithDB extends Dexie {
5
+ messages!: Table<MessageNode, string>;
6
+ threads!: Table<Thread, string>;
7
+ settings!: Table<Settings, string>;
8
+ toolEvents!: Table<ToolEvent, string>;
9
+ toolApprovals!: Table<ToolApproval, string>;
10
+ memories!: Table<MemoryEntry, string>;
11
+
12
+ constructor() {
13
+ super("zenith-chat");
14
+ this.version(1).stores({
15
+ messages: "id, conversationId, parentId, createdAt",
16
+ threads: "id, conversationId, headMessageId, updatedAt",
17
+ settings: "id",
18
+ });
19
+ this.version(2).stores({
20
+ messages: "id, conversationId, parentId, createdAt",
21
+ threads: "id, conversationId, headMessageId, forkedFromMessageId, updatedAt",
22
+ settings: "id",
23
+ });
24
+ this.version(3).stores({
25
+ messages: "id, conversationId, parentId, createdAt",
26
+ threads: "id, conversationId, headMessageId, forkedFromMessageId, updatedAt",
27
+ settings: "id",
28
+ toolEvents: "id, conversationId, threadId, assistantMessageId, toolCallId, createdAt",
29
+ toolApprovals: "id, conversationId, threadId, assistantMessageId, toolCallId, status, requestedAt",
30
+ });
31
+ this.version(4).stores({
32
+ messages: "id, conversationId, parentId, createdAt",
33
+ threads: "id, conversationId, headMessageId, forkedFromMessageId, updatedAt",
34
+ settings: "id",
35
+ toolEvents: "id, conversationId, threadId, assistantMessageId, toolCallId, createdAt",
36
+ toolApprovals: "id, conversationId, threadId, assistantMessageId, toolCallId, status, requestedAt",
37
+ });
38
+ this.version(5).stores({
39
+ messages: "id, conversationId, parentId, createdAt",
40
+ threads: "id, conversationId, headMessageId, forkedFromMessageId, updatedAt",
41
+ settings: "id",
42
+ toolEvents: "id, conversationId, threadId, assistantMessageId, toolCallId, createdAt",
43
+ toolApprovals: "id, conversationId, threadId, assistantMessageId, toolCallId, status, requestedAt",
44
+ memories: "id, kind, scope, conversationId, normalizedKey, updatedAt, lastUsedAt",
45
+ });
46
+ }
47
+ }
48
+
49
+ export const db = new ZenithDB();
@@ -0,0 +1,76 @@
1
+ import { useLiveQuery } from "dexie-react-hooks";
2
+ import { db } from "./db";
3
+ import type { MemoryEntry, MessageNode, Settings, Thread, ToolApproval, ToolEvent } from "./types";
4
+
5
+ export function useThreads(): Thread[] | undefined {
6
+ return useLiveQuery<Thread[], Thread[]>(
7
+ () => db.threads.orderBy("updatedAt").reverse().toArray(),
8
+ [],
9
+ [] as Thread[]
10
+ );
11
+ }
12
+
13
+ export function useSettings(): Settings | null | undefined {
14
+ return useLiveQuery<Settings | null, Settings | null>(
15
+ () => db.settings.get("settings").then((value) => value ?? null),
16
+ [],
17
+ null
18
+ );
19
+ }
20
+
21
+ export function useConversationMessages(
22
+ conversationId: string | null
23
+ ): MessageNode[] | undefined {
24
+ return useLiveQuery<MessageNode[], MessageNode[]>(
25
+ () =>
26
+ conversationId
27
+ ? db.messages.where("conversationId").equals(conversationId).toArray()
28
+ : Promise.resolve([] as MessageNode[]),
29
+ [conversationId],
30
+ [] as MessageNode[]
31
+ );
32
+ }
33
+
34
+ export function useToolEvents(
35
+ conversationId: string | null
36
+ ): ToolEvent[] | undefined {
37
+ return useLiveQuery<ToolEvent[], ToolEvent[]>(
38
+ () =>
39
+ conversationId
40
+ ? db.toolEvents.where("conversationId").equals(conversationId).sortBy("createdAt")
41
+ : Promise.resolve([] as ToolEvent[]),
42
+ [conversationId],
43
+ [] as ToolEvent[]
44
+ );
45
+ }
46
+
47
+ export function useToolApprovals(
48
+ conversationId: string | null
49
+ ): ToolApproval[] | undefined {
50
+ return useLiveQuery<ToolApproval[], ToolApproval[]>(
51
+ () =>
52
+ conversationId
53
+ ? db.toolApprovals.where("conversationId").equals(conversationId).sortBy("requestedAt")
54
+ : Promise.resolve([] as ToolApproval[]),
55
+ [conversationId],
56
+ [] as ToolApproval[]
57
+ );
58
+ }
59
+
60
+ export function useMemories(
61
+ conversationId?: string | null
62
+ ): MemoryEntry[] | undefined {
63
+ return useLiveQuery<MemoryEntry[], MemoryEntry[]>(
64
+ async () => {
65
+ const all = await db.memories.orderBy("updatedAt").reverse().toArray();
66
+ if (!conversationId) {
67
+ return all;
68
+ }
69
+ return all.filter(
70
+ (entry) => entry.scope === "global" || entry.conversationId === conversationId,
71
+ );
72
+ },
73
+ [conversationId],
74
+ [] as MemoryEntry[],
75
+ );
76
+ }
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { db } from "./db";
4
+ import type {
5
+ MemoryEntry,
6
+ MessageNode,
7
+ Settings,
8
+ Thread,
9
+ ToolApproval,
10
+ ToolEvent,
11
+ } from "./types";
12
+
13
+ export type SyncPayload = {
14
+ threads: Thread[];
15
+ messages: MessageNode[];
16
+ settings: Settings[];
17
+ toolEvents: ToolEvent[];
18
+ toolApprovals: ToolApproval[];
19
+ memories: MemoryEntry[];
20
+ };
21
+
22
+ export function isLocalhost(): boolean {
23
+ if (typeof window === "undefined") {
24
+ return false;
25
+ }
26
+ const h = window.location.hostname;
27
+ return h === "localhost" || h === "127.0.0.1";
28
+ }
29
+
30
+ export async function exportAll(): Promise<SyncPayload> {
31
+ const [threads, messages, settings, toolEvents, toolApprovals, memories] =
32
+ await Promise.all([
33
+ db.threads.toArray(),
34
+ db.messages.toArray(),
35
+ db.settings.toArray(),
36
+ db.toolEvents.toArray(),
37
+ db.toolApprovals.toArray(),
38
+ db.memories.toArray(),
39
+ ]);
40
+ return {
41
+ threads,
42
+ messages,
43
+ settings,
44
+ toolEvents,
45
+ toolApprovals,
46
+ memories,
47
+ };
48
+ }
49
+
50
+ export async function importAll(payload: SyncPayload): Promise<void> {
51
+ await db.transaction(
52
+ "rw",
53
+ [
54
+ db.threads,
55
+ db.messages,
56
+ db.settings,
57
+ db.toolEvents,
58
+ db.toolApprovals,
59
+ db.memories,
60
+ ],
61
+ async () => {
62
+ await db.toolEvents.clear();
63
+ await db.toolApprovals.clear();
64
+ await db.memories.clear();
65
+ await db.messages.clear();
66
+ await db.threads.clear();
67
+ await db.settings.clear();
68
+
69
+ if (payload.settings.length > 0) {
70
+ await db.settings.bulkPut(payload.settings);
71
+ }
72
+ if (payload.threads.length > 0) {
73
+ await db.threads.bulkPut(payload.threads);
74
+ }
75
+ if (payload.messages.length > 0) {
76
+ await db.messages.bulkPut(payload.messages);
77
+ }
78
+ if (payload.toolEvents.length > 0) {
79
+ await db.toolEvents.bulkPut(payload.toolEvents);
80
+ }
81
+ if (payload.toolApprovals.length > 0) {
82
+ await db.toolApprovals.bulkPut(payload.toolApprovals);
83
+ }
84
+ if (payload.memories.length > 0) {
85
+ await db.memories.bulkPut(payload.memories);
86
+ }
87
+ },
88
+ );
89
+ }
90
+
91
+ export async function loadFromServer(): Promise<void> {
92
+ if (!isLocalhost()) {
93
+ return;
94
+ }
95
+ const res = await fetch("/api/local-sync");
96
+ if (!res.ok) {
97
+ return;
98
+ }
99
+ const data = (await res.json()) as SyncPayload;
100
+ const hasData =
101
+ data.threads?.length > 0 ||
102
+ data.messages?.length > 0 ||
103
+ (data.settings?.length ?? 0) > 0 ||
104
+ (data.toolEvents?.length ?? 0) > 0 ||
105
+ (data.toolApprovals?.length ?? 0) > 0 ||
106
+ (data.memories?.length ?? 0) > 0;
107
+ if (!hasData) {
108
+ return;
109
+ }
110
+ await importAll({
111
+ threads: Array.isArray(data.threads) ? data.threads : [],
112
+ messages: Array.isArray(data.messages) ? data.messages : [],
113
+ settings: Array.isArray(data.settings) ? data.settings : [],
114
+ toolEvents: Array.isArray(data.toolEvents) ? data.toolEvents : [],
115
+ toolApprovals: Array.isArray(data.toolApprovals) ? data.toolApprovals : [],
116
+ memories: Array.isArray(data.memories) ? data.memories : [],
117
+ });
118
+ }
119
+
120
+ const SAVE_DEBOUNCE_MS = 700;
121
+ let saveTimeout: ReturnType<typeof setTimeout> | null = null;
122
+
123
+ function scheduleSave(): void {
124
+ if (!isLocalhost()) {
125
+ return;
126
+ }
127
+ if (saveTimeout) {
128
+ clearTimeout(saveTimeout);
129
+ }
130
+ saveTimeout = setTimeout(async () => {
131
+ saveTimeout = null;
132
+ try {
133
+ const payload = await exportAll();
134
+ await fetch("/api/local-sync", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify(payload),
138
+ });
139
+ } catch {
140
+ // Ignore sync errors (e.g. server down, offline)
141
+ }
142
+ }, SAVE_DEBOUNCE_MS);
143
+ }
144
+
145
+ export async function saveToServer(): Promise<void> {
146
+ if (!isLocalhost()) {
147
+ return;
148
+ }
149
+ if (saveTimeout) {
150
+ clearTimeout(saveTimeout);
151
+ saveTimeout = null;
152
+ }
153
+ try {
154
+ const payload = await exportAll();
155
+ await fetch("/api/local-sync", {
156
+ method: "POST",
157
+ headers: { "Content-Type": "application/json" },
158
+ body: JSON.stringify(payload),
159
+ });
160
+ } catch {
161
+ // Ignore
162
+ }
163
+ }
164
+
165
+ function registerTableHooks(): void {
166
+ const tables = [
167
+ db.threads,
168
+ db.messages,
169
+ db.settings,
170
+ db.toolEvents,
171
+ db.toolApprovals,
172
+ db.memories,
173
+ ] as const;
174
+ for (const table of tables) {
175
+ table.hook("creating", () => {
176
+ scheduleSave();
177
+ });
178
+ table.hook("updating", () => {
179
+ scheduleSave();
180
+ });
181
+ table.hook("deleting", () => {
182
+ scheduleSave();
183
+ });
184
+ }
185
+ }
186
+
187
+ export function registerSyncHooks(): void {
188
+ if (!isLocalhost()) {
189
+ return;
190
+ }
191
+ registerTableHooks();
192
+ }