diff --git a/decnet_web/src/components/Config/tabs/LLMTab.css b/decnet_web/src/components/Config/tabs/LLMTab.css new file mode 100644 index 00000000..f1faafb3 --- /dev/null +++ b/decnet_web/src/components/Config/tabs/LLMTab.css @@ -0,0 +1,84 @@ +/* LLMTab — layered on DeckyFleet.css + PersonaGeneration.css. + Scoped to .llm-tab-root. Same vocabulary as the old standalone page. */ + +.llm-tab-root .info-banner { + background: var(--matrix-tint-5); + border: 1px solid var(--border); + border-left: 3px solid var(--violet); + padding: 10px 14px; + font-size: 0.78rem; + line-height: 1.5; + margin-bottom: 20px; +} + +.llm-tab-root .info-banner em { color: var(--matrix); font-style: normal; } + +.llm-tab-root .form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.llm-tab-root .form-grid.single-col { + grid-template-columns: 1fr; +} + +.llm-tab-root .tweak-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.llm-tab-root .tweak-group label { + font-size: 0.62rem; + letter-spacing: 1.5px; + color: var(--dim); + text-transform: uppercase; +} + +.llm-tab-root .input { + background: var(--bg-elev, rgba(0, 0, 0, 0.3)); + border: 1px solid var(--border); + color: var(--text); + padding: 7px 10px; + font-family: inherit; + font-size: 0.8rem; + outline: none; + transition: border-color 0.15s; + width: 100%; + box-sizing: border-box; +} + +.llm-tab-root .input:focus { + border-color: var(--violet); + box-shadow: 0 0 0 1px var(--violet); +} + +.llm-tab-root select.input { cursor: pointer; } + +.llm-tab-root .key-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.7rem; + font-family: var(--font-mono); + color: var(--matrix); + letter-spacing: 0.5px; + margin-top: 2px; +} + +.llm-tab-root .key-badge.unset { color: var(--dim); } + +.llm-tab-root .actions { + display: flex; + gap: 10px; + margin-top: 4px; +} + +.llm-tab-root .field-hint { + font-size: 0.65rem; + opacity: 0.4; + letter-spacing: 0.4px; + margin-top: 2px; +} diff --git a/decnet_web/src/components/Config/tabs/LLMTab.test.tsx b/decnet_web/src/components/Config/tabs/LLMTab.test.tsx index e365a3ce..180a1fb7 100644 --- a/decnet_web/src/components/Config/tabs/LLMTab.test.tsx +++ b/decnet_web/src/components/Config/tabs/LLMTab.test.tsx @@ -40,7 +40,7 @@ describe('LLMTab', () => { apiGet.mockResolvedValueOnce({ data: { ...defaultPayload, api_key_set: true } }); render(); await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); - expect(screen.getByText(/Key stored/)).toBeDefined(); + expect(screen.getByText(/KEY SET/)).toBeDefined(); }); it('calls PUT on save and shows success', async () => { @@ -71,7 +71,7 @@ describe('LLMTab', () => { await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); await user.click(screen.getByRole('button', { name: /SAVE/ })); - await waitFor(() => expect(screen.getByText(/ADMIN ROLE REQUIRED/)).toBeDefined()); + await waitFor(() => expect(screen.getByText(/Admin role required/)).toBeDefined()); }); it('hides save button for viewers', async () => { diff --git a/decnet_web/src/components/Config/tabs/LLMTab.tsx b/decnet_web/src/components/Config/tabs/LLMTab.tsx index 98ff3538..88a938fe 100644 --- a/decnet_web/src/components/Config/tabs/LLMTab.tsx +++ b/decnet_web/src/components/Config/tabs/LLMTab.tsx @@ -1,6 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { Save, CheckCircle } from '../../../icons'; +import { Save, CheckCircle, AlertTriangle } from '../../../icons'; import api from '../../../utils/api'; +import { useToast } from '../../Toasts/useToast'; +import '../../DeckyFleet.css'; +import '../../PersonaGeneration.css'; +import './LLMTab.css'; interface LLMPayload { provider: string; @@ -31,23 +35,24 @@ interface Props { } export const LLMTab: React.FC = ({ isAdmin }) => { + const { push } = useToast(); const [cfg, setCfg] = useState(DEFAULTS); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [msg, setMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [error, setError] = useState(null); const [apiKeyInput, setApiKeyInput] = useState(''); const [clearApiKey, setClearApiKey] = useState(false); useEffect(() => { api.get('/realism/llm') .then((r) => setCfg(r.data)) - .catch(() => setMsg({ type: 'error', text: 'FAILED TO LOAD LLM CONFIG' })) + .catch(() => setError('Failed to load LLM config.')) .finally(() => setLoading(false)); }, []); const handleSave = async () => { setSaving(true); - setMsg(null); + setError(null); const body: PutBody = { provider: cfg.provider, base_url: cfg.base_url || null, @@ -62,161 +67,155 @@ export const LLMTab: React.FC = ({ isAdmin }) => { setCfg(r.data); setApiKeyInput(''); setClearApiKey(false); - setMsg({ type: 'success', text: 'LLM CONFIG SAVED' }); + push({ text: 'LLM CONFIG SAVED', tone: 'matrix', icon: 'terminal' }); } catch (err: any) { const detail = err?.response?.data?.detail; const status = err?.response?.status; - if (status === 403) setMsg({ type: 'error', text: 'ADMIN ROLE REQUIRED' }); - else if (status === 400 && detail) setMsg({ type: 'error', text: `VALIDATION FAILED: ${detail}` }); - else setMsg({ type: 'error', text: 'SAVE FAILED' }); + if (status === 403) setError('Admin role required to save.'); + else if (status === 400 && detail) setError(`Validation failed: ${detail}`); + else setError('Save failed.'); } finally { setSaving(false); } }; if (loading) { - return
LOADING…
; + return
Loading…
; } return ( -
-
- PROVIDER - {isAdmin ? ( +
+
+
+ Scope: configures the LLM backend used for{' '} + email bodies, drafts, and notes generated by the realism + subsystem. Changes are persisted and hot-reloaded immediately; the + orchestrator worker picks them up within one refresh tick (~5 min). +
+ {error && ( +
+ {error} +
+ )} +
+ +
+
+ - ) : ( - {cfg.provider} - )} +
+ +
+ + setCfg({ ...cfg, model: e.target.value })} + /> +
-
- BASE URL - {isAdmin ? ( - <> -
- setCfg({ ...cfg, base_url: e.target.value || null })} - /> -
- - Leave blank to use local Ollama subprocess. Set to the daemon URL when targeting a remote host. - - - ) : ( - {cfg.base_url || '(subprocess)'} - )} +
+
+ + setCfg({ ...cfg, base_url: e.target.value || null })} + /> + + Blank = use local ollama run subprocess. + Set to the daemon URL to target a remote host. + +
-
- MODEL - {isAdmin ? ( -
- setCfg({ ...cfg, model: e.target.value })} - /> -
- ) : ( - {cfg.model} - )} -
+
+
+ + { + const v = parseFloat(e.target.value); + if (v > 0) setCfg({ ...cfg, timeout: v }); + }} + /> +
-
- TIMEOUT (seconds) - {isAdmin ? ( -
- { - const v = parseFloat(e.target.value); - if (v > 0) setCfg({ ...cfg, timeout: v }); - }} - /> -
- ) : ( - {cfg.timeout}s - )} -
- - {isAdmin && ( -
- API KEY (write-only) +
+ {cfg.api_key_set && !clearApiKey ? ( -
- - Key stored - - -
+ <> +
+ KEY SET — enter a new value to rotate +
+ {isAdmin && ( +
+ +
+ )} + ) : ( -
+ <> setApiKeyInput(e.target.value)} /> - {clearApiKey && ( - + {clearApiKey && isAdmin && ( +
+ +
)} -
+ )}
- )} +
{isAdmin && ( -
-
- -
- {msg && ( - - {msg.text} - - )} -
- )} - - {!isAdmin && ( -
- API KEY - {cfg.api_key_set ? '••••••••' : '(not set)'} +
+
)}