@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.
- 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
|
@@ -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
|
+
}
|