@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.
- package/package.json +1 -1
- package/src/commands/add.ts +8 -3
- package/src/commands/dev.ts +18 -2
- package/src/commands/init.ts +3 -0
- package/src/devPanel.ts +138 -11
- package/src/devServiceManager.ts +210 -11
- package/src/index.ts +19 -0
- package/templates/base/apps/admin/package.json.ejs +1 -0
- package/templates/base/apps/admin/src/App.tsx +38 -4127
- package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
- package/templates/base/apps/admin/src/api.ts +11 -11
- package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
- package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
- package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
- package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
- package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
- package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
- package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
- package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
- package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
- package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
- package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
- package/templates/base/apps/admin/src/icons.tsx +104 -0
- package/templates/base/apps/admin/src/index.css +77 -19
- package/templates/base/apps/admin/src/lib/constants.ts +94 -0
- package/templates/base/apps/admin/src/lib/theme.ts +80 -0
- package/templates/base/apps/admin/src/lib/types.ts +39 -0
- package/templates/base/apps/admin/src/lib/utils.ts +147 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
- package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
- package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
- package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
- package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
- package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
- package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
- package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
- package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
- package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
- package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
- package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
- package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
- package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
- package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
- package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
- package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
- package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
- package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
- package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
- package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
- package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
- package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
- package/templates/base/apps/admin/src/style.css +6517 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
- package/templates/base/apps/ai-server/main.py.ejs +2 -2
- package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
- package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
- package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
- 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
|
+
}
|