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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- 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
|
+
}
|