From c66749209f54f8741c291ade9e301cbdc2949156 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 23:15:27 -0400 Subject: [PATCH] feat(ui): LLMConfig panel + route (/realism-llm) + nav entry --- decnet_web/src/App.tsx | 2 + .../src/components/LLMConfig/LLMConfig.css | 85 +++++++ .../components/LLMConfig/LLMConfig.test.tsx | 133 ++++++++++ .../src/components/LLMConfig/LLMConfig.tsx | 239 ++++++++++++++++++ decnet_web/src/components/Layout.tsx | 2 + 5 files changed, 461 insertions(+) create mode 100644 decnet_web/src/components/LLMConfig/LLMConfig.css create mode 100644 decnet_web/src/components/LLMConfig/LLMConfig.test.tsx create mode 100644 decnet_web/src/components/LLMConfig/LLMConfig.tsx diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index dc1c22e9..f8059a03 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -28,6 +28,7 @@ const Orchestrator = lazy(() => import('./components/Orchestrator')); const PersonaGeneration = lazy(() => import('./components/PersonaGeneration')); const SyntheticFiles = lazy(() => import('./components/SyntheticFiles/SyntheticFiles')); const RealismConfig = lazy(() => import('./components/RealismConfig/RealismConfig')); +const LLMConfig = lazy(() => import('./components/LLMConfig/LLMConfig')); const CanaryTokens = lazy(() => import('./components/CanaryTokens')); const TopologyPersonaGeneration = lazy(() => import('./components/PersonaGeneration').then((m) => ({ default: m.TopologyPersonaGeneration })), @@ -138,6 +139,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/LLMConfig/LLMConfig.css b/decnet_web/src/components/LLMConfig/LLMConfig.css new file mode 100644 index 00000000..2aeb81e7 --- /dev/null +++ b/decnet_web/src/components/LLMConfig/LLMConfig.css @@ -0,0 +1,85 @@ +/* LLM Config — layered on DeckyFleet.css + PersonaGeneration.css. + Adds: form field grid, masked-key indicator, provider selector. */ + +.llm-config-root .form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 24px; +} + +.llm-config-root .form-grid.single-col { + grid-template-columns: 1fr; +} + +.llm-config-root .tweak-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.llm-config-root .tweak-group label { + font-size: 0.62rem; + letter-spacing: 1.5px; + color: var(--dim); + text-transform: uppercase; +} + +.llm-config-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-config-root .input:focus { + border-color: var(--violet); + box-shadow: 0 0 0 1px var(--violet); +} + +.llm-config-root select.input { + cursor: pointer; +} + +.llm-config-root .key-badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.65rem; + font-family: var(--font-mono); + color: var(--matrix); + letter-spacing: 0.5px; + margin-top: 4px; +} + +.llm-config-root .key-badge.unset { + color: var(--dim); +} + +.llm-config-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-config-root .info-banner em { color: var(--matrix); font-style: normal; } + +.llm-config-root .section-head { + font-size: 0.7rem; + letter-spacing: 1.5px; + color: var(--dim); + text-transform: uppercase; + margin: 0 0 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); +} diff --git a/decnet_web/src/components/LLMConfig/LLMConfig.test.tsx b/decnet_web/src/components/LLMConfig/LLMConfig.test.tsx new file mode 100644 index 00000000..f54bc7a9 --- /dev/null +++ b/decnet_web/src/components/LLMConfig/LLMConfig.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import LLMConfig from './LLMConfig'; +import { renderWithRouter } from '../../test/renderWithRouter'; + +vi.mock('../../utils/api', () => ({ + default: { get: vi.fn(), put: vi.fn() }, +})); + +import api from '../../utils/api'; +const apiGet = api.get as ReturnType; +const apiPut = api.put as ReturnType; + +const defaultPayload = { + provider: 'ollama', + base_url: null, + model: 'llama3.1', + timeout: 60, + api_key_set: false, +}; + +const renderPage = () => renderWithRouter(); + +describe('LLMConfig', () => { + beforeEach(() => { + apiGet.mockReset(); + apiPut.mockReset(); + }); + + it('renders provider and model from loaded config', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + expect(screen.getByDisplayValue('Ollama')).toBeDefined(); + expect(screen.getByDisplayValue('llama3.1')).toBeDefined(); + }); + + it('shows api_key_set indicator when key is stored', async () => { + apiGet.mockResolvedValueOnce({ + data: { ...defaultPayload, api_key_set: true }, + }); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + expect(screen.getByText(/KEY SET/)).toBeDefined(); + }); + + it('shows password input when no key is stored', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + const input = screen.getByPlaceholderText(/Enter key to set/); + expect(input).toBeDefined(); + }); + + it('calls PUT with correct body on save', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + apiPut.mockResolvedValueOnce({ + data: { ...defaultPayload, model: 'phi3' }, + }); + + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + + const modelInput = screen.getByDisplayValue('llama3.1'); + await user.clear(modelInput); + await user.type(modelInput, 'phi3'); + + await user.click(screen.getByRole('button', { name: /SAVE/ })); + + await waitFor(() => expect(apiPut).toHaveBeenCalledOnce()); + const [url, body] = apiPut.mock.calls[0]; + expect(url).toBe('/realism/llm'); + expect(body.model).toBe('phi3'); + expect(body.api_key).toBeUndefined(); + }); + + it('includes api_key in PUT body when entered', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + apiPut.mockResolvedValueOnce({ + data: { ...defaultPayload, api_key_set: true }, + }); + + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + + const keyInput = screen.getByPlaceholderText(/Enter key to set/); + await user.type(keyInput, 'sk-secret'); + + await user.click(screen.getByRole('button', { name: /SAVE/ })); + + await waitFor(() => expect(apiPut).toHaveBeenCalledOnce()); + const [, body] = apiPut.mock.calls[0]; + expect(body.api_key).toBe('sk-secret'); + }); + + it('sends api_key="" when CLEAR is clicked', async () => { + apiGet.mockResolvedValueOnce({ + data: { ...defaultPayload, api_key_set: true }, + }); + apiPut.mockResolvedValueOnce({ + data: { ...defaultPayload, api_key_set: false }, + }); + + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + + await user.click(screen.getByRole('button', { name: /CLEAR/ })); + await user.click(screen.getByRole('button', { name: /SAVE/ })); + + await waitFor(() => expect(apiPut).toHaveBeenCalledOnce()); + const [, body] = apiPut.mock.calls[0]; + expect(body.api_key).toBe(''); + }); + + it('shows error when save returns 403', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + apiPut.mockRejectedValueOnce({ response: { status: 403 } }); + + const user = userEvent.setup(); + renderPage(); + await waitFor(() => expect(screen.queryByText('Loading…')).toBeNull()); + + await user.click(screen.getByRole('button', { name: /SAVE/ })); + + await waitFor(() => + expect(screen.getByText(/Admin role required/)).toBeDefined(), + ); + }); +}); diff --git a/decnet_web/src/components/LLMConfig/LLMConfig.tsx b/decnet_web/src/components/LLMConfig/LLMConfig.tsx new file mode 100644 index 00000000..94c04d9d --- /dev/null +++ b/decnet_web/src/components/LLMConfig/LLMConfig.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useState } from 'react'; +import api from '../../utils/api'; +import { useToast } from '../Toasts/useToast'; +import { Save, Cpu, AlertTriangle, CheckCircle } from '../../icons'; +import '../DeckyFleet.css'; +import '../PersonaGeneration.css'; +import './LLMConfig.css'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface LLMConfigPayload { + provider: string; + base_url: string | null; + model: string; + timeout: number; + api_key_set: boolean; +} + +interface PutBody { + provider?: string; + base_url?: string | null; + model?: string; + timeout?: number; + api_key?: string; +} + +const DEFAULTS: LLMConfigPayload = { + provider: 'ollama', + base_url: null, + model: 'llama3.1', + timeout: 60, + api_key_set: false, +}; + +// ─── Page ──────────────────────────────────────────────────────────────────── + +const LLMConfig: React.FC = () => { + const { push } = useToast(); + const [config, setConfig] = useState(DEFAULTS); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [apiKeyInput, setApiKeyInput] = useState(''); + const [clearApiKey, setClearApiKey] = useState(false); + + const fetchConfig = async () => { + setLoading(true); + setError(null); + try { + const res = await api.get('/realism/llm'); + setConfig(res.data); + } catch (err: any) { + setError(err?.response?.status === 401 ? 'Authentication required.' : 'Load failed.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { fetchConfig(); }, []); + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + const body: PutBody = { + provider: config.provider, + base_url: config.base_url || null, + model: config.model, + timeout: config.timeout, + }; + if (clearApiKey) { + body.api_key = ''; + } else if (apiKeyInput.trim()) { + body.api_key = apiKeyInput.trim(); + } + + const res = await api.put('/realism/llm', body); + setConfig(res.data); + setApiKeyInput(''); + setClearApiKey(false); + 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) setError('Admin role required to save.'); + else if (status === 400 && detail) setError(`Validation failed: ${detail}`); + else setError('Save failed.'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+
+ +

