@bhooai/nexus-cli 2.0.6 → 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 (61) 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
@@ -0,0 +1,144 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { getUsers, getRoles, putUserRoles, type UserRecord, type RoleDefinition } from '../api.js';
3
+ import { useAdminAlert } from '../alertCenter.js';
4
+ import { PageHead } from '../components/ui/PageHead.js';
5
+ import { PageHero } from '../components/ui/PageHero.js';
6
+
7
+ export function Users({ currentUserEmail }: { currentUserEmail?: string }) {
8
+ const [data, setData] = useState<{ users: UserRecord[] } | null>(null);
9
+ const [roles, setRoles] = useState<RoleDefinition[]>([]);
10
+ const [msg, setMsg] = useAdminAlert('users');
11
+ const [editing, setEditing] = useState<UserRecord | null>(null);
12
+ const [draft, setDraft] = useState<string[]>([]);
13
+ const [busy, setBusy] = useState(false);
14
+
15
+ const reload = useCallback(async () => {
16
+ try {
17
+ const [u, r] = await Promise.all([getUsers(), getRoles()]);
18
+ setData(u);
19
+ setRoles(r?.roles ?? []);
20
+ } catch (e: any) {
21
+ setMsg({ kind: 'err', text: `Could not load users or roles: ${e?.message ?? 'unknown error'}` });
22
+ }
23
+ }, []);
24
+
25
+ useEffect(() => { void reload(); }, [reload]);
26
+
27
+ const openEditor = (u: UserRecord) => { setEditing(u); setDraft([...(u.roles ?? [])]); setMsg(null); };
28
+ const toggleRole = (id: string) => setDraft((d) => (d.includes(id) ? d.filter((r) => r !== id) : [...d, id]));
29
+
30
+ const saveRoles = async () => {
31
+ if (!editing) return;
32
+ if (!draft.length) { setMsg({ kind: 'err', text: 'A user must keep at least one role.' }); return; }
33
+ setBusy(true);
34
+ try {
35
+ await putUserRoles(editing._id, draft);
36
+ setMsg({ kind: 'ok', text: `Saved roles for ${editing.email}.` });
37
+ setEditing(null);
38
+ await reload();
39
+ } catch (e: any) {
40
+ const raw = String(e?.message ?? 'Update failed');
41
+ const clean = raw.replace(/^.*\/admin\/users[^\s]*\s*\d+\s*/i, '').trim();
42
+ setMsg({ kind: 'err', text: clean || 'Update failed.' });
43
+ } finally { setBusy(false); }
44
+ };
45
+
46
+ const roleDef = (id: string) => roles.find((r) => r.id === id);
47
+ const roleChip = (id: string) => {
48
+ const def = roleDef(id);
49
+ const accent = def?.accent ?? '#8ba0bd';
50
+ return <span key={id} className="role-chip" style={{ color: accent, borderColor: `${accent}66`, background: `${accent}1a` }}>{def?.icon ?? '◌'} {def?.label ?? id}</span>;
51
+ };
52
+
53
+ if (!data) return <p className="text-slate-400">Loading…</p>;
54
+
55
+ return (
56
+ <div className="space-y-6">
57
+ <PageHead title="Users & Roles" />
58
+ <PageHero kicker="ACCESS & PERMISSIONS" title={<>Your team, <em>in control.</em></>} desc="Every account that can reach this Nexus surface — with role grants and restrictions applied in one built-in permission catalog." glyph="users" art="people" />
59
+
60
+ <div className="glass-panel overflow-hidden p-2">
61
+ <table className="glass-table">
62
+ <thead><tr><th>Email</th><th className="text-center">Name</th><th>Roles</th><th className="text-center">Verified</th><th className="text-right">Actions</th></tr></thead>
63
+ <tbody>
64
+ {data.users.map((u) => (
65
+ <tr key={String(u._id)}>
66
+ <td className="text-slate-200">{u.email}{u.email === currentUserEmail && <span className="you-tag">you</span>}</td>
67
+ <td className="text-slate-300">{u.name ?? '—'}</td>
68
+ <td>
69
+ {(u.roles ?? []).map(roleChip)}
70
+ {!(u.roles ?? []).length && '—'}
71
+ </td>
72
+ <td className="text-center text-emerald-400">{u.emailVerified ? '✓' : <span className="text-slate-500">—</span>}</td>
73
+ <td className="text-right"><button className="glass-chip-btn" onClick={() => openEditor(u)}>â—ˆ Edit roles</button></td>
74
+ </tr>
75
+ ))}
76
+ {!data.users.length && <tr><td colSpan={5} className="py-8 text-center text-slate-500">No users registered.</td></tr>}
77
+ </tbody>
78
+ </table>
79
+ </div>
80
+
81
+ <div className="role-catalog">
82
+ {roles.map((r) => (
83
+ <div className="role-box" key={r.id} style={{ ['--role-accent' as string]: r.accent }}>
84
+ <div className="role-box-head">
85
+ <span className="role-box-icon" style={{ color: r.accent }}>{r.icon}</span>
86
+ <div>
87
+ <strong style={{ color: r.accent }}>{r.label}</strong>
88
+ <code className="role-box-id">{r.id}</code>
89
+ </div>
90
+ </div>
91
+ <p className="role-box-desc">{r.description}</p>
92
+ <div className="role-section">
93
+ <span className="role-section-title is-can">✓ Can do</span>
94
+ <ul className="role-list">
95
+ {r.grants.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
96
+ </ul>
97
+ </div>
98
+ <div className="role-section">
99
+ <span className="role-section-title is-no">✕ Restricted from</span>
100
+ <ul className="role-list">
101
+ {r.restricts.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
102
+ </ul>
103
+ </div>
104
+ </div>
105
+ ))}
106
+ </div>
107
+
108
+ {editing && (
109
+ <div className="confirm-overlay" onClick={() => !busy && setEditing(null)}>
110
+ <div className="confirm-dialog role-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
111
+ <div className="confirm-dialog-head">
112
+ <span className="confirm-dialog-mark">â—Ž</span>
113
+ <strong>Edit roles · {editing.email}</strong>
114
+ <button className="confirm-dialog-close" onClick={() => !busy && setEditing(null)} aria-label="Close">×</button>
115
+ </div>
116
+ <p className="confirm-dialog-message">Choose which roles this account holds — every selected role applies.</p>
117
+ <div className="role-options">
118
+ {roles.map((r) => {
119
+ const on = draft.includes(r.id);
120
+ return (
121
+ <button key={r.id} type="button" className={`role-option${on ? ' is-on' : ''}`} style={{ ['--role-accent' as string]: r.accent }} onClick={() => toggleRole(r.id)}>
122
+ <span className="role-option-icon" style={{ color: r.accent }}>{on ? '✓' : r.icon}</span>
123
+ <span className="role-option-text">
124
+ <b>{r.label}</b>
125
+ <small>{r.description}</small>
126
+ </span>
127
+ <span className="role-option-check">{on ? '✓' : ''}</span>
128
+ </button>
129
+ );
130
+ })}
131
+ </div>
132
+ {editing.email === currentUserEmail && (
133
+ <p className="role-hint">Editing your own account — removing <b>Administrator</b> from yourself is blocked server-side.</p>
134
+ )}
135
+ <div className="confirm-dialog-actions">
136
+ <button className="glass-chip-btn" onClick={() => setEditing(null)} disabled={busy}>Cancel</button>
137
+ <button className="glass-chip-btn-danger confirm-dialog-primary" onClick={saveRoles} disabled={busy}>{busy ? 'Saving…' : 'Save roles'}</button>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ )}
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,150 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { POPULAR_PROVIDERS, PRESET_BY_ID } from '../../lib/constants.js';
3
+ import type { ProviderPreset } from '../../lib/types.js';
4
+
5
+ export function AddProviderDialog({ value, onChange, onSubmit, onCancel, busy }: {
6
+ value: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string };
7
+ onChange: (v: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string }) => void;
8
+ onSubmit: () => void;
9
+ onCancel: () => void;
10
+ busy: boolean;
11
+ }) {
12
+ const [presetId, setPresetId] = useState('');
13
+
14
+ useEffect(() => {
15
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); };
16
+ window.addEventListener('keydown', onKey);
17
+ return () => window.removeEventListener('keydown', onKey);
18
+ }, [onCancel]);
19
+
20
+ // Track which field the user is actively editing so we don't clobber their
21
+ // manual input when the preset changes the model list.
22
+ const preset = presetId ? PRESET_BY_ID[presetId] : undefined;
23
+
24
+ const applyPreset = (id: string) => {
25
+ setPresetId(id);
26
+ if (!id) return; // "— select —"
27
+ if (id === 'other') {
28
+ // Clear fields for a fully custom provider; user fills everything.
29
+ onChange({ id: '', label: '', baseUrl: '', apiKey: value.apiKey, defaultModel: '' });
30
+ return;
31
+ }
32
+ const p = PRESET_BY_ID[id];
33
+ if (p) {
34
+ onChange({ ...value, id: p.id, label: p.label, baseUrl: p.baseUrl, defaultModel: p.defaultModel });
35
+ }
36
+ };
37
+
38
+ const missing = {
39
+ id: !value.id.trim(),
40
+ label: !value.label.trim(),
41
+ baseUrl: !value.baseUrl.trim(),
42
+ };
43
+
44
+ return (
45
+ <div className="provider-modal-backdrop" onClick={onCancel}>
46
+ <div className="provider-modal space-y-4" onClick={(e) => e.stopPropagation()}>
47
+ <div className="flex items-center justify-between">
48
+ <h3 className="glass-h3">Add custom provider</h3>
49
+ <button onClick={onCancel} className="glass-chip-btn text-xs">✕</button>
50
+ </div>
51
+
52
+ <div className="space-y-3">
53
+ {/* Preset selector — pick a popular provider or "Other" */}
54
+ <label className="block">
55
+ <span className="admin-overline">Choose a provider</span>
56
+ <select
57
+ className="glass-input mt-1 text-sm"
58
+ value={presetId}
59
+ onChange={(e) => applyPreset(e.target.value)}
60
+ >
61
+ <option value="" className="bg-slate-900">— select —</option>
62
+ {POPULAR_PROVIDERS.map((p) => (
63
+ <option key={p.id} value={p.id} className="bg-slate-900">{p.label}</option>
64
+ ))}
65
+ <option value="other" className="bg-slate-900">Other (custom)</option>
66
+ </select>
67
+ <span className="text-[10px] text-slate-500">Selecting a provider autofills the fields below — all editable.</span>
68
+ </label>
69
+
70
+ <label className="block">
71
+ <span className="admin-overline">Provider ID <span className="text-rose-400">*</span></span>
72
+ <input
73
+ className={`glass-input mt-1 text-sm font-mono ${missing.id ? '!border-amber-500/50' : ''}`}
74
+ placeholder="e.g. myai"
75
+ value={value.id}
76
+ onChange={(e) => onChange({ ...value, id: e.target.value })}
77
+ />
78
+ {missing.id && <span className="text-[10px] text-amber-400">required — lowercase, no spaces</span>}
79
+ </label>
80
+
81
+ <label className="block">
82
+ <span className="admin-overline">Label <span className="text-rose-400">*</span></span>
83
+ <input
84
+ className={`glass-input mt-1 text-sm ${missing.label ? '!border-amber-500/50' : ''}`}
85
+ placeholder="e.g. My AI Service"
86
+ value={value.label}
87
+ onChange={(e) => onChange({ ...value, label: e.target.value })}
88
+ />
89
+ {missing.label && <span className="text-[10px] text-amber-400">required</span>}
90
+ </label>
91
+
92
+ <label className="block">
93
+ <span className="admin-overline">Base URL <span className="text-rose-400">*</span></span>
94
+ <input
95
+ className={`glass-input mt-1 text-sm font-mono ${missing.baseUrl ? '!border-amber-500/50' : ''}`}
96
+ placeholder="https://api.example.com/v1"
97
+ value={value.baseUrl}
98
+ onChange={(e) => onChange({ ...value, baseUrl: e.target.value })}
99
+ />
100
+ {missing.baseUrl && <span className="text-[10px] text-amber-400">required</span>}
101
+ </label>
102
+
103
+ <label className="block">
104
+ <span className="admin-overline">API key <span className="text-slate-500">(optional)</span></span>
105
+ <input
106
+ className="glass-input mt-1 text-sm"
107
+ type="password"
108
+ placeholder="paste API key"
109
+ value={value.apiKey}
110
+ onChange={(e) => onChange({ ...value, apiKey: e.target.value })}
111
+ />
112
+ </label>
113
+
114
+ {/* Default model — text input + a "pick from popular" select */}
115
+ <label className="block">
116
+ <span className="admin-overline">Default model <span className="text-slate-500">(optional)</span></span>
117
+ <input
118
+ className="glass-input mt-1 text-sm font-mono"
119
+ placeholder="e.g. gpt-4o-mini"
120
+ value={value.defaultModel}
121
+ onChange={(e) => onChange({ ...value, defaultModel: e.target.value })}
122
+ />
123
+ </label>
124
+ {preset && preset.models.length > 0 && (
125
+ <label className="block">
126
+ <span className="admin-overline">…or pick a model</span>
127
+ <select
128
+ className="glass-input mt-1 text-sm font-mono"
129
+ value=""
130
+ onChange={(e) => { if (e.target.value) onChange({ ...value, defaultModel: e.target.value }); }}
131
+ >
132
+ <option value="" className="bg-slate-900">{preset.models.length} popular model{preset.models.length === 1 ? '' : 's'} — click to use</option>
133
+ {preset.models.map((m) => (
134
+ <option key={m} value={m} className="bg-slate-900">{m}</option>
135
+ ))}
136
+ </select>
137
+ </label>
138
+ )}
139
+ </div>
140
+
141
+ <div className="flex justify-end gap-2 pt-2 border-t border-sky-400/20">
142
+ <button onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
143
+ <button onClick={onSubmit} disabled={busy || missing.id || missing.label || missing.baseUrl} className="glass-btn-primary !py-1.5 text-sm">
144
+ {busy ? 'Adding…' : '+ Add provider'}
145
+ </button>
146
+ </div>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
@@ -0,0 +1,31 @@
1
+ import React, { useState } from 'react';
2
+ import { Icon } from '../../icons.js';
3
+ import type { ToggleStyle } from '../../lib/types.js';
4
+ import { PageHead } from '../../components/ui/PageHead.js';
5
+ import { PageHero } from '../../components/ui/PageHero.js';
6
+ import { AiSettings } from './AiSettings.js';
7
+ import { AiProviders } from './AiProviders.js';
8
+ import { AiChatPlayground } from './AiChatPlayground.js';
9
+
10
+ export function AiAgents({ toggle }: { toggle: ToggleStyle }) {
11
+ const [aiTab, setAiTab] = useState<'settings' | 'providers' | 'chat'>('settings');
12
+ return (
13
+ <div className="space-y-6">
14
+ <PageHead title="AI Agents" />
15
+ <PageHero kicker="AI WORKSPACE" title={<>Agents, <em>at your command.</em></>} desc="Manage AI settings, configure providers with API keys, and test agents with a live chat playground." glyph="sparkles" art="spark" />
16
+
17
+ {/* Sub-tab nav */}
18
+ <div className="workspace-tabs">
19
+ {([['settings', 'AI Settings', 'settings'], ['providers', 'AI Providers', 'grid'], ['chat', 'Chat Playground', 'message']] as const).map(([id, label, icon]) => (
20
+ <button key={id} onClick={() => setAiTab(id)} className={aiTab === id ? 'is-active' : ''}>
21
+ <span className="mr-1.5"><Icon name={icon} size={13} /></span>{label}
22
+ </button>
23
+ ))}
24
+ </div>
25
+
26
+ {aiTab === 'settings' && <AiSettings />}
27
+ {aiTab === 'providers' && <AiProviders toggle={toggle} />}
28
+ {aiTab === 'chat' && <AiChatPlayground />}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,208 @@
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
+ }