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,1257 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Eye, EyeOff, RefreshCw, X } from "lucide-react";
5
+ import {
6
+ BUILTIN_CONNECTION_IDS,
7
+ ensureBuiltinConnections,
8
+ normalizeBaseUrl,
9
+ } from "../lib/connections";
10
+ import { db } from "../lib/db";
11
+ import { useMemories } from "../lib/hooks";
12
+ import { normalizeMemoryKey } from "../lib/memory";
13
+ import { filterModelIdsForConnection, getConnectionModelPresets } from "../lib/model-presets";
14
+ import {
15
+ DEFAULT_MEMORY_SETTINGS,
16
+ DEFAULT_LOCAL_TOOLS_SETTINGS,
17
+ type ApprovalMode,
18
+ type ChatConnectionPayload,
19
+ type ConnectionKind,
20
+ type MemoryEntry,
21
+ type MemoryKind,
22
+ type MemoryScope,
23
+ type MemorySource,
24
+ type ModelConnection,
25
+ type SafetyProfile,
26
+ type Settings,
27
+ type WebSearchBackend,
28
+ } from "../lib/types";
29
+
30
+ type TabId =
31
+ | "models"
32
+ | "keys"
33
+ | "connections"
34
+ | "local-tools"
35
+ | "memory"
36
+ | "appearance"
37
+ | "advanced";
38
+
39
+ const TABS: Array<{ id: TabId; label: string }> = [
40
+ { id: "models", label: "Models" },
41
+ { id: "keys", label: "API Keys" },
42
+ { id: "connections", label: "Connections" },
43
+ { id: "local-tools", label: "Local Tools" },
44
+ { id: "memory", label: "Memory" },
45
+ { id: "appearance", label: "Appearance" },
46
+ { id: "advanced", label: "Advanced" },
47
+ ];
48
+
49
+ function parseHeadersText(value: string): Array<{ key: string; value: string }> {
50
+ return value
51
+ .split("\n")
52
+ .map((line) => line.trim())
53
+ .filter(Boolean)
54
+ .map((line) => {
55
+ const separatorIndex = line.indexOf(":");
56
+ if (separatorIndex === -1) {
57
+ return { key: line, value: "" };
58
+ }
59
+ return {
60
+ key: line.slice(0, separatorIndex).trim(),
61
+ value: line.slice(separatorIndex + 1).trim(),
62
+ };
63
+ })
64
+ .filter((header) => header.key.length > 0);
65
+ }
66
+
67
+ function formatHeadersText(headers: Array<{ key: string; value: string }> | undefined): string {
68
+ if (!Array.isArray(headers) || headers.length === 0) {
69
+ return "";
70
+ }
71
+ return headers.map((header) => `${header.key}: ${header.value}`).join("\n");
72
+ }
73
+
74
+ function makeConnectionPayload(
75
+ connection: ModelConnection,
76
+ keys: { openaiKey: string; anthropicKey: string; geminiKey: string },
77
+ ): ChatConnectionPayload {
78
+ const keyFromProvider =
79
+ connection.kind === "builtin"
80
+ ? connection.provider === "openai"
81
+ ? keys.openaiKey
82
+ : connection.provider === "anthropic"
83
+ ? keys.anthropicKey
84
+ : connection.provider === "google"
85
+ ? keys.geminiKey
86
+ : ""
87
+ : "";
88
+
89
+ return {
90
+ id: connection.id,
91
+ name: connection.name,
92
+ kind: connection.kind,
93
+ provider: connection.provider,
94
+ baseUrl: connection.baseUrl,
95
+ apiKey: connection.apiKey || keyFromProvider || undefined,
96
+ headers: connection.headers ?? [],
97
+ supportsTools: connection.supportsTools,
98
+ };
99
+ }
100
+
101
+ export default function SettingsModal({
102
+ settings,
103
+ onClose,
104
+ }: {
105
+ settings: Settings | null;
106
+ onClose: () => void;
107
+ }) {
108
+ const [activeTab, setActiveTab] = useState<TabId>("models");
109
+ const [openaiKey, setOpenaiKey] = useState(settings?.openaiKey || "");
110
+ const [anthropicKey, setAnthropicKey] = useState(settings?.anthropicKey || "");
111
+ const [geminiKey, setGeminiKey] = useState(settings?.geminiKey || "");
112
+ const [showOpenAIKey, setShowOpenAIKey] = useState(false);
113
+ const [showAnthropicKey, setShowAnthropicKey] = useState(false);
114
+ const [showGeminiKey, setShowGeminiKey] = useState(false);
115
+ const [accentColor, setAccentColor] = useState(settings?.accentColor || "#66706e");
116
+ const [theme, setTheme] = useState<"dark" | "light">(settings?.theme || "dark");
117
+ const [font, setFont] = useState<"ibm" | "manrope" | "sora" | "space" | "poppins">(
118
+ settings?.font || "manrope",
119
+ );
120
+
121
+ const localTools = settings?.localTools ?? DEFAULT_LOCAL_TOOLS_SETTINGS;
122
+ const [toolsEnabled, setToolsEnabled] = useState(localTools.enabled);
123
+ const [approvalMode, setApprovalMode] = useState<ApprovalMode>(localTools.approvalMode);
124
+ const [safetyProfile, setSafetyProfile] = useState<SafetyProfile>(localTools.safetyProfile);
125
+ const [allowedRootsText, setAllowedRootsText] = useState((localTools.allowedRoots ?? []).join("\n"));
126
+ const [enableNotes, setEnableNotes] = useState(localTools.enableNotes);
127
+ const [enableApps, setEnableApps] = useState(localTools.enableApps);
128
+ const [enableNumbers, setEnableNumbers] = useState(localTools.enableNumbers);
129
+ const [enableWeb, setEnableWeb] = useState(localTools.enableWeb);
130
+ const [enableMusic, setEnableMusic] = useState(localTools.enableMusic);
131
+ const [enableCalendar, setEnableCalendar] = useState(localTools.enableCalendar);
132
+ const [enableMail, setEnableMail] = useState(localTools.enableMail);
133
+ const [enableWorkflow, setEnableWorkflow] = useState(localTools.enableWorkflow);
134
+ const [enableSystem, setEnableSystem] = useState(localTools.enableSystem);
135
+ const [webSearchBackend, setWebSearchBackend] = useState<WebSearchBackend>(localTools.webSearchBackend);
136
+ const [dryRun, setDryRun] = useState(localTools.dryRun);
137
+ const memory = settings?.memory ?? DEFAULT_MEMORY_SETTINGS;
138
+ const [memoryEnabled, setMemoryEnabled] = useState(memory.enabled);
139
+ const [memoryAutoCapture, setMemoryAutoCapture] = useState(memory.autoCapture);
140
+ const [memoryToolInfluence, setMemoryToolInfluence] = useState(memory.toolInfluence);
141
+
142
+ const [connections, setConnections] = useState<ModelConnection[]>(settings?.connections ?? []);
143
+ const [defaultConnectionId, setDefaultConnectionId] = useState(
144
+ settings?.defaultConnectionId ?? settings?.connections?.[0]?.id ?? BUILTIN_CONNECTION_IDS.openai,
145
+ );
146
+ const [defaultModelByConnection, setDefaultModelByConnection] = useState<Record<string, string>>(
147
+ settings?.defaultModelByConnection ?? {},
148
+ );
149
+ const [showExtendedOpenAIModels, setShowExtendedOpenAIModels] = useState(
150
+ Boolean(settings?.showExtendedOpenAIModels),
151
+ );
152
+ const [enableWebSources, setEnableWebSources] = useState(settings?.enableWebSources ?? true);
153
+
154
+ const [editingConnectionId, setEditingConnectionId] = useState<string | null>(null);
155
+ const [connectionFormName, setConnectionFormName] = useState("");
156
+ const [connectionFormKind, setConnectionFormKind] = useState<ConnectionKind>("openai_compatible");
157
+ const [connectionFormBaseUrl, setConnectionFormBaseUrl] = useState("");
158
+ const [connectionFormApiKey, setConnectionFormApiKey] = useState("");
159
+ const [connectionFormSupportsTools, setConnectionFormSupportsTools] = useState(true);
160
+ const [connectionFormHeadersText, setConnectionFormHeadersText] = useState("");
161
+
162
+ const [modelInput, setModelInput] = useState("");
163
+ const [connectionStatus, setConnectionStatus] = useState<Record<string, string>>({});
164
+ const [connectionStatusBusy, setConnectionStatusBusy] = useState<Record<string, boolean>>({});
165
+ const [fetchedModels, setFetchedModels] = useState<Record<string, string[]>>({});
166
+ const memories = useMemories();
167
+ const [memorySearch, setMemorySearch] = useState("");
168
+ const [editingMemoryId, setEditingMemoryId] = useState<string | null>(null);
169
+ const [memoryFormKind, setMemoryFormKind] = useState<MemoryKind>("note");
170
+ const [memoryFormScope, setMemoryFormScope] = useState<MemoryScope>("global");
171
+ const [memoryFormConversationId, setMemoryFormConversationId] = useState("");
172
+ const [memoryFormSource, setMemoryFormSource] = useState<MemorySource>("manual");
173
+ const [memoryFormKey, setMemoryFormKey] = useState("");
174
+ const [memoryFormValue, setMemoryFormValue] = useState("");
175
+ const [memoryStatus, setMemoryStatus] = useState<string | null>(null);
176
+ const enabledConnections = connections.filter((connection) => connection.enabled);
177
+ const selectedDefaultConnection =
178
+ connections.find((connection) => connection.id === defaultConnectionId) ?? enabledConnections[0] ?? null;
179
+
180
+ useEffect(() => {
181
+ if (!selectedDefaultConnection) {
182
+ setModelInput("");
183
+ return;
184
+ }
185
+ setModelInput(defaultModelByConnection[selectedDefaultConnection.id] || "");
186
+ }, [selectedDefaultConnection, defaultModelByConnection]);
187
+
188
+ if (!settings) {
189
+ return (
190
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6">
191
+ <div className="w-full max-w-md rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-6 text-sm text-[var(--text-muted)]">
192
+ Loading settings...
193
+ </div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ const selectedDefaultModel = selectedDefaultConnection
199
+ ? defaultModelByConnection[selectedDefaultConnection.id] ||
200
+ getConnectionModelPresets(selectedDefaultConnection)[0] ||
201
+ ""
202
+ : "";
203
+ const selectedPresets = selectedDefaultConnection ? getConnectionModelPresets(selectedDefaultConnection) : [];
204
+ const selectedFetchedModels = selectedDefaultConnection ? fetchedModels[selectedDefaultConnection.id] ?? [] : [];
205
+ const selectableModels = filterModelIdsForConnection({
206
+ connection: selectedDefaultConnection,
207
+ modelIds: [...new Set([...selectedFetchedModels, ...selectedPresets])],
208
+ includeExtendedOpenAI: showExtendedOpenAIModels,
209
+ });
210
+ const filteredMemories = (memories ?? []).filter((entry) => {
211
+ const query = memorySearch.trim().toLowerCase();
212
+ if (!query) {
213
+ return true;
214
+ }
215
+ const text = `${entry.kind} ${entry.scope} ${entry.key} ${entry.value} ${entry.conversationId ?? ""}`.toLowerCase();
216
+ return text.includes(query);
217
+ });
218
+
219
+ const resetConnectionForm = () => {
220
+ setEditingConnectionId(null);
221
+ setConnectionFormName("");
222
+ setConnectionFormKind("openai_compatible");
223
+ setConnectionFormBaseUrl("");
224
+ setConnectionFormApiKey("");
225
+ setConnectionFormSupportsTools(true);
226
+ setConnectionFormHeadersText("");
227
+ };
228
+
229
+ const resetMemoryForm = () => {
230
+ setEditingMemoryId(null);
231
+ setMemoryFormKind("note");
232
+ setMemoryFormScope("global");
233
+ setMemoryFormConversationId("");
234
+ setMemoryFormSource("manual");
235
+ setMemoryFormKey("");
236
+ setMemoryFormValue("");
237
+ setMemoryStatus(null);
238
+ };
239
+
240
+ const loadMemoryIntoForm = (entry: MemoryEntry) => {
241
+ setEditingMemoryId(entry.id);
242
+ setMemoryFormKind(entry.kind);
243
+ setMemoryFormScope(entry.scope);
244
+ setMemoryFormConversationId(entry.conversationId ?? "");
245
+ setMemoryFormSource(entry.source);
246
+ setMemoryFormKey(entry.key);
247
+ setMemoryFormValue(entry.value);
248
+ setMemoryStatus(null);
249
+ };
250
+
251
+ const upsertMemoryEntry = async () => {
252
+ const key = memoryFormKey.trim();
253
+ const value = memoryFormValue.trim();
254
+ if (!key || !value) {
255
+ setMemoryStatus("Key and value are required.");
256
+ return;
257
+ }
258
+ const normalizedKey = normalizeMemoryKey(key);
259
+ if (!normalizedKey) {
260
+ setMemoryStatus("Key must include letters or numbers.");
261
+ return;
262
+ }
263
+ if (memoryFormScope === "conversation" && !memoryFormConversationId.trim()) {
264
+ setMemoryStatus("Conversation ID is required for conversation-scoped memory.");
265
+ return;
266
+ }
267
+
268
+ const now = Date.now();
269
+ const existing = editingMemoryId
270
+ ? (memories ?? []).find((entry) => entry.id === editingMemoryId)
271
+ : null;
272
+ const entry: MemoryEntry = {
273
+ id: editingMemoryId ?? `memory-${now}`,
274
+ kind: memoryFormKind,
275
+ scope: memoryFormScope,
276
+ conversationId:
277
+ memoryFormScope === "conversation"
278
+ ? memoryFormConversationId.trim()
279
+ : undefined,
280
+ key,
281
+ value,
282
+ normalizedKey,
283
+ source: memoryFormSource,
284
+ confidence:
285
+ memoryFormSource === "auto"
286
+ ? 0.75
287
+ : 1,
288
+ createdAt: existing?.createdAt ?? now,
289
+ updatedAt: now,
290
+ lastUsedAt: existing?.lastUsedAt ?? now,
291
+ };
292
+
293
+ await db.memories.put(entry);
294
+ resetMemoryForm();
295
+ };
296
+
297
+ const deleteMemoryEntry = async (entryId: string) => {
298
+ await db.memories.delete(entryId);
299
+ if (editingMemoryId === entryId) {
300
+ resetMemoryForm();
301
+ }
302
+ };
303
+
304
+ const clearAllMemories = async () => {
305
+ if (!window.confirm("Delete all saved memory entries?")) {
306
+ return;
307
+ }
308
+ await db.memories.clear();
309
+ resetMemoryForm();
310
+ };
311
+
312
+ const upsertConnectionFromForm = () => {
313
+ const normalizedKind: ConnectionKind =
314
+ connectionFormKind === "ollama" ? "ollama" : "openai_compatible";
315
+ const normalizedBaseUrl = normalizeBaseUrl(connectionFormBaseUrl);
316
+ const baseUrl =
317
+ normalizedKind === "ollama"
318
+ ? normalizedBaseUrl || "http://localhost:11434"
319
+ : normalizedBaseUrl;
320
+ if (normalizedKind === "openai_compatible" && !baseUrl) {
321
+ setConnectionStatus((current) => ({
322
+ ...current,
323
+ form: "Base URL is required for OpenAI-compatible connections.",
324
+ }));
325
+ return;
326
+ }
327
+ const now = Date.now();
328
+ const headers = parseHeadersText(connectionFormHeadersText);
329
+ const id = editingConnectionId || `conn-${now}`;
330
+ const nextConnection: ModelConnection = {
331
+ id,
332
+ name: connectionFormName.trim() || id,
333
+ kind: normalizedKind,
334
+ provider: undefined,
335
+ baseUrl,
336
+ apiKey: connectionFormApiKey.trim() || undefined,
337
+ headers,
338
+ supportsTools: connectionFormSupportsTools,
339
+ enabled: true,
340
+ createdAt: editingConnectionId
341
+ ? connections.find((connection) => connection.id === editingConnectionId)?.createdAt ?? now
342
+ : now,
343
+ updatedAt: now,
344
+ };
345
+ setConnections((current) => {
346
+ const existingIndex = current.findIndex((item) => item.id === id);
347
+ if (existingIndex === -1) {
348
+ return [...current, nextConnection];
349
+ }
350
+ const next = [...current];
351
+ next[existingIndex] = nextConnection;
352
+ return next;
353
+ });
354
+ setDefaultConnectionId((current) => current || id);
355
+ setConnectionStatus((current) => {
356
+ if (!current.form) {
357
+ return current;
358
+ }
359
+ const next = { ...current };
360
+ delete next.form;
361
+ return next;
362
+ });
363
+ resetConnectionForm();
364
+ };
365
+
366
+ const testConnection = async (connection: ModelConnection) => {
367
+ setConnectionStatusBusy((current) => ({ ...current, [connection.id]: true }));
368
+ setConnectionStatus((current) => ({ ...current, [connection.id]: "Testing..." }));
369
+ try {
370
+ const payload = makeConnectionPayload(connection, { openaiKey, anthropicKey, geminiKey });
371
+ const response = await fetch("/api/connections/test", {
372
+ method: "POST",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify({ connection: payload }),
375
+ });
376
+ const body = (await response.json()) as { ok?: boolean; message?: string; error?: string };
377
+ if (!response.ok || body.ok === false) {
378
+ throw new Error(body.error || "Connection test failed.");
379
+ }
380
+ setConnectionStatus((current) => ({ ...current, [connection.id]: body.message || "Connected." }));
381
+ } catch (error) {
382
+ const message = error instanceof Error ? error.message : "Connection test failed.";
383
+ setConnectionStatus((current) => ({ ...current, [connection.id]: message }));
384
+ } finally {
385
+ setConnectionStatusBusy((current) => ({ ...current, [connection.id]: false }));
386
+ }
387
+ };
388
+
389
+ const fetchModelsForConnection = async (connection: ModelConnection) => {
390
+ setConnectionStatusBusy((current) => ({ ...current, [connection.id]: true }));
391
+ setConnectionStatus((current) => ({ ...current, [connection.id]: "Fetching models..." }));
392
+ try {
393
+ const payload = makeConnectionPayload(connection, { openaiKey, anthropicKey, geminiKey });
394
+ const response = await fetch("/api/connections/models", {
395
+ method: "POST",
396
+ headers: { "Content-Type": "application/json" },
397
+ body: JSON.stringify({ connection: payload }),
398
+ });
399
+ const body = (await response.json()) as { ok?: boolean; models?: string[]; error?: string };
400
+ if (!response.ok || body.ok === false) {
401
+ throw new Error(body.error || "Could not fetch models.");
402
+ }
403
+ const models = Array.isArray(body.models) ? body.models.filter(Boolean) : [];
404
+ setFetchedModels((current) => ({ ...current, [connection.id]: models }));
405
+ setConnectionStatus((current) => ({
406
+ ...current,
407
+ [connection.id]: models.length > 0 ? `Fetched ${models.length} models.` : "No models returned.",
408
+ }));
409
+ if (models.length > 0) {
410
+ setDefaultModelByConnection((current) => ({
411
+ ...current,
412
+ [connection.id]: current[connection.id] || models[0],
413
+ }));
414
+ }
415
+ } catch (error) {
416
+ const message = error instanceof Error ? error.message : "Could not fetch models.";
417
+ setConnectionStatus((current) => ({ ...current, [connection.id]: message }));
418
+ } finally {
419
+ setConnectionStatusBusy((current) => ({ ...current, [connection.id]: false }));
420
+ }
421
+ };
422
+
423
+ const handleSave = async () => {
424
+ const allowedRoots = allowedRootsText
425
+ .split("\n")
426
+ .map((root) => root.trim())
427
+ .filter(Boolean);
428
+
429
+ const syncedConnections = connections.map((connection) => {
430
+ if (connection.kind !== "builtin") {
431
+ return connection;
432
+ }
433
+ if (connection.provider === "openai") {
434
+ return { ...connection, apiKey: openaiKey.trim() || undefined, updatedAt: Date.now() };
435
+ }
436
+ if (connection.provider === "anthropic") {
437
+ return { ...connection, apiKey: anthropicKey.trim() || undefined, updatedAt: Date.now() };
438
+ }
439
+ if (connection.provider === "google") {
440
+ return { ...connection, apiKey: geminiKey.trim() || undefined, updatedAt: Date.now() };
441
+ }
442
+ return connection;
443
+ });
444
+ const normalizedConnections = ensureBuiltinConnections(
445
+ syncedConnections,
446
+ {
447
+ openaiKey: openaiKey.trim() || undefined,
448
+ anthropicKey: anthropicKey.trim() || undefined,
449
+ geminiKey: geminiKey.trim() || undefined,
450
+ },
451
+ Date.now(),
452
+ );
453
+ const enabled = normalizedConnections.filter((connection) => connection.enabled);
454
+ const resolvedDefaultConnection =
455
+ (enabled.find((connection) => connection.id === defaultConnectionId)?.id ?? enabled[0]?.id) ||
456
+ normalizedConnections[0]?.id ||
457
+ BUILTIN_CONNECTION_IDS.openai;
458
+
459
+ const defaultConnection =
460
+ normalizedConnections.find((connection) => connection.id === resolvedDefaultConnection) ?? null;
461
+ const resolvedDefaultModelMap: Record<string, string> = {};
462
+ for (const connection of normalizedConnections) {
463
+ const model =
464
+ defaultModelByConnection[connection.id]?.trim() ||
465
+ getConnectionModelPresets(connection)[0] ||
466
+ "";
467
+ if (model) {
468
+ resolvedDefaultModelMap[connection.id] = model;
469
+ }
470
+ }
471
+ const legacyProvider =
472
+ defaultConnection?.kind === "builtin" && defaultConnection.provider
473
+ ? defaultConnection.provider
474
+ : "openai";
475
+ const legacyModel = resolvedDefaultModelMap[resolvedDefaultConnection] || settings.defaultModel || "gpt-5.2";
476
+
477
+ await db.settings.put({
478
+ id: "settings",
479
+ openaiKey: openaiKey.trim() || undefined,
480
+ anthropicKey: anthropicKey.trim() || undefined,
481
+ geminiKey: geminiKey.trim() || undefined,
482
+ defaultProvider: legacyProvider,
483
+ defaultModel: legacyModel,
484
+ connections: normalizedConnections,
485
+ defaultConnectionId: resolvedDefaultConnection,
486
+ defaultModelByConnection: resolvedDefaultModelMap,
487
+ showExtendedOpenAIModels,
488
+ enableWebSources,
489
+ accentColor: accentColor || "#66706e",
490
+ font,
491
+ theme,
492
+ localTools: {
493
+ enabled: toolsEnabled,
494
+ approvalMode,
495
+ safetyProfile,
496
+ allowedRoots:
497
+ allowedRoots.length > 0 ? allowedRoots : [...DEFAULT_LOCAL_TOOLS_SETTINGS.allowedRoots],
498
+ enableNotes,
499
+ enableApps,
500
+ enableNumbers,
501
+ enableWeb,
502
+ enableMusic,
503
+ enableCalendar,
504
+ enableMail,
505
+ enableWorkflow,
506
+ enableSystem,
507
+ webSearchBackend,
508
+ dryRun,
509
+ },
510
+ memory: {
511
+ enabled: memoryEnabled,
512
+ autoCapture: memoryAutoCapture,
513
+ toolInfluence: memoryToolInfluence,
514
+ extractionMode: "conservative",
515
+ },
516
+ });
517
+ onClose();
518
+ };
519
+
520
+ const accentPresets = [
521
+ { id: "default", label: "Default", color: "#66706e" },
522
+ { id: "blue", label: "Blue", color: "#2563eb" },
523
+ { id: "green", label: "Green", color: "#16a34a" },
524
+ { id: "yellow", label: "Yellow", color: "#f59e0b" },
525
+ { id: "pink", label: "Pink", color: "#ec4899" },
526
+ { id: "orange", label: "Orange", color: "#f97316" },
527
+ { id: "red", label: "Red", color: "#dc2626" },
528
+ ];
529
+
530
+ return (
531
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6">
532
+ <div className="w-full max-w-4xl rounded-2xl border border-[var(--border)] bg-[var(--panel)] p-6 shadow-[var(--shadow)]">
533
+ <div className="flex items-center justify-between">
534
+ <h2 className="text-lg font-semibold">Settings</h2>
535
+ <button
536
+ onClick={onClose}
537
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] p-2"
538
+ >
539
+ <X className="h-4 w-4" />
540
+ </button>
541
+ </div>
542
+
543
+ <div className="mt-4 flex flex-wrap gap-2 border-b border-[var(--border)] pb-3">
544
+ {TABS.map((tab) => (
545
+ <button
546
+ key={tab.id}
547
+ onClick={() => setActiveTab(tab.id)}
548
+ className={`rounded-full px-3 py-1.5 text-xs ${
549
+ activeTab === tab.id
550
+ ? "bg-[var(--accent)] text-white"
551
+ : "border border-[var(--border)] bg-[var(--panel-2)] text-[var(--text-secondary)]"
552
+ }`}
553
+ >
554
+ {tab.label}
555
+ </button>
556
+ ))}
557
+ </div>
558
+
559
+ <div className="mt-5 max-h-[68vh] overflow-y-auto pr-1 text-sm">
560
+ {activeTab === "models" ? (
561
+ <div className="space-y-4">
562
+ <label className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-3 text-sm text-[var(--text-secondary)]">
563
+ <input
564
+ type="checkbox"
565
+ checked={showExtendedOpenAIModels}
566
+ onChange={(event) => setShowExtendedOpenAIModels(event.target.checked)}
567
+ />
568
+ Show extended OpenAI model list (non-frontier models)
569
+ </label>
570
+ <div>
571
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
572
+ Default Connection
573
+ </label>
574
+ <select
575
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-sm"
576
+ value={defaultConnectionId}
577
+ onChange={(event) => setDefaultConnectionId(event.target.value)}
578
+ >
579
+ {enabledConnections.map((connection) => (
580
+ <option key={connection.id} value={connection.id}>
581
+ {connection.name}
582
+ </option>
583
+ ))}
584
+ </select>
585
+ </div>
586
+
587
+ {selectedDefaultConnection ? (
588
+ <div className="space-y-3 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
589
+ <div className="text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
590
+ Default model for {selectedDefaultConnection.name}
591
+ </div>
592
+ <input
593
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
594
+ value={modelInput || selectedDefaultModel}
595
+ onChange={(event) => {
596
+ const value = event.target.value;
597
+ setModelInput(value);
598
+ setDefaultModelByConnection((current) => ({
599
+ ...current,
600
+ [selectedDefaultConnection.id]: value,
601
+ }));
602
+ }}
603
+ placeholder="Enter model id"
604
+ />
605
+ <div className="flex flex-wrap gap-2">
606
+ {selectableModels.slice(0, 20).map((preset) => (
607
+ <button
608
+ key={preset}
609
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs text-[var(--text-secondary)]"
610
+ onClick={() => {
611
+ setModelInput(preset);
612
+ setDefaultModelByConnection((current) => ({
613
+ ...current,
614
+ [selectedDefaultConnection.id]: preset,
615
+ }));
616
+ }}
617
+ >
618
+ {preset}
619
+ </button>
620
+ ))}
621
+ {selectableModels.length === 0 ? (
622
+ <div className="text-xs text-[var(--text-muted)]">
623
+ No presets yet. Fetch models from Connections tab.
624
+ </div>
625
+ ) : null}
626
+ </div>
627
+ </div>
628
+ ) : null}
629
+ </div>
630
+ ) : null}
631
+
632
+ {activeTab === "keys" ? (
633
+ <div className="space-y-4">
634
+ {[
635
+ {
636
+ label: "OpenAI API Key",
637
+ value: openaiKey,
638
+ setValue: setOpenaiKey,
639
+ show: showOpenAIKey,
640
+ setShow: setShowOpenAIKey,
641
+ placeholder: "sk-...",
642
+ },
643
+ {
644
+ label: "Anthropic API Key",
645
+ value: anthropicKey,
646
+ setValue: setAnthropicKey,
647
+ show: showAnthropicKey,
648
+ setShow: setShowAnthropicKey,
649
+ placeholder: "sk-ant-...",
650
+ },
651
+ {
652
+ label: "Google Gemini API Key",
653
+ value: geminiKey,
654
+ setValue: setGeminiKey,
655
+ show: showGeminiKey,
656
+ setShow: setShowGeminiKey,
657
+ placeholder: "AIza...",
658
+ },
659
+ ].map((item) => (
660
+ <div key={item.label}>
661
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
662
+ {item.label}
663
+ </label>
664
+ <div className="relative">
665
+ <input
666
+ type={item.show ? "text" : "password"}
667
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 pr-10 text-sm"
668
+ value={item.value}
669
+ onChange={(event) => item.setValue(event.target.value)}
670
+ placeholder={item.placeholder}
671
+ />
672
+ <button
673
+ type="button"
674
+ onClick={() => item.setShow((prev: boolean) => !prev)}
675
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
676
+ >
677
+ {item.show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
678
+ </button>
679
+ </div>
680
+ </div>
681
+ ))}
682
+ </div>
683
+ ) : null}
684
+
685
+ {activeTab === "connections" ? (
686
+ <div className="space-y-4">
687
+ <div className="space-y-2">
688
+ {connections.map((connection) => (
689
+ <div
690
+ key={connection.id}
691
+ className="rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-3"
692
+ >
693
+ <div className="flex flex-wrap items-center justify-between gap-2">
694
+ <div>
695
+ <div className="text-sm font-medium text-[var(--text-primary)]">{connection.name}</div>
696
+ <div className="text-xs text-[var(--text-muted)]">
697
+ {connection.kind === "builtin"
698
+ ? `Built-in: ${connection.provider}`
699
+ : connection.kind === "ollama"
700
+ ? `Ollama (${connection.baseUrl || "http://localhost:11434"})`
701
+ : `OpenAI-compatible (${connection.baseUrl || "No URL"})`}
702
+ </div>
703
+ </div>
704
+ <div className="flex flex-wrap gap-2">
705
+ {connection.kind !== "builtin" ? (
706
+ <button
707
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs"
708
+ onClick={() => {
709
+ setEditingConnectionId(connection.id);
710
+ setConnectionFormName(connection.name);
711
+ setConnectionFormKind(connection.kind);
712
+ setConnectionFormBaseUrl(connection.baseUrl || "");
713
+ setConnectionFormApiKey(connection.apiKey || "");
714
+ setConnectionFormSupportsTools(connection.supportsTools ?? true);
715
+ setConnectionFormHeadersText(formatHeadersText(connection.headers));
716
+ }}
717
+ >
718
+ Edit
719
+ </button>
720
+ ) : null}
721
+ <button
722
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs"
723
+ onClick={() => testConnection(connection)}
724
+ disabled={Boolean(connectionStatusBusy[connection.id])}
725
+ >
726
+ Test
727
+ </button>
728
+ <button
729
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs"
730
+ onClick={() => fetchModelsForConnection(connection)}
731
+ disabled={Boolean(connectionStatusBusy[connection.id])}
732
+ >
733
+ Fetch Models
734
+ </button>
735
+ {connection.kind !== "builtin" ? (
736
+ <button
737
+ className="rounded-full border border-[var(--danger)]/40 bg-[var(--panel)] px-3 py-1 text-xs text-[var(--danger)]"
738
+ onClick={() =>
739
+ setConnections((current) =>
740
+ current.filter((item) => item.id !== connection.id),
741
+ )
742
+ }
743
+ >
744
+ Remove
745
+ </button>
746
+ ) : null}
747
+ </div>
748
+ </div>
749
+ <div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-[var(--text-secondary)]">
750
+ <label className="flex items-center gap-2">
751
+ <input
752
+ type="checkbox"
753
+ checked={connection.enabled}
754
+ onChange={(event) =>
755
+ setConnections((current) =>
756
+ current.map((item) =>
757
+ item.id === connection.id
758
+ ? { ...item, enabled: event.target.checked, updatedAt: Date.now() }
759
+ : item,
760
+ ),
761
+ )
762
+ }
763
+ />
764
+ Enabled
765
+ </label>
766
+ <span>
767
+ Tools: {(connection.supportsTools ?? true) ? "supported" : "disabled"}
768
+ </span>
769
+ {connection.kind === "builtin" ? (
770
+ <span>Keys configured in API Keys tab</span>
771
+ ) : null}
772
+ {connectionStatus[connection.id] ? <span>{connectionStatus[connection.id]}</span> : null}
773
+ </div>
774
+ </div>
775
+ ))}
776
+ </div>
777
+
778
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
779
+ <div className="mb-3 text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
780
+ {editingConnectionId ? "Edit Connection" : "Add Connection"}
781
+ </div>
782
+ <div className="grid gap-3 md:grid-cols-2">
783
+ <input
784
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
785
+ placeholder="Connection name"
786
+ value={connectionFormName}
787
+ onChange={(event) => setConnectionFormName(event.target.value)}
788
+ />
789
+ <select
790
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
791
+ value={connectionFormKind}
792
+ onChange={(event) => setConnectionFormKind(event.target.value as ConnectionKind)}
793
+ >
794
+ <option value="openai_compatible">OpenAI-compatible</option>
795
+ <option value="ollama">Ollama</option>
796
+ </select>
797
+ <input
798
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
799
+ placeholder="Base URL (e.g. http://localhost:11434)"
800
+ value={connectionFormBaseUrl}
801
+ onChange={(event) => setConnectionFormBaseUrl(event.target.value)}
802
+ />
803
+ <input
804
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
805
+ placeholder="Optional API key override"
806
+ value={connectionFormApiKey}
807
+ onChange={(event) => setConnectionFormApiKey(event.target.value)}
808
+ />
809
+ <textarea
810
+ className="h-20 rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-xs md:col-span-2"
811
+ placeholder="Optional headers (one per line):&#10;Authorization: Bearer ...&#10;X-API-Key: ..."
812
+ value={connectionFormHeadersText}
813
+ onChange={(event) => setConnectionFormHeadersText(event.target.value)}
814
+ />
815
+ </div>
816
+ {connectionStatus.form ? (
817
+ <div className="mt-2 text-xs text-[var(--danger)]">{connectionStatus.form}</div>
818
+ ) : null}
819
+ <div className="mt-3 flex flex-wrap items-center gap-3">
820
+ <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
821
+ <input
822
+ type="checkbox"
823
+ checked={connectionFormSupportsTools}
824
+ onChange={(event) => setConnectionFormSupportsTools(event.target.checked)}
825
+ />
826
+ Supports tool calls
827
+ </label>
828
+ <button
829
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs"
830
+ onClick={() => {
831
+ setConnectionFormKind("openai_compatible");
832
+ setConnectionFormName("Local OpenAI-compatible");
833
+ setConnectionFormBaseUrl("http://localhost:1234/v1");
834
+ }}
835
+ >
836
+ LM Studio / vLLM preset
837
+ </button>
838
+ <button
839
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-3 py-1 text-xs"
840
+ onClick={() => {
841
+ setConnectionFormKind("ollama");
842
+ setConnectionFormName("Ollama Local");
843
+ setConnectionFormBaseUrl("http://localhost:11434");
844
+ setConnectionFormSupportsTools(false);
845
+ }}
846
+ >
847
+ Ollama preset
848
+ </button>
849
+ </div>
850
+ <div className="mt-3 flex gap-2">
851
+ <button
852
+ className="rounded-full bg-[var(--accent)] px-4 py-2 text-xs text-white"
853
+ onClick={upsertConnectionFromForm}
854
+ >
855
+ {editingConnectionId ? "Update Connection" : "Add Connection"}
856
+ </button>
857
+ <button
858
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-4 py-2 text-xs"
859
+ onClick={resetConnectionForm}
860
+ >
861
+ Clear
862
+ </button>
863
+ </div>
864
+ </div>
865
+ </div>
866
+ ) : null}
867
+
868
+ {activeTab === "local-tools" ? (
869
+ <div className="space-y-4 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
870
+ <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
871
+ <input
872
+ type="checkbox"
873
+ checked={toolsEnabled}
874
+ onChange={(event) => setToolsEnabled(event.target.checked)}
875
+ />
876
+ Enable local computer tools
877
+ </label>
878
+ <div className="grid gap-3 md:grid-cols-2">
879
+ <div>
880
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
881
+ Safety Profile
882
+ </label>
883
+ <select
884
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
885
+ value={safetyProfile}
886
+ onChange={(event) => setSafetyProfile(event.target.value as SafetyProfile)}
887
+ >
888
+ <option value="strict">Strict</option>
889
+ <option value="balanced">Balanced</option>
890
+ <option value="auto">Auto</option>
891
+ </select>
892
+ </div>
893
+ <div>
894
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
895
+ Approval Mode
896
+ </label>
897
+ <select
898
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
899
+ value={approvalMode}
900
+ onChange={(event) => setApprovalMode(event.target.value as ApprovalMode)}
901
+ >
902
+ <option value="always_confirm_writes">Always confirm writes</option>
903
+ <option value="confirm_risky_only">Confirm risky only</option>
904
+ <option value="trusted_auto">Trusted auto</option>
905
+ </select>
906
+ </div>
907
+ </div>
908
+ <div>
909
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
910
+ Allowed Roots (one per line)
911
+ </label>
912
+ <textarea
913
+ className="h-24 w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-xs"
914
+ value={allowedRootsText}
915
+ onChange={(event) => setAllowedRootsText(event.target.value)}
916
+ placeholder="/Users/evanalexander/zenith&#10;~/Desktop"
917
+ />
918
+ </div>
919
+ <div className="grid gap-2 md:grid-cols-2">
920
+ {[
921
+ { label: "Enable Apple Notes tools", checked: enableNotes, setter: setEnableNotes },
922
+ { label: "Enable app open/focus tools", checked: enableApps, setter: setEnableApps },
923
+ { label: "Enable Numbers tools", checked: enableNumbers, setter: setEnableNumbers },
924
+ { label: "Enable web search/open tools", checked: enableWeb, setter: setEnableWeb },
925
+ { label: "Enable music controls", checked: enableMusic, setter: setEnableMusic },
926
+ { label: "Enable calendar/reminders tools", checked: enableCalendar, setter: setEnableCalendar },
927
+ { label: "Enable mail/messages tools", checked: enableMail, setter: setEnableMail },
928
+ { label: "Enable workflow tool", checked: enableWorkflow, setter: setEnableWorkflow },
929
+ { label: "Enable system controls", checked: enableSystem, setter: setEnableSystem },
930
+ { label: "Dry-run only (no writes)", checked: dryRun, setter: setDryRun },
931
+ ].map((item) => (
932
+ <label key={item.label} className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
933
+ <input
934
+ type="checkbox"
935
+ checked={item.checked}
936
+ onChange={(event) => item.setter(event.target.checked)}
937
+ />
938
+ {item.label}
939
+ </label>
940
+ ))}
941
+ </div>
942
+ <div>
943
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
944
+ Web Search Backend
945
+ </label>
946
+ <select
947
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
948
+ value={webSearchBackend}
949
+ onChange={(event) => setWebSearchBackend(event.target.value as WebSearchBackend)}
950
+ >
951
+ <option value="no_key">No-key (default)</option>
952
+ <option value="hybrid">Hybrid</option>
953
+ </select>
954
+ </div>
955
+ </div>
956
+ ) : null}
957
+
958
+ {activeTab === "memory" ? (
959
+ <div className="space-y-4">
960
+ <div className="space-y-3 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
961
+ <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
962
+ <input
963
+ type="checkbox"
964
+ checked={memoryEnabled}
965
+ onChange={(event) => setMemoryEnabled(event.target.checked)}
966
+ />
967
+ Enable memory personalization
968
+ </label>
969
+ <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
970
+ <input
971
+ type="checkbox"
972
+ checked={memoryAutoCapture}
973
+ onChange={(event) => setMemoryAutoCapture(event.target.checked)}
974
+ disabled={!memoryEnabled}
975
+ />
976
+ Auto-capture stable memories
977
+ </label>
978
+ <label className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
979
+ <input
980
+ type="checkbox"
981
+ checked={memoryToolInfluence}
982
+ onChange={(event) => setMemoryToolInfluence(event.target.checked)}
983
+ disabled={!memoryEnabled}
984
+ />
985
+ Allow memory to influence tool planning and fast-path aliases
986
+ </label>
987
+ <div className="text-xs text-[var(--text-muted)]">
988
+ Extraction mode: <span className="font-medium text-[var(--text-primary)]">Conservative</span>
989
+ </div>
990
+ </div>
991
+
992
+ <div className="space-y-3 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
993
+ <div className="flex items-center justify-between gap-3">
994
+ <div className="text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
995
+ Saved Memory
996
+ </div>
997
+ <button
998
+ className="rounded-full border border-[var(--danger)]/40 bg-[var(--panel)] px-3 py-1 text-xs text-[var(--danger)]"
999
+ onClick={clearAllMemories}
1000
+ disabled={(memories?.length ?? 0) === 0}
1001
+ >
1002
+ Clear all
1003
+ </button>
1004
+ </div>
1005
+ <input
1006
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
1007
+ placeholder="Search memory entries"
1008
+ value={memorySearch}
1009
+ onChange={(event) => setMemorySearch(event.target.value)}
1010
+ />
1011
+ <div className="max-h-64 space-y-2 overflow-y-auto pr-1">
1012
+ {filteredMemories.map((entry) => (
1013
+ <div
1014
+ key={entry.id}
1015
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2"
1016
+ >
1017
+ <div className="flex items-start justify-between gap-3">
1018
+ <div>
1019
+ <div className="text-sm font-medium text-[var(--text-primary)]">{entry.key}</div>
1020
+ <div className="mt-0.5 text-xs text-[var(--text-secondary)]">{entry.value}</div>
1021
+ <div className="mt-1 text-[11px] text-[var(--text-muted)]">
1022
+ {entry.kind} | {entry.scope}
1023
+ {entry.scope === "conversation" && entry.conversationId
1024
+ ? ` | ${entry.conversationId}`
1025
+ : ""}
1026
+ {` | ${entry.source}`}
1027
+ </div>
1028
+ </div>
1029
+ <div className="flex shrink-0 gap-2">
1030
+ <button
1031
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-xs"
1032
+ onClick={() => loadMemoryIntoForm(entry)}
1033
+ >
1034
+ Edit
1035
+ </button>
1036
+ <button
1037
+ className="rounded-full border border-[var(--danger)]/40 bg-[var(--panel-2)] px-3 py-1 text-xs text-[var(--danger)]"
1038
+ onClick={() => {
1039
+ void deleteMemoryEntry(entry.id);
1040
+ }}
1041
+ >
1042
+ Delete
1043
+ </button>
1044
+ </div>
1045
+ </div>
1046
+ </div>
1047
+ ))}
1048
+ {filteredMemories.length === 0 ? (
1049
+ <div className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-4 text-xs text-[var(--text-muted)]">
1050
+ No memory entries found.
1051
+ </div>
1052
+ ) : null}
1053
+ </div>
1054
+ </div>
1055
+
1056
+ <div className="space-y-3 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4">
1057
+ <div className="text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1058
+ {editingMemoryId ? "Edit Memory Entry" : "Add Memory Entry"}
1059
+ </div>
1060
+ <div className="grid gap-3 md:grid-cols-2">
1061
+ <div>
1062
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1063
+ Kind
1064
+ </label>
1065
+ <select
1066
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
1067
+ value={memoryFormKind}
1068
+ onChange={(event) => setMemoryFormKind(event.target.value as MemoryKind)}
1069
+ >
1070
+ <option value="profile">Profile</option>
1071
+ <option value="preference">Preference</option>
1072
+ <option value="person_alias">Person alias</option>
1073
+ <option value="music_alias">Music alias</option>
1074
+ <option value="note">Note</option>
1075
+ </select>
1076
+ </div>
1077
+ <div>
1078
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1079
+ Scope
1080
+ </label>
1081
+ <select
1082
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
1083
+ value={memoryFormScope}
1084
+ onChange={(event) => setMemoryFormScope(event.target.value as MemoryScope)}
1085
+ >
1086
+ <option value="global">Global</option>
1087
+ <option value="conversation">Conversation</option>
1088
+ </select>
1089
+ </div>
1090
+ {memoryFormScope === "conversation" ? (
1091
+ <input
1092
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
1093
+ placeholder="Conversation ID"
1094
+ value={memoryFormConversationId}
1095
+ onChange={(event) => setMemoryFormConversationId(event.target.value)}
1096
+ />
1097
+ ) : null}
1098
+ <div>
1099
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1100
+ Source
1101
+ </label>
1102
+ <select
1103
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm"
1104
+ value={memoryFormSource}
1105
+ onChange={(event) => setMemoryFormSource(event.target.value as MemorySource)}
1106
+ >
1107
+ <option value="manual">Manual</option>
1108
+ <option value="explicit">Explicit</option>
1109
+ <option value="auto">Auto</option>
1110
+ </select>
1111
+ </div>
1112
+ <input
1113
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
1114
+ placeholder="Memory key"
1115
+ value={memoryFormKey}
1116
+ onChange={(event) => setMemoryFormKey(event.target.value)}
1117
+ />
1118
+ <textarea
1119
+ className="h-20 rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-sm md:col-span-2"
1120
+ placeholder="Memory value"
1121
+ value={memoryFormValue}
1122
+ onChange={(event) => setMemoryFormValue(event.target.value)}
1123
+ />
1124
+ </div>
1125
+ {memoryStatus ? (
1126
+ <div className="text-xs text-[var(--danger)]">{memoryStatus}</div>
1127
+ ) : null}
1128
+ <div className="flex gap-2">
1129
+ <button
1130
+ className="rounded-full bg-[var(--accent)] px-4 py-2 text-xs text-white"
1131
+ onClick={() => {
1132
+ void upsertMemoryEntry();
1133
+ }}
1134
+ >
1135
+ {editingMemoryId ? "Update Memory" : "Add Memory"}
1136
+ </button>
1137
+ <button
1138
+ className="rounded-full border border-[var(--border)] bg-[var(--panel)] px-4 py-2 text-xs"
1139
+ onClick={resetMemoryForm}
1140
+ >
1141
+ Clear
1142
+ </button>
1143
+ </div>
1144
+ </div>
1145
+ </div>
1146
+ ) : null}
1147
+
1148
+ {activeTab === "appearance" ? (
1149
+ <div className="space-y-4">
1150
+ <div>
1151
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1152
+ Theme
1153
+ </label>
1154
+ <select
1155
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-sm"
1156
+ value={theme}
1157
+ onChange={(event) => setTheme(event.target.value as "dark" | "light")}
1158
+ >
1159
+ <option value="dark">Dark</option>
1160
+ <option value="light">Light</option>
1161
+ </select>
1162
+ </div>
1163
+ <div>
1164
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1165
+ Font
1166
+ </label>
1167
+ <select
1168
+ className="w-full rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-sm"
1169
+ value={font}
1170
+ onChange={(event) =>
1171
+ setFont(event.target.value as "ibm" | "manrope" | "sora" | "space" | "poppins")
1172
+ }
1173
+ >
1174
+ <option value="ibm">IBM Plex Sans (Default)</option>
1175
+ <option value="manrope">Manrope</option>
1176
+ <option value="poppins">Poppins</option>
1177
+ <option value="sora">Sora</option>
1178
+ <option value="space">Space Grotesk</option>
1179
+ </select>
1180
+ </div>
1181
+ <div>
1182
+ <label className="mb-2 block text-xs uppercase tracking-[0.2em] text-[var(--text-muted)]">
1183
+ Accent Color
1184
+ </label>
1185
+ <div className="grid grid-cols-4 gap-2">
1186
+ {accentPresets.map((preset) => (
1187
+ <button
1188
+ key={preset.id}
1189
+ className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs ${
1190
+ accentColor === preset.color
1191
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
1192
+ : "border-[var(--border)] text-[var(--text-secondary)]"
1193
+ }`}
1194
+ onClick={() => setAccentColor(preset.color)}
1195
+ >
1196
+ <span className="h-3 w-3 rounded-full" style={{ background: preset.color }} />
1197
+ {preset.label}
1198
+ </button>
1199
+ ))}
1200
+ </div>
1201
+ </div>
1202
+ </div>
1203
+ ) : null}
1204
+
1205
+ {activeTab === "advanced" ? (
1206
+ <div className="space-y-4">
1207
+ <label className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4 text-sm text-[var(--text-secondary)]">
1208
+ <input
1209
+ type="checkbox"
1210
+ checked={enableWebSources}
1211
+ onChange={(event) => setEnableWebSources(event.target.checked)}
1212
+ />
1213
+ Enable OpenAI web sources (adds citations when web search is used)
1214
+ </label>
1215
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--panel-2)] p-4 text-sm text-[var(--text-secondary)]">
1216
+ <div className="font-medium text-[var(--text-primary)]">Diagnostics</div>
1217
+ <div className="mt-2 text-xs">
1218
+ Connections: {connections.length} total, {enabledConnections.length} enabled
1219
+ </div>
1220
+ <div className="mt-1 text-xs">Default connection: {defaultConnectionId || "(none)"}</div>
1221
+ </div>
1222
+ <button
1223
+ className="inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-4 py-2 text-xs text-[var(--text-secondary)]"
1224
+ onClick={() => {
1225
+ const now = Date.now();
1226
+ setConnections(
1227
+ ensureBuiltinConnections([], { openaiKey, anthropicKey, geminiKey }, now),
1228
+ );
1229
+ setDefaultConnectionId(BUILTIN_CONNECTION_IDS.openai);
1230
+ setDefaultModelByConnection({});
1231
+ }}
1232
+ >
1233
+ <RefreshCw className="h-3.5 w-3.5" />
1234
+ Reset Connection Defaults
1235
+ </button>
1236
+ </div>
1237
+ ) : null}
1238
+ </div>
1239
+
1240
+ <div className="mt-6 flex justify-end gap-2">
1241
+ <button
1242
+ onClick={onClose}
1243
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-4 py-2 text-xs"
1244
+ >
1245
+ Cancel
1246
+ </button>
1247
+ <button
1248
+ className="rounded-full bg-[var(--accent)] px-4 py-2 text-xs text-white"
1249
+ onClick={handleSave}
1250
+ >
1251
+ Save
1252
+ </button>
1253
+ </div>
1254
+ </div>
1255
+ </div>
1256
+ );
1257
+ }