@bhooai/nexus-cli 2.0.5 → 2.0.7

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 (62) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +8 -3
  3. package/src/commands/dev.ts +18 -2
  4. package/src/commands/init.ts +3 -0
  5. package/src/devPanel.ts +138 -11
  6. package/src/devServiceManager.ts +210 -11
  7. package/src/index.ts +19 -0
  8. package/templates/base/apps/admin/package.json.ejs +1 -0
  9. package/templates/base/apps/admin/src/App.tsx +38 -4127
  10. package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
  11. package/templates/base/apps/admin/src/api.ts +11 -11
  12. package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
  13. package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
  14. package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
  15. package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
  16. package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
  17. package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
  18. package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
  19. package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
  20. package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
  21. package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
  22. package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
  23. package/templates/base/apps/admin/src/icons.tsx +104 -0
  24. package/templates/base/apps/admin/src/index.css +77 -19
  25. package/templates/base/apps/admin/src/lib/constants.ts +94 -0
  26. package/templates/base/apps/admin/src/lib/theme.ts +80 -0
  27. package/templates/base/apps/admin/src/lib/types.ts +39 -0
  28. package/templates/base/apps/admin/src/lib/utils.ts +147 -0
  29. package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
  30. package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
  31. package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
  32. package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
  33. package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
  34. package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
  35. package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
  36. package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
  37. package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
  38. package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
  39. package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
  40. package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
  41. package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
  42. package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
  43. package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
  44. package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
  45. package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
  46. package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
  47. package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
  48. package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
  49. package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
  50. package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
  51. package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
  52. package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
  53. package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
  54. package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
  55. package/templates/base/apps/admin/src/style.css +6517 -0
  56. package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
  57. package/templates/base/apps/ai-server/main.py.ejs +2 -2
  58. package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
  59. package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
  60. package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
  61. package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
  62. package/templates/base/docker-compose.yml.ejs +2 -2