LLM PROVIDER CONFIG

+
+ + {config.provider.toUpperCase()} · {config.model} + {config.base_url ? ` · ${config.base_url}` : ' · subprocess'} + +
+
+ +
+
+ +
+
+ Scope: configures the LLM backend used for{' '} + email bodies, drafts, and notes generated by the realism + subsystem. Changes are persisted to{' '} + realism_config and + hot-reloaded in this API process immediately; the orchestrator worker + picks them up within one refresh tick (~5 minutes). +
+ {error && ( +
+ {error} +
+ )} +
+ + {loading ? ( +
Loading…
+ ) : ( + <> +
Provider
+ +
+
+ + +
+ +
+ + setConfig({ ...config, model: e.target.value })} + /> +
+
+ +
+
+ + + setConfig({ ...config, base_url: e.target.value || null }) + } + /> +
+
+ +
+
+ + { + const v = parseFloat(e.target.value); + if (v > 0) setConfig({ ...config, timeout: v }); + }} + /> +
+ +
+ + {config.api_key_set && !clearApiKey ? ( + <> +
+ KEY SET — enter a new value to rotate, or + +
+ + ) : ( + <> + setApiKeyInput(e.target.value)} + /> + {clearApiKey && ( + + )} + + )} +
+
+ + )} +
+ ); +}; + +export default LLMConfig; diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 452bce9b..d9bd3f55 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -39,6 +39,7 @@ const ROUTE_LABELS: Record = { '/persona-generation': 'PERSONA GENERATION', '/synthetic-files': 'SYNTHETIC FILES', '/realism-config': 'REALISM CONFIG', + '/realism-llm': 'LLM PROVIDER', '/canary-tokens': 'CANARY TOKENS', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', @@ -153,6 +154,7 @@ const Layout: React.FC = ({ } label="Persona Generation" open={sidebarOpen} indent /> } label="Synthetic Files" open={sidebarOpen} indent /> } label="Realism Config" open={sidebarOpen} indent /> + } label="LLM Provider" open={sidebarOpen} indent /> } label="Canary Tokens" open={sidebarOpen} indent /> } open={sidebarOpen}>