diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index f8059a03..dc1c22e9 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -28,7 +28,6 @@ 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 })), @@ -139,7 +138,6 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> - } /> } /> } /> } /> diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index 78158bf4..67d82ba6 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; import { - Settings, Users, Sliders, Shield, Palette, Activity, + Settings, Users, Sliders, Shield, Palette, Activity, Cpu, } from '../icons'; import { useToast } from './Toasts/useToast'; import RuleStateControls from './RuleStateControls'; -import './Dashboard.css'; +import './DeckyFleet.css'; import './Config.css'; import type { ConfigTab } from './Config/types'; import { useConfig } from './Config/useConfig'; @@ -13,6 +13,7 @@ import { LimitsTab } from './Config/tabs/LimitsTab'; import { UsersTab } from './Config/tabs/UsersTab'; import { GlobalsTab } from './Config/tabs/GlobalsTab'; import { AppearanceTab } from './Config/tabs/AppearanceTab'; +import { LLMTab } from './Config/tabs/LLMTab'; const Config: React.FC = () => { const { @@ -63,14 +64,19 @@ const Config: React.FC = () => { ...(isAdmin ? [{ key: 'ttp' as const, label: 'TTP RULES', icon: }] : []), + ...(isAdmin + ? [{ key: 'llm' as const, label: 'LLM PROVIDER', icon: }] + : []), ]; return ( -
-
-
- -

SYSTEM CONFIGURATION

+
+
+
+
+ +

SYSTEM CONFIGURATION

+
@@ -124,6 +130,8 @@ const Config: React.FC = () => { {/* RuleStateControls also self-gates on /config?.role so a state leak can't render it. */} {activeTab === 'ttp' && isAdmin && } + + {activeTab === 'llm' && isAdmin && }
); }; diff --git a/decnet_web/src/components/Config/tabs/LLMTab.test.tsx b/decnet_web/src/components/Config/tabs/LLMTab.test.tsx new file mode 100644 index 00000000..e365a3ce --- /dev/null +++ b/decnet_web/src/components/Config/tabs/LLMTab.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LLMTab } from './LLMTab'; +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 render = (isAdmin = true) => + renderWithRouter(); + +describe('LLMTab', () => { + beforeEach(() => { + apiGet.mockReset(); + apiPut.mockReset(); + }); + + it('renders current model after load', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + render(); + await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); + expect(screen.getByDisplayValue('llama3.1')).toBeDefined(); + }); + + it('shows key-stored indicator when api_key_set is true', async () => { + apiGet.mockResolvedValueOnce({ data: { ...defaultPayload, api_key_set: true } }); + render(); + await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); + expect(screen.getByText(/Key stored/)).toBeDefined(); + }); + + it('calls PUT on save and shows success', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + apiPut.mockResolvedValueOnce({ data: { ...defaultPayload, model: 'phi3' } }); + + const user = userEvent.setup(); + render(); + 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(screen.getByText('LLM CONFIG SAVED')).toBeDefined()); + const [url, body] = apiPut.mock.calls[0]; + expect(url).toBe('/realism/llm'); + expect(body.model).toBe('phi3'); + }); + + it('shows error on 403', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + apiPut.mockRejectedValueOnce({ response: { status: 403 } }); + + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); + await user.click(screen.getByRole('button', { name: /SAVE/ })); + + await waitFor(() => expect(screen.getByText(/ADMIN ROLE REQUIRED/)).toBeDefined()); + }); + + it('hides save button for viewers', async () => { + apiGet.mockResolvedValueOnce({ data: defaultPayload }); + render(false); + await waitFor(() => expect(screen.queryByText('LOADING…')).toBeNull()); + expect(screen.queryByRole('button', { name: /SAVE/ })).toBeNull(); + }); + + it('sends empty api_key to clear when CLEAR button used', async () => { + apiGet.mockResolvedValueOnce({ data: { ...defaultPayload, api_key_set: true } }); + apiPut.mockResolvedValueOnce({ data: { ...defaultPayload, api_key_set: false } }); + + const user = userEvent.setup(); + render(); + 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(''); + }); +}); diff --git a/decnet_web/src/components/Config/tabs/LLMTab.tsx b/decnet_web/src/components/Config/tabs/LLMTab.tsx new file mode 100644 index 00000000..c1c99810 --- /dev/null +++ b/decnet_web/src/components/Config/tabs/LLMTab.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useState } from 'react'; +import { Save, CheckCircle } from '../../../icons'; +import api from '../../../utils/api'; + +interface LLMPayload { + 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: LLMPayload = { + provider: 'ollama', + base_url: null, + model: 'llama3.1', + timeout: 60, + api_key_set: false, +}; + +const _SENTINEL = Symbol(); + +interface Props { + isAdmin: boolean; +} + +export const LLMTab: React.FC = ({ isAdmin }) => { + 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 [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' })) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + setMsg(null); + const body: PutBody = { + provider: cfg.provider, + base_url: cfg.base_url || null, + model: cfg.model, + timeout: cfg.timeout, + }; + if (clearApiKey) body.api_key = ''; + else if (apiKeyInput.trim()) body.api_key = apiKeyInput.trim(); + + try { + const r = await api.put('/realism/llm', body); + setCfg(r.data); + setApiKeyInput(''); + setClearApiKey(false); + setMsg({ type: 'success', text: 'LLM CONFIG SAVED' }); + } 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' }); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
LOADING…
; + } + + return ( +
+
+ PROVIDER + {isAdmin ? ( + + ) : ( + {cfg.provider} + )} +
+ +
+ 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)'} + )} +
+ +
+ MODEL + {isAdmin ? ( +
+ setCfg({ ...cfg, model: e.target.value })} + /> +
+ ) : ( + {cfg.model} + )} +
+ +
+ 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 + + +
+ ) : ( +
+ setApiKeyInput(e.target.value)} + /> + {clearApiKey && ( + + )} +
+ )} +
+ )} + + {isAdmin && ( +
+
+ +
+ {msg && ( + + {msg.text} + + )} +
+ )} + + {!isAdmin && ( +
+ API KEY + {cfg.api_key_set ? '••••••••' : '(not set)'} +
+ )} +
+ ); +}; diff --git a/decnet_web/src/components/Config/types.ts b/decnet_web/src/components/Config/types.ts index 5f26b177..7905fb03 100644 --- a/decnet_web/src/components/Config/types.ts +++ b/decnet_web/src/components/Config/types.ts @@ -24,4 +24,5 @@ export type ConfigTab = | 'globals' | 'appearance' | 'workers' - | 'ttp'; + | 'ttp' + | 'llm'; diff --git a/decnet_web/src/components/LLMConfig/LLMConfig.css b/decnet_web/src/components/LLMConfig/LLMConfig.css deleted file mode 100644 index 2aeb81e7..00000000 --- a/decnet_web/src/components/LLMConfig/LLMConfig.css +++ /dev/null @@ -1,85 +0,0 @@ -/* 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 deleted file mode 100644 index f54bc7a9..00000000 --- a/decnet_web/src/components/LLMConfig/LLMConfig.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 94c04d9d..00000000 --- a/decnet_web/src/components/LLMConfig/LLMConfig.tsx +++ /dev/null @@ -1,239 +0,0 @@ -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 d9bd3f55..452bce9b 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -39,7 +39,6 @@ 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', @@ -154,7 +153,6 @@ 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}>