@@ -0,0 +1,276 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Icon } from '../../icons.js';
3
+ import { SafeGotoLink } from '@bhooai/nexus-safe-goto';
4
+ import {
5
+ getAiProviders, updateAiProvider, addAiProvider, deleteAiProvider, testAiProvider,
6
+ type AiProviderView, type AiProviderPersistence, type AiProviderTestResult,
7
+ } from '../../api.js';
8
+ import { useAdminAlert } from '../../alertCenter.js';
9
+ import type { ToggleStyle, ProviderFilter } from '../../lib/types.js';
10
+ import { BUILTIN_PROVIDER_IDS } from '../../lib/constants.js';
11
+ import { ProviderGroup } from './ProviderGroup.js';
12
+ import { AddProviderDialog } from './AddProviderDialog.js';
13
+ import { ProviderKeyDialog } from './ProviderKeyDialog.js';
14
+
15
+ export function AiProviders({ toggle }: { toggle: ToggleStyle }) {
16
+ const [providers, setProviders] = useState<AiProviderView[]>([]);
17
+ const [busy, setBusy] = useState(false);
18
+ const [msg, setMsg] = useAdminAlert('ai-providers');
19
+ const [showAdd, setShowAdd] = useState(false);
20
+ const [newProvider, setNewProvider] = useState({ id: '', label: '', baseUrl: '', apiKey: '', defaultModel: '' });
21
+ const [query, setQuery] = useState('');
22
+ const [filter, setFilter] = useState<ProviderFilter>('enabled');
23
+ const [testingId, setTestingId] = useState<string | null>(null);
24
+ const [testResults, setTestResults] = useState<Record<string, AiProviderTestResult>>({});
25
+ const [keyEditorProvider, setKeyEditorProvider] = useState<AiProviderView | null>(null);
26
+ const [keyDraft, setKeyDraft] = useState('');
27
+ const [switchAnim, setSwitchAnim] = useState<Record<string, 'out' | 'in' | null>>({});
28
+
29
+ useEffect(() => { loadProviders(); }, []);
30
+
31
+ const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
32
+
33
+ const loadProviders = async () => {
34
+ try { const r = await getAiProviders(); setProviders(r.providers ?? []); } catch { /* non-fatal */ }
35
+ };
36
+
37
+ const persistenceNote = (persistence?: AiProviderPersistence): string => {
38
+ if (!persistence) return '';
39
+ const missing = [
40
+ !persistence.runtime ? 'nexus.runtime.json' : '',
41
+ !persistence.database ? 'MongoDB' : '',
42
+ ].filter(Boolean);
43
+ return missing.length ? ` Persistence failed: ${missing.join(' and ')}.` : '';
44
+ };
45
+
46
+ const openKeyEditor = (provider: AiProviderView) => {
47
+ setKeyEditorProvider(provider);
48
+ setKeyDraft('');
49
+ };
50
+
51
+ const closeKeyEditor = () => {
52
+ if (busy) return;
53
+ setKeyEditorProvider(null);
54
+ setKeyDraft('');
55
+ };
56
+
57
+ const saveKeyFromEditor = async () => {
58
+ if (!keyEditorProvider || !keyDraft.trim()) return;
59
+ const saved = await saveApiKey(keyEditorProvider, keyDraft);
60
+ if (saved) {
61
+ setKeyEditorProvider(null);
62
+ setKeyDraft('');
63
+ }
64
+ };
65
+
66
+ const toggleProvider = async (p: AiProviderView) => {
67
+ setMsg('');
68
+ setBusy(true);
69
+ setSwitchAnim((prev) => ({ ...prev, [p.id]: 'out' }));
70
+ await wait(280);
71
+ try {
72
+ const result = await updateAiProvider(p.id, { enabled: !p.enabled });
73
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
74
+ setSwitchAnim((prev) => ({ ...prev, [p.id]: 'in' }));
75
+ setMsg(`${p.label} ${result.provider.enabled ? 'enabled' : 'disabled'}.${persistenceNote(result.persistence)}`);
76
+ }
77
+ catch (e: any) {
78
+ setSwitchAnim((prev) => { const next = { ...prev }; delete next[p.id]; return next; });
79
+ setMsg(`Failed: ${e?.message ?? e}`);
80
+ }
81
+ finally {
82
+ setBusy(false);
83
+ await wait(420);
84
+ setSwitchAnim((prev) => { const next = { ...prev }; delete next[p.id]; return next; });
85
+ }
86
+ };
87
+
88
+ const saveApiKey = async (p: AiProviderView, key: string): Promise<boolean> => {
89
+ if (!key.trim()) return false;
90
+ setBusy(true);
91
+ try {
92
+ const result = await updateAiProvider(p.id, { apiKey: key.trim() });
93
+ // Update only the provider that changed. Refreshing the whole collection
94
+ // here can replace the visible 20-provider list with a stale/empty read
95
+ // while the backend is persisting the key.
96
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
97
+ setMsg(`${p.label} API key saved.${persistenceNote(result.persistence)}`);
98
+ return true;
99
+ } catch (e: any) {
100
+ setMsg(`Failed: ${e?.message ?? e}`);
101
+ return false;
102
+ } finally { setBusy(false); }
103
+ };
104
+
105
+ const saveDefaultModel = async (p: AiProviderView, m: string) => {
106
+ setBusy(true);
107
+ try {
108
+ const result = await updateAiProvider(p.id, { defaultModel: m.trim() });
109
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
110
+ setMsg(`${p.label} model saved.${persistenceNote(result.persistence)}`);
111
+ }
112
+ catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
113
+ finally { setBusy(false); }
114
+ };
115
+
116
+ const addProvider = async () => {
117
+ if (!newProvider.id.trim() || !newProvider.label.trim() || !newProvider.baseUrl.trim()) { setMsg('id, label and baseUrl are required'); return; }
118
+ setBusy(true);
119
+ try {
120
+ const result = await addAiProvider({ id: newProvider.id.trim().toLowerCase(), label: newProvider.label.trim(), baseUrl: newProvider.baseUrl.trim(), apiKey: newProvider.apiKey.trim() || undefined, defaultModel: newProvider.defaultModel.trim() || undefined, enabled: true });
121
+ setProviders((current) => [...current, result.provider]);
122
+ setShowAdd(false);
123
+ setNewProvider({ id: '', label: '', baseUrl: '', apiKey: '', defaultModel: '' });
124
+ setMsg(`Provider added.${persistenceNote(result.persistence)}`);
125
+ } catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
126
+ finally { setBusy(false); }
127
+ };
128
+
129
+ const removeProvider = async (id: string) => {
130
+ setBusy(true);
131
+ try {
132
+ const result = await deleteAiProvider(id);
133
+ setProviders((current) => current.filter((item) => item.id !== id));
134
+ setTestResults((prev) => { const next = { ...prev }; delete next[id]; return next; });
135
+ setMsg(`Provider removed.${persistenceNote(result.persistence)}`);
136
+ }
137
+ catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
138
+ finally { setBusy(false); }
139
+ };
140
+
141
+ const testProvider = async (p: AiProviderView) => {
142
+ setTestingId(p.id);
143
+ setMsg('');
144
+ try {
145
+ const result = await testAiProvider(p.id);
146
+ setTestResults((prev) => ({ ...prev, [p.id]: result }));
147
+ if (result.ok) setMsg(`${p.label}: reachable — ${result.modelCount ?? 0} model(s).`);
148
+ else setMsg(`${p.label}: ${result.error ?? 'unreachable'}.`);
149
+ } catch (e: any) {
150
+ setTestResults((prev) => ({ ...prev, [p.id]: { ok: false, provider: p.id, error: String(e?.message ?? e), checkedAt: new Date().toISOString() } }));
151
+ setMsg(`${p.label}: ${e?.message ?? e}`);
152
+ } finally {
153
+ setTestingId(null);
154
+ }
155
+ };
156
+
157
+ // Search + filter logic.
158
+ const matchesQuery = (p: AiProviderView) => {
159
+ if (!query.trim()) return true;
160
+ const q = query.toLowerCase();
161
+ return p.id.toLowerCase().includes(q) || p.label.toLowerCase().includes(q);
162
+ };
163
+
164
+ const enabled = providers.filter((p) => matchesQuery(p) && p.enabled && BUILTIN_PROVIDER_IDS.has(p.id));
165
+ const disabled = providers.filter((p) => matchesQuery(p) && !p.enabled && BUILTIN_PROVIDER_IDS.has(p.id));
166
+ const custom = providers.filter((p) => matchesQuery(p) && !BUILTIN_PROVIDER_IDS.has(p.id));
167
+
168
+ const groups: Array<{ id: string; label: string; icon: string; items: AiProviderView[] }> = [];
169
+ if (filter === 'all' || filter === 'enabled') {
170
+ if (enabled.length) groups.push({ id: 'enabled', label: 'Enabled', icon: '●', items: enabled });
171
+ }
172
+ if (filter === 'all' || filter === 'disabled') {
173
+ if (disabled.length) groups.push({ id: 'disabled', label: 'Disabled', icon: 'â—‹', items: disabled });
174
+ }
175
+ if (filter === 'all' || filter === 'custom') {
176
+ if (custom.length) groups.push({ id: 'custom', label: 'Custom', icon: '✚', items: custom });
177
+ }
178
+
179
+ const filterPills: Array<[ProviderFilter, string, number]> = [
180
+ ['all', 'All', providers.length],
181
+ ['enabled', 'Enabled', enabled.length],
182
+ ['disabled', 'Disabled', disabled.length],
183
+ ['custom', 'Custom', custom.length],
184
+ ];
185
+
186
+ return (
187
+ <div className="space-y-4">
188
+
189
+ {/* Header */}
190
+ <section className="glass-card space-y-3">
191
+ <div className="flex items-center justify-between">
192
+ <h3 className="glass-h3">AI providers ({providers.length})</h3>
193
+ <div className="flex gap-2">
194
+ <button onClick={loadProviders} disabled={busy} className="glass-chip-btn">refresh</button>
195
+ <button onClick={() => setShowAdd(true)} disabled={busy} className="glass-chip-btn">+ add provider</button>
196
+ </div>
197
+ </div>
198
+
199
+ {/* Filter bar */}
200
+ <div className="provider-filter-bar">
201
+ <div className="relative flex-1 sm:w-64">
202
+ <span className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500"><Icon name="search" size={13} /></span>
203
+ <input
204
+ className="glass-input !py-1.5 text-sm w-full sm:w-64 pl-8"
205
+ placeholder="Search providers…"
206
+ type="search"
207
+ autoComplete="off"
208
+ autoCapitalize="none"
209
+ autoCorrect="off"
210
+ spellCheck={false}
211
+ value={query}
212
+ onChange={(e) => setQuery(e.target.value)}
213
+ />
214
+ </div>
215
+ <div className="flex flex-wrap gap-1.5">
216
+ {filterPills.map(([id, label, count]) => (
217
+ <button
218
+ key={id}
219
+ onClick={() => setFilter(id)}
220
+ className={filter === id ? 'glass-btn-primary !py-1 !px-2.5 text-xs' : 'glass-chip-btn'}
221
+ >
222
+ {label} <span className="ml-1 opacity-60">{count}</span>
223
+ </button>
224
+ ))}
225
+ </div>
226
+ </div>
227
+
228
+ {/* Groups */}
229
+ {groups.length === 0 && (
230
+ <p className="text-xs text-slate-500 py-4 text-center">
231
+ {providers.length === 0 ? 'No providers configured.' : 'No providers match your search/filter.'}
232
+ </p>
233
+ )}
234
+ {groups.map((g) => (
235
+ <ProviderGroup
236
+ key={g.id}
237
+ label={g.label}
238
+ icon={g.icon}
239
+ items={g.items}
240
+ toggle={toggle}
241
+ switchAnim={switchAnim}
242
+ busy={busy}
243
+ testingId={testingId}
244
+ testResults={testResults}
245
+ onToggle={toggleProvider}
246
+ onSaveModel={saveDefaultModel}
247
+ onRemove={removeProvider}
248
+ onTest={testProvider}
249
+ onOpenKey={openKeyEditor}
250
+ />
251
+ ))}
252
+ </section>
253
+
254
+ {/* Add provider modal */}
255
+ {showAdd && (
256
+ <AddProviderDialog
257
+ value={newProvider}
258
+ onChange={setNewProvider}
259
+ onSubmit={addProvider}
260
+ onCancel={() => setShowAdd(false)}
261
+ busy={busy}
262
+ />
263
+ )}
264
+ {keyEditorProvider && (
265
+ <ProviderKeyDialog
266
+ provider={keyEditorProvider}
267
+ value={keyDraft}
268
+ onChange={setKeyDraft}
269
+ onSave={saveKeyFromEditor}
270
+ onCancel={closeKeyEditor}
271
+ busy={busy}
272
+ />
273
+ )}
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,84 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { getAdminConfig, getAiStatus, type AdminConfig, type AiStatus } from '../../api.js';
3
+ import { useAdminAlert } from '../../alertCenter.js';
4
+
5
+ export function AiSettings() {
6
+ const [config, setConfig] = useState<AdminConfig | null>(null);
7
+ const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
8
+ const [aiErr, setAiErr] = useState('');
9
+ const [msg, setMsg] = useAdminAlert('ai-settings');
10
+
11
+ useEffect(() => {
12
+ getAdminConfig().then((c) => setConfig(c)).catch(() => {});
13
+ checkAi();
14
+ }, []);
15
+
16
+ const checkAi = async () => {
17
+ setAiErr('');
18
+ try { setAiStatus(await getAiStatus()); }
19
+ catch (e: any) { setAiErr(String(e?.message ?? e)); }
20
+ };
21
+
22
+ const ai = config?.config?.ai as { serverUrl: string; timeoutMs: number; defaultProvider: string; schemaModel: string } | undefined;
23
+
24
+ return (
25
+ <div className="space-y-4">
26
+
27
+ <section className="glass-card space-y-3">
28
+ <h3 className="glass-h3">AI configuration</h3>
29
+ {ai ? (
30
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
31
+ {([
32
+ ['Server URL', ai.serverUrl],
33
+ ['Timeout', `${(ai.timeoutMs / 1000).toFixed(0)}s`],
34
+ ['Default provider', ai.defaultProvider],
35
+ ['Schema model', ai.schemaModel],
36
+ ] as const).map(([label, value]) => (
37
+ <div key={label} className="glass rounded-xl px-3 py-2">
38
+ <span className="admin-overline">{label}</span>
39
+ <code className="block mt-1 truncate font-mono text-sm text-slate-200">{value}</code>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ ) : (
44
+ <p className="text-xs text-slate-500">Loading settings…</p>
45
+ )}
46
+ </section>
47
+
48
+ <section className="glass-card space-y-3">
49
+ <div className="flex items-center justify-between">
50
+ <h3 className="glass-h3">AI server health</h3>
51
+ <button onClick={checkAi} className="glass-chip-btn">re-check</button>
52
+ </div>
53
+ {aiErr && <p className="text-xs text-rose-400">{aiErr}</p>}
54
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
55
+ {aiStatus ? (
56
+ <>
57
+ {([
58
+ ['AI server', aiStatus.aiServer, aiStatus.aiServer.detail ? `health: ${aiStatus.aiServer.detail.status ?? 'unknown'}` : undefined],
59
+ ['Ollama', aiStatus.ollama, aiStatus.ollama.detail?.modelCount != null ? `${aiStatus.ollama.detail.modelCount} model(s)` : undefined],
60
+ ] as const).map(([label, s, detail]) => (
61
+ <div key={label} className="glass flex items-center gap-2 rounded-xl px-3 py-2 text-sm">
62
+ <span className={s.ok ? 'dot-ok' : 'dot-bad'} />
63
+ <span className="font-medium text-slate-200">{label}</span>
64
+ <span className={`text-xs ${s.ok ? 'text-emerald-400' : 'text-rose-400'}`}>{s.ok ? (detail ?? 'ok') : (s.error ?? 'down')}</span>
65
+ </div>
66
+ ))}
67
+ </>
68
+ ) : (
69
+ <p className="col-span-3 text-xs text-slate-500">No status yet{aiErr ? '' : ' — run a check'}.</p>
70
+ )}
71
+ </div>
72
+ {aiStatus && <p className="text-xs text-slate-500">"auto" currently resolves to <span className="font-mono text-slate-300">{aiStatus.autoResolvesTo}</span> on the AI server.</p>}
73
+ {aiStatus?.aiServer?.detail?.providers && (
74
+ <div className="flex flex-wrap gap-2">
75
+ <span className="text-xs text-slate-500">Available providers on AI server:</span>
76
+ {aiStatus.aiServer.detail.providers.map((p) => (
77
+ <span key={p} className="glass-chip text-xs">{p}</span>
78
+ ))}
79
+ </div>
80
+ )}
81
+ </section>
82
+ </div>
83
+ );
84
+ }
@@ -0,0 +1,145 @@
1
+ import React, { useState } from 'react';
2
+ import { Icon, isIconName } from '../../icons.js';
3
+ import { SafeGotoLink } from '@bhooai/nexus-safe-goto';
4
+ import type { ToggleStyle } from '../../lib/types.js';
5
+ import { PRESET_BY_ID } from '../../lib/constants.js';
6
+ import { providerMeta, isLocalProvider, websiteFromBaseUrl } from '../../lib/utils.js';
7
+ import type { AiProviderView, AiProviderTestResult } from '../../api.js';
8
+
9
+ export function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testResult, onToggle, onSaveModel, onRemove, onTest, onOpenKey }: {
10
+ provider: AiProviderView;
11
+ toggle: ToggleStyle;
12
+ anim: 'out' | 'in' | null;
13
+ busy: boolean;
14
+ isCustom: boolean;
15
+ testing: boolean;
16
+ testResult?: AiProviderTestResult;
17
+ onToggle: () => void;
18
+ onSaveModel: (model: string) => void;
19
+ onRemove: () => void;
20
+ onTest: () => void;
21
+ onOpenKey: () => void;
22
+ }) {
23
+ const [editingModel, setEditingModel] = useState(false);
24
+ const [modelInput, setModelInput] = useState(provider.defaultModel ?? '');
25
+
26
+ const meta = providerMeta(provider.id);
27
+ const local = isLocalProvider(provider.baseUrl);
28
+ const cardClass = provider.enabled
29
+ ? provider.hasApiKey ? 'is-on' : 'is-warn'
30
+ : 'is-off';
31
+
32
+ return (
33
+ <div className={`provider-card ${cardClass}${anim === 'out' ? ' is-switch-out' : ''}${anim === 'in' ? ' is-switch-in' : ''}`} style={{ ['--avatar-hue' as string]: String(meta.hue) }}>
34
+ {/* Header: avatar + label/id + toggle */}
35
+ <div className="flex items-center gap-2.5">
36
+ <span className="provider-avatar" style={{ ['--avatar-hue' as string]: String(meta.hue) }}>{isIconName(meta.glyph) ? <Icon name={meta.glyph} size={16} /> : meta.glyph}</span>
37
+ <div className="flex-1 min-w-0">
38
+ <div className="font-medium text-sm text-slate-200 truncate">{provider.label}</div>
39
+ <div className="text-[11px] text-slate-500 font-mono truncate">{provider.id}</div>
40
+ </div>
41
+ <button
42
+ type="button"
43
+ onClick={onToggle}
44
+ disabled={busy}
45
+ className={`ai-toggle tg-${toggle} ${provider.enabled ? 'is-on' : ''}`}
46
+ title={provider.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
47
+ >
48
+ <span className="ai-toggle-knob" />
49
+ </button>
50
+ </div>
51
+
52
+ {/* Status badges */}
53
+ <div className="flex flex-wrap items-center gap-1.5">
54
+ {provider.hasApiKey
55
+ ? <span className="provider-badge-ok">✓ key</span>
56
+ : <span className="provider-badge-warn">no key</span>}
57
+ {provider.enabled
58
+ ? <span className="provider-badge-ok">enabled</span>
59
+ : <span className="provider-badge-mute">disabled</span>}
60
+ {isCustom && <span className="provider-badge-accent">custom</span>}
61
+ {local && <span className="provider-badge-local">local</span>}
62
+ </div>
63
+
64
+ {/* Model row */}
65
+ <div className="space-y-1.5">
66
+ <div className="flex items-center gap-2">
67
+ <span className="text-[11px] text-slate-500 shrink-0">Model</span>
68
+ {editingModel ? (
69
+ <>
70
+ <input
71
+ className="glass-input !py-1 text-xs font-mono flex-1"
72
+ placeholder="default model"
73
+ value={modelInput}
74
+ onChange={(e) => setModelInput(e.target.value)}
75
+ autoFocus
76
+ />
77
+ <button
78
+ type="button"
79
+ onClick={() => { onSaveModel(modelInput); setEditingModel(false); }}
80
+ disabled={busy || !modelInput.trim()}
81
+ className="glass-chip-btn shrink-0 text-[10px]"
82
+ >save</button>
83
+ <button
84
+ type="button"
85
+ onClick={() => { setEditingModel(false); setModelInput(provider.defaultModel ?? ''); }}
86
+ className="glass-chip-btn shrink-0 text-[10px]"
87
+ >cancel</button>
88
+ </>
89
+ ) : (
90
+ <>
91
+ <code className="text-xs text-slate-300 font-mono truncate flex-1">{provider.defaultModel || '—'}</code>
92
+ <button type="button" onClick={() => setEditingModel(true)} disabled={busy} className="glass-chip-btn shrink-0 text-[10px]" title="Edit model"><Icon name="pencil" size={11} /></button>
93
+ </>
94
+ )}
95
+ </div>
96
+ {editingModel && PRESET_BY_ID[provider.id]?.models?.length ? (
97
+ <select
98
+ className="glass-input !py-1 text-xs font-mono"
99
+ value=""
100
+ onChange={(e) => { if (e.target.value) setModelInput(e.target.value); }}
101
+ >
102
+ <option value="" className="bg-slate-900">…or pick a popular model</option>
103
+ {PRESET_BY_ID[provider.id]!.models.map((m) => (
104
+ <option key={m} value={m} className="bg-slate-900">{m}</option>
105
+ ))}
106
+ </select>
107
+ ) : null}
108
+ </div>
109
+
110
+ {/* Footer: baseUrl + website + set key */}
111
+ <div className="flex flex-wrap items-center gap-1.5">
112
+ <code className="text-[10px] text-slate-500 truncate flex-1 min-w-[100px]">{provider.baseUrl}</code>
113
+ <SafeGotoLink href={websiteFromBaseUrl(provider.baseUrl)} className="glass-chip-btn shrink-0 text-[10px]"><Icon name="externalLink" size={11} /></SafeGotoLink>
114
+ <button
115
+ type="button"
116
+ onClick={onOpenKey}
117
+ disabled={busy}
118
+ className="glass-chip-btn shrink-0 text-[10px]"
119
+ >key</button>
120
+ {isCustom && (
121
+ <button type="button" onClick={onRemove} disabled={busy} className="glass-chip-btn-danger shrink-0 text-[10px]" title="Remove provider"><Icon name="trash" size={11} /></button>
122
+ )}
123
+ </div>
124
+
125
+ {/* Test row */}
126
+ <div className="flex items-center gap-2 pt-0.5 border-t border-white/[0.06]">
127
+ <button type="button" onClick={onTest} disabled={busy || testing} className="glass-chip-btn shrink-0 text-[10px]">
128
+ {testing ? 'testing…' : <><Icon name="zap" size={11} /> test</>}
129
+ </button>
130
+ {testResult && (
131
+ testResult.ok ? (
132
+ <span className="text-[10px] text-emerald-300 truncate">
133
+ ✓ {testResult.modelCount ?? 0} model{testResult.modelCount === 1 ? '' : 's'}
134
+ {testResult.models && testResult.models.length > 0 && (
135
+ <span className="text-slate-500"> — {testResult.models.slice(0, 3).join(', ')}{testResult.models.length > 3 ? '…' : ''}</span>
136
+ )}
137
+ </span>
138
+ ) : (
139
+ <span className="text-[10px] text-rose-400 truncate" title={testResult.error}>✗ {testResult.error ?? 'unreachable'}</span>
140
+ )
141
+ )}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import type { ToggleStyle } from '../../lib/types.js';
3
+ import { BUILTIN_PROVIDER_IDS } from '../../lib/constants.js';
4
+ import type { AiProviderView, AiProviderTestResult } from '../../api.js';
5
+ import { ProviderCard } from './ProviderCard.js';
6
+
7
+ export function ProviderGroup({ label, icon, items, toggle, switchAnim, busy, testingId, testResults, onToggle, onSaveModel, onRemove, onTest, onOpenKey }: {
8
+ label: string;
9
+ icon: string;
10
+ items: AiProviderView[];
11
+ toggle: ToggleStyle;
12
+ switchAnim: Record<string, 'out' | 'in' | null>;
13
+ busy: boolean;
14
+ testingId: string | null;
15
+ testResults: Record<string, AiProviderTestResult>;
16
+ onToggle: (p: AiProviderView) => void;
17
+ onSaveModel: (p: AiProviderView, model: string) => void;
18
+ onRemove: (id: string) => void;
19
+ onTest: (p: AiProviderView) => void;
20
+ onOpenKey: (p: AiProviderView) => void;
21
+ }) {
22
+ return (
23
+ <div className="space-y-2">
24
+ <div className="flex items-center gap-2">
25
+ <span className="text-sm text-slate-300">{icon}</span>
26
+ <h4 className="glass-h3">{label}</h4>
27
+ <span className="glass-chip text-xs">{items.length}</span>
28
+ </div>
29
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
30
+ {items.map((p) => (
31
+ <ProviderCard
32
+ key={p.id}
33
+ provider={p}
34
+ toggle={toggle}
35
+ anim={switchAnim[p.id] ?? null}
36
+ busy={busy}
37
+ isCustom={!BUILTIN_PROVIDER_IDS.has(p.id)}
38
+ testing={testingId === p.id}
39
+ testResult={testResults[p.id]}
40
+ onToggle={() => onToggle(p)}
41
+ onSaveModel={(m) => onSaveModel(p, m)}
42
+ onRemove={() => onRemove(p.id)}
43
+ onTest={() => onTest(p)}
44
+ onOpenKey={() => onOpenKey(p)}
45
+ />
46
+ ))}
47
+ </div>
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,66 @@
1
+ import React, { useEffect } from 'react';
2
+ import type { AiProviderView } from '../../api.js';
3
+
4
+ export function ProviderKeyDialog({ provider, value, onChange, onSave, onCancel, busy }: {
5
+ provider: AiProviderView;
6
+ value: string;
7
+ onChange: (value: string) => void;
8
+ onSave: () => void;
9
+ onCancel: () => void;
10
+ busy: boolean;
11
+ }) {
12
+ useEffect(() => {
13
+ const onKey = (event: KeyboardEvent) => {
14
+ if (event.key === 'Escape') onCancel();
15
+ };
16
+ window.addEventListener('keydown', onKey);
17
+ return () => window.removeEventListener('keydown', onKey);
18
+ }, [onCancel]);
19
+
20
+ return (
21
+ <div className="provider-modal-backdrop" onClick={onCancel}>
22
+ <div className="provider-modal space-y-4" role="dialog" aria-modal="true" aria-labelledby="provider-key-title" onClick={(event) => event.stopPropagation()}>
23
+ <div className="flex items-start justify-between gap-3">
24
+ <div>
25
+ <span className="admin-overline">API CREDENTIAL</span>
26
+ <h3 id="provider-key-title" className="mt-1 text-base font-semibold text-slate-100">{provider.label}</h3>
27
+ <code className="text-xs text-slate-500">{provider.id}</code>
28
+ </div>
29
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn text-xs" aria-label="Close API key dialog">✕</button>
30
+ </div>
31
+
32
+ <div className="rounded-xl border border-sky-400/20 bg-black/20 px-3 py-2 text-xs text-slate-400">
33
+ {provider.hasApiKey ? 'A key is already configured. Enter a new key to replace it.' : 'This key is stored server-side in .env and is never written to the config file.'}
34
+ </div>
35
+
36
+ <label className="block">
37
+ <span className="admin-overline">API key</span>
38
+ <input
39
+ className="glass-input mt-1 text-sm"
40
+ type="password"
41
+ id="ai-provider-api-key"
42
+ name="ai-provider-api-key"
43
+ autoComplete="new-password"
44
+ autoFocus
45
+ placeholder={provider.hasApiKey ? '•••••••• (enter new to replace)' : 'paste API key'}
46
+ value={value}
47
+ onChange={(event) => onChange(event.target.value)}
48
+ onKeyDown={(event) => {
49
+ if (event.key === 'Enter' && value.trim() && !busy) {
50
+ event.preventDefault();
51
+ onSave();
52
+ }
53
+ }}
54
+ />
55
+ </label>
56
+
57
+ <div className="flex justify-end gap-2 border-t border-sky-400/20 pt-3">
58
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
59
+ <button type="button" onClick={onSave} disabled={busy || !value.trim()} className="glass-btn-primary !py-1.5 text-sm">
60
+ {busy ? 'Saving…' : 'Save API key'}
61
+ </button>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ );
66
+ }