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,153 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import {
5
+ Folder,
6
+ PanelLeftClose,
7
+ PenSquare,
8
+ Search,
9
+ Trash2,
10
+ } from "lucide-react";
11
+ import type { Thread } from "../lib/types";
12
+
13
+ export default function Sidebar({
14
+ threads,
15
+ activeThreadId,
16
+ collapsed,
17
+ onSelect,
18
+ onNewChat,
19
+ onDeleteThread,
20
+ onToggleCollapse,
21
+ onOpenSearch,
22
+ }: {
23
+ threads: Thread[];
24
+ activeThreadId: string | null;
25
+ collapsed: boolean;
26
+ onSelect: (id: string) => void;
27
+ onNewChat: () => void;
28
+ onDeleteThread: (thread: Thread) => void;
29
+ onToggleCollapse: () => void;
30
+ onOpenSearch: () => void;
31
+ }) {
32
+ const groups = useMemo(() => {
33
+ const map = new Map<string, Thread[]>();
34
+ threads.forEach((thread) => {
35
+ const list = map.get(thread.conversationId) || [];
36
+ list.push(thread);
37
+ map.set(thread.conversationId, list);
38
+ });
39
+
40
+ const grouped = Array.from(map.values()).map((list) => {
41
+ const sorted = [...list].sort((a, b) => b.updatedAt - a.updatedAt);
42
+ const root =
43
+ sorted.find((item) => !item.forkedFromMessageId) || sorted[0];
44
+ return { root };
45
+ });
46
+
47
+ grouped.sort((a, b) => b.root.updatedAt - a.root.updatedAt);
48
+ return grouped;
49
+ }, [threads]);
50
+
51
+ return (
52
+ <aside className={`sidebar flex flex-col h-full ${collapsed ? "collapsed" : ""}`}>
53
+ {!collapsed ? (
54
+ <div className="flex items-center justify-between px-4 py-4">
55
+ <button
56
+ className="flex items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--panel-2)] px-3 py-2 text-sm text-[var(--text-primary)] hover:border-[var(--border-strong)] transition"
57
+ onClick={onNewChat}
58
+ >
59
+ <PenSquare className="h-4 w-4 sidebar-text" />
60
+ <span className="sidebar-text">New chat</span>
61
+ </button>
62
+ {!collapsed ? (
63
+ <button
64
+ className="rounded-lg border border-[var(--border)] bg-[var(--panel-2)] p-2 text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
65
+ onClick={onToggleCollapse}
66
+ >
67
+ <PanelLeftClose className="h-4 w-4" />
68
+ </button>
69
+ ) : null}
70
+ </div>
71
+ ) : null}
72
+
73
+ {!collapsed ? (
74
+ <>
75
+ <div className="px-4 pb-2">
76
+ <button
77
+ className="flex w-full items-center gap-2 rounded-lg border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-xs text-[var(--text-muted)] hover:border-[var(--border-strong)]"
78
+ onClick={onOpenSearch}
79
+ >
80
+ <Search className="h-4 w-4" />
81
+ <span className="sidebar-text">Search chats</span>
82
+ </button>
83
+ </div>
84
+
85
+ <div className="flex-1 overflow-y-auto px-2">
86
+ <div className="sidebar-text px-2 py-2 text-[11px] uppercase tracking-[0.18em] text-[var(--text-muted)]">
87
+ Your chats
88
+ </div>
89
+ <div className="space-y-2">
90
+ {groups.map(({ root }) => (
91
+ <div key={root.id} className="space-y-1">
92
+ <div
93
+ className={`group flex h-11 items-center justify-between rounded-lg px-3 text-sm transition ${
94
+ activeThreadId === root.id
95
+ ? "bg-[var(--panel-2)] text-[var(--text-primary)]"
96
+ : "text-[var(--text-secondary)] hover:bg-[var(--panel)]"
97
+ }`}
98
+ >
99
+ <button
100
+ onClick={() => onSelect(root.id)}
101
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
102
+ >
103
+ <Folder className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
104
+ <div className="sidebar-text min-w-0 truncate">
105
+ {root.title || "Main chat"}
106
+ </div>
107
+ </button>
108
+ <button
109
+ onClick={() => onDeleteThread(root)}
110
+ className="ml-2 rounded-full border border-transparent p-1 text-[var(--text-muted)] opacity-0 transition hover:border-[var(--border)] hover:text-[var(--danger)] group-hover:opacity-100"
111
+ >
112
+ <Trash2 className="h-3 w-3" />
113
+ </button>
114
+ </div>
115
+ </div>
116
+ ))}
117
+ </div>
118
+ </div>
119
+
120
+ <div className="border-t border-[var(--border)] px-4 py-4 text-xs text-[var(--text-muted)]">
121
+ <span className="sidebar-text">Local mode</span>
122
+ </div>
123
+ </>
124
+ ) : (
125
+ <div className="flex-1 px-2 pb-4 pt-4">
126
+ <div className="sidebar-collapsed-stack">
127
+ <button
128
+ className="sidebar-icon-button"
129
+ onClick={onToggleCollapse}
130
+ title="Expand sidebar"
131
+ >
132
+ <PanelLeftClose className="h-5 w-5 rotate-180" />
133
+ </button>
134
+ <button
135
+ className="sidebar-icon-button"
136
+ onClick={onNewChat}
137
+ title="New chat"
138
+ >
139
+ <PenSquare className="h-5 w-5" />
140
+ </button>
141
+ <button
142
+ className="sidebar-icon-button"
143
+ title="Search chats"
144
+ onClick={onOpenSearch}
145
+ >
146
+ <Search className="h-5 w-5" />
147
+ </button>
148
+ </div>
149
+ </div>
150
+ )}
151
+ </aside>
152
+ );
153
+ }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { ChevronDown, MessageSquare, Settings, Waypoints } from "lucide-react";
5
+ import type { ModelConnection } from "../lib/types";
6
+ import { getModelDisplayLabel } from "../lib/model-presets";
7
+
8
+ export default function TopBar({
9
+ connectionId,
10
+ connectionName,
11
+ connection,
12
+ connections,
13
+ model,
14
+ localToolsEnabled,
15
+ onConnectionChange,
16
+ onModelChange,
17
+ viewMode,
18
+ onToggleView,
19
+ onEnableLocalTools,
20
+ onOpenSettings,
21
+ modelPresets,
22
+ }: {
23
+ connectionId: string;
24
+ connectionName: string;
25
+ connection: ModelConnection | null;
26
+ connections: ModelConnection[];
27
+ model: string;
28
+ localToolsEnabled: boolean;
29
+ onConnectionChange: (connectionId: string) => void;
30
+ onModelChange: (model: string) => void;
31
+ viewMode: "chat" | "map";
32
+ onToggleView: () => void;
33
+ onEnableLocalTools: () => void | Promise<void>;
34
+ onOpenSettings: () => void;
35
+ modelPresets: string[];
36
+ }) {
37
+ const effectiveModelOptions = modelPresets;
38
+ const selectedModelLabel = getModelDisplayLabel(model, connection);
39
+ const [connectionMenuOpen, setConnectionMenuOpen] = useState(false);
40
+ const [modelMenuOpen, setModelMenuOpen] = useState(false);
41
+ const connectionMenuRef = useRef<HTMLDivElement | null>(null);
42
+ const modelMenuRef = useRef<HTMLDivElement | null>(null);
43
+
44
+ useEffect(() => {
45
+ if (!connectionMenuOpen && !modelMenuOpen) {
46
+ return;
47
+ }
48
+ const handlePointerDown = (event: MouseEvent) => {
49
+ if (!connectionMenuRef.current?.contains(event.target as Node)) {
50
+ setConnectionMenuOpen(false);
51
+ }
52
+ if (!modelMenuRef.current?.contains(event.target as Node)) {
53
+ setModelMenuOpen(false);
54
+ }
55
+ };
56
+ document.addEventListener("mousedown", handlePointerDown);
57
+ return () => document.removeEventListener("mousedown", handlePointerDown);
58
+ }, [connectionMenuOpen, modelMenuOpen]);
59
+
60
+ return (
61
+ <div className="topbar flex flex-wrap items-start justify-between gap-2 px-3 py-3 sm:items-center sm:px-4 sm:py-4">
62
+ <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
63
+ <div className="flex min-w-0 max-w-full flex-wrap items-center gap-2 rounded-2xl border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm text-[var(--text-secondary)] sm:rounded-full sm:px-4">
64
+ <div className="relative" ref={connectionMenuRef}>
65
+ <button
66
+ type="button"
67
+ onClick={() => setConnectionMenuOpen((current) => !current)}
68
+ className="inline-flex max-w-[42vw] items-center gap-2 rounded-md px-1.5 py-1 text-left sm:max-w-[240px] sm:px-2"
69
+ aria-haspopup="listbox"
70
+ aria-expanded={connectionMenuOpen}
71
+ aria-label="Model provider"
72
+ >
73
+ <span className="truncate">{connectionName}</span>
74
+ <ChevronDown className={`h-4 w-4 text-[var(--text-muted)] ${connectionMenuOpen ? "rotate-180" : ""}`} />
75
+ </button>
76
+ {connectionMenuOpen ? (
77
+ <div className="model-menu top-full mt-2 max-h-80 overflow-y-auto">
78
+ {connections.map((connection) => (
79
+ <button
80
+ key={connection.id}
81
+ className={`model-menu-item ${connection.id === connectionId ? "active" : ""}`}
82
+ onClick={() => {
83
+ onConnectionChange(connection.id);
84
+ setConnectionMenuOpen(false);
85
+ }}
86
+ role="option"
87
+ aria-selected={connection.id === connectionId}
88
+ >
89
+ {connection.name}
90
+ </button>
91
+ ))}
92
+ </div>
93
+ ) : null}
94
+ </div>
95
+ <span className="text-[var(--text-muted)]">|</span>
96
+ <div className="relative" ref={modelMenuRef}>
97
+ <button
98
+ type="button"
99
+ onClick={() => setModelMenuOpen((current) => !current)}
100
+ className="inline-flex max-w-[44vw] items-center gap-2 rounded-md px-1.5 py-1 text-left text-[var(--text-primary)] sm:max-w-[280px] sm:px-2"
101
+ aria-haspopup="listbox"
102
+ aria-expanded={modelMenuOpen}
103
+ aria-label={`${connectionName} model`}
104
+ >
105
+ <span className="truncate">{selectedModelLabel}</span>
106
+ <ChevronDown className={`h-4 w-4 text-[var(--text-muted)] ${modelMenuOpen ? "rotate-180" : ""}`} />
107
+ </button>
108
+ {modelMenuOpen ? (
109
+ <div className="model-menu top-full mt-2 max-h-80 overflow-y-auto">
110
+ {effectiveModelOptions.map((preset) => (
111
+ <button
112
+ key={preset}
113
+ className={`model-menu-item ${preset === model ? "active" : ""}`}
114
+ onClick={() => {
115
+ onModelChange(preset);
116
+ setModelMenuOpen(false);
117
+ }}
118
+ role="option"
119
+ aria-selected={preset === model}
120
+ >
121
+ {getModelDisplayLabel(preset, connection)}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ ) : null}
126
+ </div>
127
+ </div>
128
+ </div>
129
+ <div className="flex shrink-0 items-center gap-2 self-start sm:self-auto">
130
+ {!localToolsEnabled ? (
131
+ <button
132
+ className="rounded-full border border-emerald-400/40 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200 hover:border-emerald-300/60 sm:px-4 sm:text-sm"
133
+ onClick={onEnableLocalTools}
134
+ title="Enable local tools so chat can perform file/app/notes actions."
135
+ >
136
+ Enable Local Tools
137
+ </button>
138
+ ) : null}
139
+ <button
140
+ className="flex items-center gap-1.5 rounded-full border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-xs text-[var(--text-secondary)] hover:border-[var(--border-strong)] sm:gap-2 sm:px-4 sm:text-sm"
141
+ onClick={onToggleView}
142
+ >
143
+ {viewMode === "chat" ? (
144
+ <>
145
+ <Waypoints className="h-5 w-5" />
146
+ <span className="hidden sm:inline">Map</span>
147
+ </>
148
+ ) : (
149
+ <>
150
+ <MessageSquare className="h-5 w-5" />
151
+ <span className="hidden sm:inline">Chat</span>
152
+ </>
153
+ )}
154
+ </button>
155
+ <button
156
+ className="rounded-full border border-[var(--border)] bg-[var(--bg)] p-2.5 text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
157
+ onClick={onOpenSettings}
158
+ >
159
+ <Settings className="h-5 w-5" />
160
+ </button>
161
+ </div>
162
+ </div>
163
+ );
164
+ }
@@ -0,0 +1,275 @@
1
+ import type {
2
+ BuiltInProvider,
3
+ ChatConnectionPayload,
4
+ ConnectionHeader,
5
+ ModelConnection,
6
+ Settings,
7
+ } from "./types";
8
+
9
+ export const BUILTIN_CONNECTION_IDS = {
10
+ openai: "builtin-openai",
11
+ anthropic: "builtin-anthropic",
12
+ google: "builtin-google",
13
+ } as const;
14
+
15
+ export const GEMINI_OPENAI_BASE_URL =
16
+ "https://generativelanguage.googleapis.com/v1beta/openai";
17
+
18
+ const DEFAULT_MODEL_BY_PROVIDER: Record<BuiltInProvider, string> = {
19
+ openai: "gpt-5.2",
20
+ anthropic: "claude-sonnet-4-5",
21
+ google: "gemini-2.5-flash",
22
+ };
23
+
24
+ function builtinProviderName(provider: BuiltInProvider): string {
25
+ if (provider === "openai") {
26
+ return "OpenAI";
27
+ }
28
+ if (provider === "anthropic") {
29
+ return "Anthropic";
30
+ }
31
+ return "Google";
32
+ }
33
+
34
+ function trimHeaders(headers: ConnectionHeader[] | undefined): ConnectionHeader[] {
35
+ if (!Array.isArray(headers)) {
36
+ return [];
37
+ }
38
+ return headers
39
+ .filter((header) => header && typeof header === "object")
40
+ .map((header) => ({
41
+ key: typeof header.key === "string" ? header.key.trim() : "",
42
+ value: typeof header.value === "string" ? header.value.trim() : "",
43
+ }))
44
+ .filter((header) => header.key.length > 0);
45
+ }
46
+
47
+ export function supportsToolsByDefault(connection: {
48
+ kind: ModelConnection["kind"];
49
+ provider?: BuiltInProvider;
50
+ }): boolean {
51
+ if (connection.kind === "ollama") {
52
+ return false;
53
+ }
54
+ if (connection.kind === "builtin" && connection.provider === "google") {
55
+ return true;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ export function createBuiltinConnection(params: {
61
+ provider: BuiltInProvider;
62
+ apiKey?: string;
63
+ now?: number;
64
+ }): ModelConnection {
65
+ const now = params.now ?? Date.now();
66
+ const id = BUILTIN_CONNECTION_IDS[params.provider];
67
+ const baseUrl = params.provider === "google" ? GEMINI_OPENAI_BASE_URL : undefined;
68
+ return {
69
+ id,
70
+ name: builtinProviderName(params.provider),
71
+ kind: "builtin",
72
+ provider: params.provider,
73
+ baseUrl,
74
+ apiKey: params.apiKey?.trim() || undefined,
75
+ headers: [],
76
+ supportsTools: true,
77
+ enabled: true,
78
+ createdAt: now,
79
+ updatedAt: now,
80
+ };
81
+ }
82
+
83
+ export function normalizeConnection(input: ModelConnection, now = Date.now()): ModelConnection {
84
+ const normalizedKind =
85
+ input.kind === "builtin" || input.kind === "openai_compatible" || input.kind === "ollama"
86
+ ? input.kind
87
+ : "openai_compatible";
88
+ const normalizedProvider =
89
+ normalizedKind === "builtin" &&
90
+ (input.provider === "openai" || input.provider === "anthropic" || input.provider === "google")
91
+ ? input.provider
92
+ : undefined;
93
+ const apiKey = typeof input.apiKey === "string" && input.apiKey.trim() ? input.apiKey.trim() : undefined;
94
+ const baseUrl =
95
+ typeof input.baseUrl === "string" && input.baseUrl.trim() ? input.baseUrl.trim() : undefined;
96
+ const headers = trimHeaders(input.headers);
97
+ const supportsTools =
98
+ typeof input.supportsTools === "boolean"
99
+ ? input.supportsTools
100
+ : supportsToolsByDefault({ kind: normalizedKind, provider: normalizedProvider });
101
+
102
+ return {
103
+ id: input.id,
104
+ name:
105
+ normalizedKind === "builtin" && normalizedProvider
106
+ ? builtinProviderName(normalizedProvider)
107
+ : input.name?.trim() || input.id,
108
+ kind: normalizedKind,
109
+ provider: normalizedProvider,
110
+ baseUrl: normalizedKind === "builtin" && normalizedProvider === "google" ? GEMINI_OPENAI_BASE_URL : baseUrl,
111
+ apiKey,
112
+ headers,
113
+ supportsTools,
114
+ enabled: typeof input.enabled === "boolean" ? input.enabled : true,
115
+ createdAt: Number.isFinite(input.createdAt) ? input.createdAt : now,
116
+ updatedAt: now,
117
+ };
118
+ }
119
+
120
+ export function ensureBuiltinConnections(
121
+ connections: ModelConnection[],
122
+ keys: { openaiKey?: string; anthropicKey?: string; geminiKey?: string },
123
+ now = Date.now(),
124
+ ): ModelConnection[] {
125
+ const map = new Map(connections.map((connection) => [connection.id, connection]));
126
+
127
+ const requiredBuiltins: Array<{ provider: BuiltInProvider; apiKey?: string }> = [
128
+ { provider: "openai", apiKey: keys.openaiKey },
129
+ { provider: "anthropic", apiKey: keys.anthropicKey },
130
+ { provider: "google", apiKey: keys.geminiKey },
131
+ ];
132
+
133
+ for (const builtin of requiredBuiltins) {
134
+ const id = BUILTIN_CONNECTION_IDS[builtin.provider];
135
+ const existing = map.get(id);
136
+ if (!existing) {
137
+ map.set(id, createBuiltinConnection({ provider: builtin.provider, apiKey: builtin.apiKey, now }));
138
+ continue;
139
+ }
140
+
141
+ if (!existing.apiKey && builtin.apiKey) {
142
+ map.set(id, normalizeConnection({ ...existing, apiKey: builtin.apiKey, updatedAt: now }, now));
143
+ }
144
+ }
145
+
146
+ return [...map.values()];
147
+ }
148
+
149
+ export function migrateLegacyConnections(settings: Partial<Settings>, now = Date.now()): {
150
+ connections: ModelConnection[];
151
+ defaultConnectionId: string;
152
+ defaultModelByConnection: Record<string, string>;
153
+ } {
154
+ const legacyProvider = settings.defaultProvider ?? "openai";
155
+ const legacyModel = settings.defaultModel?.trim() || DEFAULT_MODEL_BY_PROVIDER[legacyProvider];
156
+
157
+ const incoming = Array.isArray(settings.connections) ? settings.connections : [];
158
+ const normalizedIncoming = incoming
159
+ .filter((connection) => connection && typeof connection === "object" && typeof connection.id === "string")
160
+ .map((connection) => normalizeConnection(connection, now));
161
+ const connections = ensureBuiltinConnections(
162
+ normalizedIncoming,
163
+ {
164
+ openaiKey: settings.openaiKey,
165
+ anthropicKey: settings.anthropicKey,
166
+ geminiKey: settings.geminiKey,
167
+ },
168
+ now,
169
+ );
170
+
171
+ const enabled = connections.filter((connection) => connection.enabled);
172
+ const fallbackConnectionId = BUILTIN_CONNECTION_IDS[legacyProvider];
173
+ const defaultConnectionId =
174
+ (settings.defaultConnectionId &&
175
+ connections.some((connection) => connection.id === settings.defaultConnectionId)
176
+ ? settings.defaultConnectionId
177
+ : null) ||
178
+ (enabled.find((connection) => connection.id === fallbackConnectionId)?.id ?? enabled[0]?.id) ||
179
+ fallbackConnectionId;
180
+
181
+ const existingModelMap =
182
+ settings.defaultModelByConnection && typeof settings.defaultModelByConnection === "object"
183
+ ? settings.defaultModelByConnection
184
+ : {};
185
+ const defaultModelByConnection: Record<string, string> = {};
186
+
187
+ for (const connection of connections) {
188
+ const mapped = existingModelMap[connection.id];
189
+ if (typeof mapped === "string" && mapped.trim()) {
190
+ defaultModelByConnection[connection.id] = mapped.trim();
191
+ continue;
192
+ }
193
+ if (connection.id === fallbackConnectionId && legacyModel) {
194
+ defaultModelByConnection[connection.id] = legacyModel;
195
+ continue;
196
+ }
197
+ if (connection.kind === "builtin" && connection.provider) {
198
+ defaultModelByConnection[connection.id] = DEFAULT_MODEL_BY_PROVIDER[connection.provider];
199
+ }
200
+ }
201
+
202
+ return {
203
+ connections,
204
+ defaultConnectionId,
205
+ defaultModelByConnection,
206
+ };
207
+ }
208
+
209
+ export function resolveConnectionApiKey(settings: Settings, connection: ModelConnection): string | undefined {
210
+ if (connection.apiKey?.trim()) {
211
+ return connection.apiKey.trim();
212
+ }
213
+ if (connection.kind !== "builtin") {
214
+ return undefined;
215
+ }
216
+ if (connection.provider === "openai") {
217
+ return settings.openaiKey?.trim() || undefined;
218
+ }
219
+ if (connection.provider === "anthropic") {
220
+ return settings.anthropicKey?.trim() || undefined;
221
+ }
222
+ if (connection.provider === "google") {
223
+ return settings.geminiKey?.trim() || undefined;
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ export function toChatConnectionPayload(
229
+ connection: ModelConnection,
230
+ settings: Settings,
231
+ ): ChatConnectionPayload {
232
+ return {
233
+ id: connection.id,
234
+ name: connection.name,
235
+ kind: connection.kind,
236
+ provider: connection.provider,
237
+ baseUrl: connection.baseUrl,
238
+ apiKey: resolveConnectionApiKey(settings, connection),
239
+ headers: trimHeaders(connection.headers),
240
+ supportsTools:
241
+ typeof connection.supportsTools === "boolean"
242
+ ? connection.supportsTools
243
+ : supportsToolsByDefault(connection),
244
+ };
245
+ }
246
+
247
+ export function getEnabledConnections(settings: Settings | null): ModelConnection[] {
248
+ if (!settings?.connections?.length) {
249
+ return [];
250
+ }
251
+ return settings.connections.filter((connection) => connection.enabled);
252
+ }
253
+
254
+ export function getConnectionById(
255
+ settings: Settings | null,
256
+ connectionId: string | null | undefined,
257
+ ): ModelConnection | null {
258
+ if (!settings || !connectionId) {
259
+ return null;
260
+ }
261
+ return settings.connections.find((connection) => connection.id === connectionId) ?? null;
262
+ }
263
+
264
+ export function normalizeBaseUrl(value: string): string {
265
+ const trimmed = value.trim();
266
+ if (!trimmed) {
267
+ return "";
268
+ }
269
+ try {
270
+ const url = new URL(trimmed);
271
+ return url.toString().replace(/\/$/, "");
272
+ } catch {
273
+ return trimmed;
274
+ }
275
+ }