@bhooai/nexus-cli 2.0.7 → 2.0.9
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/templates/base/apps/admin/package.json.ejs +3 -3
- package/templates/base/apps/admin/postcss.config.js +1 -1
- package/templates/base/apps/admin/tailwind.config.js +1 -0
- package/templates/base/apps/admin/src/App.tsx +0 -38
- package/templates/base/apps/admin/src/alertCenter.tsx +0 -151
- package/templates/base/apps/admin/src/api.ts +0 -474
- package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +0 -25
- package/templates/base/apps/admin/src/components/ThemeCentre.tsx +0 -201
- package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +0 -37
- package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +0 -441
- package/templates/base/apps/admin/src/components/shell/Login.tsx +0 -69
- package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +0 -102
- package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +0 -133
- package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +0 -217
- package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +0 -38
- package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +0 -54
- package/templates/base/apps/admin/src/components/ui/PageHead.tsx +0 -13
- package/templates/base/apps/admin/src/components/ui/PageHero.tsx +0 -15
- package/templates/base/apps/admin/src/icons.tsx +0 -104
- package/templates/base/apps/admin/src/index.css +0 -3539
- package/templates/base/apps/admin/src/lib/constants.ts +0 -94
- package/templates/base/apps/admin/src/lib/theme.ts +0 -80
- package/templates/base/apps/admin/src/lib/types.ts +0 -39
- package/templates/base/apps/admin/src/lib/utils.ts +0 -147
- package/templates/base/apps/admin/src/pages/Config.tsx +0 -108
- package/templates/base/apps/admin/src/pages/Databases.tsx +0 -122
- package/templates/base/apps/admin/src/pages/Environment.tsx +0 -69
- package/templates/base/apps/admin/src/pages/Logs.tsx +0 -270
- package/templates/base/apps/admin/src/pages/Monitoring.tsx +0 -88
- package/templates/base/apps/admin/src/pages/Overview.tsx +0 -108
- package/templates/base/apps/admin/src/pages/Payments.tsx +0 -68
- package/templates/base/apps/admin/src/pages/Plugins.tsx +0 -36
- package/templates/base/apps/admin/src/pages/Processes.tsx +0 -67
- package/templates/base/apps/admin/src/pages/Schema.tsx +0 -130
- package/templates/base/apps/admin/src/pages/Users.tsx +0 -144
- package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +0 -150
- package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +0 -31
- package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +0 -208
- package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +0 -276
- package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +0 -84
- package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +0 -145
- package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +0 -50
- package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +0 -66
- package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +0 -70
- package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +0 -32
- package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +0 -27
- package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +0 -74
- package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +0 -173
- package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +0 -26
- package/templates/base/apps/admin/src/style.css +0 -6517
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { Icon } from '../../icons.js';
|
|
3
|
-
import {
|
|
4
|
-
getAdminConfig, getAiStatus, getAiModels, aiChatStream, getAiProviders,
|
|
5
|
-
type AdminConfig, type AiStatus, type AiModelInfo, type AiChatMessage, type AiProviderView,
|
|
6
|
-
} from '../../api.js';
|
|
7
|
-
import { useAdminAlert } from '../../alertCenter.js';
|
|
8
|
-
|
|
9
|
-
export function AiChatPlayground() {
|
|
10
|
-
const [config, setConfig] = useState<AdminConfig | null>(null);
|
|
11
|
-
const [providers, setProviders] = useState<AiProviderView[]>([]);
|
|
12
|
-
const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
|
|
13
|
-
const [models, setModels] = useState<AiModelInfo[]>([]);
|
|
14
|
-
const [model, setModel] = useState('');
|
|
15
|
-
const [provider, setProvider] = useState('');
|
|
16
|
-
const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.');
|
|
17
|
-
const [input, setInput] = useState('');
|
|
18
|
-
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
|
19
|
-
const [streaming, setStreaming] = useState(false);
|
|
20
|
-
const [streamText, setStreamText] = useState('');
|
|
21
|
-
const [msg, setMsg] = useAdminAlert('ai-playground');
|
|
22
|
-
const [modelsLoaded, setModelsLoaded] = useState(false);
|
|
23
|
-
const [loadedFor, setLoadedFor] = useState('');
|
|
24
|
-
const loadModelsRef = useRef<(p: string) => Promise<void>>(async () => {});
|
|
25
|
-
const checkAiRef = useRef<() => Promise<void>>(async () => {});
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
getAdminConfig().then((c) => { setConfig(c); const a = c?.config?.ai as { schemaModel?: string; defaultProvider?: string } | undefined; setModel((x) => x || (a?.schemaModel ?? 'llama3:latest')); }).catch(() => {});
|
|
29
|
-
getAiProviders().then((r) => {
|
|
30
|
-
setProviders(r.providers ?? []);
|
|
31
|
-
// Auto-select the first enabled provider, then load its models.
|
|
32
|
-
const firstEnabled = (r.providers ?? []).find((p) => p.enabled);
|
|
33
|
-
if (firstEnabled) {
|
|
34
|
-
setProvider(firstEnabled.id);
|
|
35
|
-
setModel(firstEnabled.defaultModel ?? 'llama3:latest');
|
|
36
|
-
// Load its models once the provider state lands.
|
|
37
|
-
setTimeout(() => { void loadModelsRef.current(firstEnabled.id); }, 0);
|
|
38
|
-
} else {
|
|
39
|
-
// No enabled provider — fall back to the AI server's own detection.
|
|
40
|
-
void checkAiRef.current();
|
|
41
|
-
}
|
|
42
|
-
}).catch(() => { void checkAiRef.current(); });
|
|
43
|
-
}, []);
|
|
44
|
-
|
|
45
|
-
const checkAi = async () => {
|
|
46
|
-
try {
|
|
47
|
-
const s = await getAiStatus();
|
|
48
|
-
setAiStatus(s);
|
|
49
|
-
// Auto-select ollama if it's the only provider available on the AI server
|
|
50
|
-
if (s.ollama?.ok && s.ollama.detail?.models?.length && !provider) {
|
|
51
|
-
setProvider('ollama');
|
|
52
|
-
setModel(s.ollama.detail.models[0] ?? 'llama3:latest');
|
|
53
|
-
}
|
|
54
|
-
// Load models for the current (or resolved) provider.
|
|
55
|
-
const prov = provider || (s.autoResolvesTo ?? 'ollama');
|
|
56
|
-
await loadModels(prov);
|
|
57
|
-
} catch { /* non-fatal */ }
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const loadModels = async (p: string) => {
|
|
61
|
-
setProvider(p);
|
|
62
|
-
// Find the provider's default model
|
|
63
|
-
const pv = providers.find((x) => x.id === p);
|
|
64
|
-
if (pv?.defaultModel || modelsLoaded) setModel((cur) => pv?.defaultModel ?? cur);
|
|
65
|
-
try {
|
|
66
|
-
const m = await getAiModels(p);
|
|
67
|
-
setModels(m?.data ?? []);
|
|
68
|
-
setModelsLoaded(true);
|
|
69
|
-
setLoadedFor(p);
|
|
70
|
-
const err = m?.error;
|
|
71
|
-
if (err) setMsg(`Models unavailable for "${p}": ${err}`);
|
|
72
|
-
else setMsg('');
|
|
73
|
-
} catch (e: any) {
|
|
74
|
-
setModels([]);
|
|
75
|
-
setModelsLoaded(false);
|
|
76
|
-
setLoadedFor('');
|
|
77
|
-
setMsg(`Could not load models for "${p}": ${e?.message ?? e}`);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
loadModelsRef.current = loadModels;
|
|
81
|
-
checkAiRef.current = () => checkAi();
|
|
82
|
-
|
|
83
|
-
const send = async () => {
|
|
84
|
-
if (!input.trim() || streaming) return;
|
|
85
|
-
// Resolve the actual provider to send to the AI server
|
|
86
|
-
const effectiveProvider = provider || aiStatus?.autoResolvesTo || 'ollama';
|
|
87
|
-
const effectiveModel = model || models[0]?.id || 'llama3:latest';
|
|
88
|
-
|
|
89
|
-
const userMsg: AiChatMessage = { role: 'user', content: input.trim() };
|
|
90
|
-
const chatMsgs: AiChatMessage[] = [];
|
|
91
|
-
if (systemPrompt.trim()) chatMsgs.push({ role: 'system', content: systemPrompt.trim() });
|
|
92
|
-
chatMsgs.push(...messages, userMsg);
|
|
93
|
-
setMessages((m) => [...m, userMsg]);
|
|
94
|
-
setInput('');
|
|
95
|
-
setStreaming(true);
|
|
96
|
-
setStreamText('');
|
|
97
|
-
setMsg('');
|
|
98
|
-
let acc = '';
|
|
99
|
-
try {
|
|
100
|
-
await aiChatStream(effectiveModel, chatMsgs, effectiveProvider, (chunk) => {
|
|
101
|
-
acc += chunk;
|
|
102
|
-
setStreamText(acc);
|
|
103
|
-
});
|
|
104
|
-
if (!acc) { setMsg('AI returned an empty response. Check that the model is available and the AI server is running.'); }
|
|
105
|
-
else { setMessages((m) => [...m, { role: 'assistant', content: acc }]); }
|
|
106
|
-
setStreamText('');
|
|
107
|
-
} catch (e: any) {
|
|
108
|
-
const errMsg = String(e?.message ?? e);
|
|
109
|
-
setMsg(`Chat failed: ${errMsg}`);
|
|
110
|
-
if (acc) setMessages((m) => [...m, { role: 'assistant', content: acc }]);
|
|
111
|
-
setStreamText('');
|
|
112
|
-
} finally {
|
|
113
|
-
setStreaming(false);
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const clearChat = () => { setMessages([]); setStreamText(''); setMsg(''); };
|
|
118
|
-
const sendOnEnter = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } };
|
|
119
|
-
|
|
120
|
-
const modelOptions = models.map((m) => m.id);
|
|
121
|
-
const enabledProviders = providers.filter((p) => p.enabled);
|
|
122
|
-
const ai = config?.config?.ai as { serverUrl: string; timeoutMs: number; defaultProvider: string; schemaModel: string } | undefined;
|
|
123
|
-
|
|
124
|
-
// Build provider options: enabled config providers + any from AI server status
|
|
125
|
-
const providerOptions: { id: string; label: string }[] = [];
|
|
126
|
-
if (enabledProviders.length) {
|
|
127
|
-
enabledProviders.forEach((p) => providerOptions.push({ id: p.id, label: p.label }));
|
|
128
|
-
} else {
|
|
129
|
-
// Fall back to AI server's known providers
|
|
130
|
-
if (aiStatus?.aiServer?.ok) providerOptions.push({ id: 'ollama', label: 'Ollama' });
|
|
131
|
-
if (aiStatus?.openai?.ok) providerOptions.push({ id: 'openai', label: 'OpenAI' });
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<div className="space-y-4">
|
|
136
|
-
|
|
137
|
-
<section className="glass-card space-y-4">
|
|
138
|
-
<div className="flex items-center justify-between">
|
|
139
|
-
<h3 className="glass-h3">Chat playground</h3>
|
|
140
|
-
<div className="flex gap-2">
|
|
141
|
-
<button onClick={checkAi} disabled={streaming} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh models</button>
|
|
142
|
-
<button onClick={clearChat} disabled={streaming} className="glass-chip-btn">clear</button>
|
|
143
|
-
</div>
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
{/* Controls */}
|
|
147
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
148
|
-
<label className="text-xs text-slate-400">Provider
|
|
149
|
-
<select className="glass-input w-44 !py-1.5 text-sm" value={provider} onChange={(e) => loadModels(e.target.value)}>
|
|
150
|
-
{!provider && <option value="" className="bg-slate-900">— select —</option>}
|
|
151
|
-
{providerOptions.map((p) => <option key={p.id} value={p.id} className="bg-slate-900">{p.label}</option>)}
|
|
152
|
-
</select>
|
|
153
|
-
</label>
|
|
154
|
-
<label className="text-xs text-slate-400">Model
|
|
155
|
-
<input className="glass-input w-52 !py-1.5 font-mono text-sm" value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3:latest" />
|
|
156
|
-
</label>
|
|
157
|
-
<select
|
|
158
|
-
className="glass-input w-48 !py-1.5 text-sm"
|
|
159
|
-
value=""
|
|
160
|
-
onChange={(e) => { const v = e.target.value; if (v) setModel(v); }}
|
|
161
|
-
disabled={!modelOptions.length}
|
|
162
|
-
title={modelOptions.length ? `Available models for "${loadedFor || provider}"` : 'No models — click refresh models'}
|
|
163
|
-
>
|
|
164
|
-
<option value="" className="bg-slate-900">{modelOptions.length ? `${modelOptions.length} available for "${loadedFor || provider}"` : 'no models loaded'}</option>
|
|
165
|
-
{modelOptions.map((m) => <option key={m} value={m} className="bg-slate-900">{m}</option>)}
|
|
166
|
-
</select>
|
|
167
|
-
</div>
|
|
168
|
-
|
|
169
|
-
{!provider && (
|
|
170
|
-
<p className="text-xs text-amber-400"><Icon name="warning" size={12} className="mb-0.5 mr-1 inline" /> Select a provider above. If no providers are listed, go to the AI Providers tab and enable one, or click "refresh models" to detect the AI server's available providers.</p>
|
|
171
|
-
)}
|
|
172
|
-
|
|
173
|
-
{/* System prompt */}
|
|
174
|
-
<div>
|
|
175
|
-
<label className="text-xs text-slate-400">System prompt</label>
|
|
176
|
-
<textarea className="glass-code mt-1 h-16 w-full p-2 text-sm" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} placeholder="Set the assistant's behaviour…" />
|
|
177
|
-
</div>
|
|
178
|
-
|
|
179
|
-
{/* Chat messages */}
|
|
180
|
-
<div className="glass-code max-h-96 min-h-[200px] space-y-3 overflow-y-auto p-3 text-sm">
|
|
181
|
-
{messages.length === 0 && !streamText && <p className="text-slate-500">Start a conversation by typing below.</p>}
|
|
182
|
-
{messages.map((m, i) => (
|
|
183
|
-
<div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
184
|
-
<div className={`max-w-[80%] rounded-xl px-3 py-2 ${m.role === 'user' ? 'bg-indigo-500/20 text-slate-100' : m.role === 'assistant' ? 'bg-emerald-500/15 text-slate-100' : 'bg-slate-700/30 text-slate-400'}`}>
|
|
185
|
-
<span className="mb-1 block text-[10px] uppercase tracking-wider opacity-60">{m.role}</span>
|
|
186
|
-
<span className="whitespace-pre-wrap">{m.content}</span>
|
|
187
|
-
</div>
|
|
188
|
-
</div>
|
|
189
|
-
))}
|
|
190
|
-
{streaming && (
|
|
191
|
-
<div className="flex justify-start">
|
|
192
|
-
<div className="max-w-[80%] rounded-xl bg-emerald-500/15 px-3 py-2 text-slate-100">
|
|
193
|
-
<span className="mb-1 block text-[10px] uppercase tracking-wider opacity-60">assistant <span className="animate-pulse">…</span></span>
|
|
194
|
-
<span className="whitespace-pre-wrap">{streamText}<span className="animate-pulse">â–‹</span></span>
|
|
195
|
-
</div>
|
|
196
|
-
</div>
|
|
197
|
-
)}
|
|
198
|
-
</div>
|
|
199
|
-
|
|
200
|
-
{/* Input */}
|
|
201
|
-
<div className="flex gap-2">
|
|
202
|
-
<textarea className="glass-code flex-1 h-12 p-2 text-sm" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={sendOnEnter} placeholder="Type a message… (Enter to send, Shift+Enter for newline)" disabled={streaming} />
|
|
203
|
-
<button onClick={send} disabled={streaming || !input.trim()} className="glass-btn-primary shrink-0">{streaming ? '…' : 'Send'}</button>
|
|
204
|
-
</div>
|
|
205
|
-
</section>
|
|
206
|
-
</div>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
@@ -1,276 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
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
|
-
}
|