diff --git a/CLAUDE.md b/CLAUDE.md index 6afec983..1032446c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ uvicorn app.main:app --host 0.0.0.0 --port 8001 --workers 4 make backend-ci # Unit tests (50% coverage gate) + tier-1 integration tests make backend-static # Ruff lint + Bandit security scan make backend-backlog # Mypy typecheck + pip-audit (non-blocking) -make frontend-ci # Typecheck, lint, audit, Vitest (35% line coverage gate), build +make frontend-ci # Typecheck, lint, audit, Vitest (coverage gate over whole src/ tree, currently ~6% lines — see frontend/vitest.config.ts), build make ci # backend-ci + frontend-ci make release-check # ci + backend-static + Docker builds diff --git a/Makefile b/Makefile index 07b1b15b..2a2beec1 100644 --- a/Makefile +++ b/Makefile @@ -78,10 +78,12 @@ frontend-typecheck: frontend-lint: cd $(FRONTEND_DIR) && npm run lint -# Coverage gate set just below current measured (~44%). Bump as more -# components/hooks/api modules get tests. +# Coverage denominator is the whole frontend/src tree (see vitest.config.ts), +# not just what a test happens to import, so this number is honest and +# monotonic. Thresholds live in vitest.config.ts (single source) — bump them +# there as more components/hooks/api modules get tests. frontend-test: - cd $(FRONTEND_DIR) && npm run test:coverage -- --coverage.thresholds.lines=40 + cd $(FRONTEND_DIR) && npm run test:coverage frontend-build: cd $(FRONTEND_DIR) && npm run build diff --git a/frontend/package.json b/frontend/package.json index 004b8bb4..9a4e0063 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,13 +6,13 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", "test:coverage": "vitest run --coverage --coverage.reporter=text", "test:e2e": "playwright test", - "ci": "npm run typecheck && npm run lint && npm run test:coverage -- --coverage.thresholds.lines=40 && npm run build" + "ci": "npm run typecheck && npm run lint && npm run test:coverage && npm run build" }, "dependencies": { "@sentry/react": "^10.63.0", diff --git a/frontend/src/components/admin/AuditTab.tsx b/frontend/src/components/admin/AuditTab.tsx new file mode 100644 index 00000000..ebe2c8ba --- /dev/null +++ b/frontend/src/components/admin/AuditTab.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronLeft, ChevronRight, Download } from 'lucide-react' + +import * as auditApi from '../../api/audit' +import type { AuditLogEntry } from '../../api/audit' + +export function AuditTab() { + const [entries, setEntries] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(0) + const [loading, setLoading] = useState(true) + const [actionFilter, setActionFilter] = useState('') + const [resourceTypeFilter, setResourceTypeFilter] = useState('') + const limit = 25 + + const load = useCallback(async () => { + setLoading(true) + try { + const data = await auditApi.queryAuditLog({ action: actionFilter || undefined, resource_type: resourceTypeFilter || undefined, skip: page * limit, limit }) + setEntries(data.entries) + setTotal(data.total) + } catch { /* ignore */ } finally { setLoading(false) } + }, [page, actionFilter, resourceTypeFilter]) + + useEffect(() => { load() }, [load]) + + const ACTION_COLORS: Record = { + 'document.create': '#dcfce7', 'document.delete': '#fee2e2', + 'extraction.run': '#dbeafe', 'workflow.run': '#f3e8ff', + 'workflow.approve': '#dcfce7', 'workflow.reject': '#fee2e2', + 'user.login': '#f3f4f6', 'config.update': '#ffedd5', + } + const totalPages = Math.ceil(total / limit) + + return ( +
+
+

Audit Log ({total} entries)

+ + Export CSV + +
+
+ { setActionFilter(e.target.value); setPage(0) }} + placeholder="Filter by action…" style={{ padding: '6px 10px', border: '1px solid #d1d5db', borderRadius: 6, fontSize: 13, fontFamily: 'inherit' }} /> + +
+
+ + + + {['Time','Action','Actor','Resource','Details'].map(h => ( + + ))} + + + + {loading ? ( + + ) : entries.length === 0 ? ( + + ) : entries.map(entry => ( + + + + + + + + ))} + +
{h}
Loading…
No entries found
+ {entry.timestamp ? new Date(entry.timestamp).toLocaleDateString() + ' ' + new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '-'} + + + {entry.action} + + {entry.actor_user_id} + {entry.resource_name || entry.resource_id || '-'} + {entry.resource_type} + + {Object.keys(entry.detail).length > 0 ? JSON.stringify(entry.detail).slice(0, 80) : '-'} +
+
+ {totalPages > 1 && ( +
+ Page {page + 1} of {totalPages} +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/admin/CertificationsTab.tsx b/frontend/src/components/admin/CertificationsTab.tsx new file mode 100644 index 00000000..f7679736 --- /dev/null +++ b/frontend/src/components/admin/CertificationsTab.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Award, Lock, RefreshCw, Unlock } from 'lucide-react' +import { + getCertificationProgressList, setCertificationUnlock, + type CertificationProgressItem, +} from '../../api/admin' +import { formatNumber } from './shared/format' +import { ExportButton, SearchInput, UserAvatar } from './shared/primitives' + +function parseUtcDate(d: string): Date { + // Backend stores UTC but may omit timezone suffix; ensure JS treats it as UTC + if (!d.endsWith('Z') && !d.includes('+') && !d.includes('-', 10)) return new Date(d + 'Z') + return new Date(d) +} + +function formatDate(d: string | null): string { + if (!d) return '-' + return parseUtcDate(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +} + +function downloadCSV(filename: string, headers: string[], rows: (string | number | null)[][]) { + const escape = (v: string | number | null) => { + if (v === null || v === undefined) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s + } + const csv = [headers.join(','), ...rows.map(r => r.map(escape).join(','))].join('\n') + const blob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function CertificationsTab() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [busyUser, setBusyUser] = useState(null) + + const refresh = useCallback(async () => { + setLoading(true) + try { + const data = await getCertificationProgressList() + setItems(data) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { refresh() }, [refresh]) + + const filtered = useMemo(() => { + if (!search.trim()) return items + const q = search.toLowerCase() + return items.filter(p => + (p.name || '').toLowerCase().includes(q) || + (p.email || '').toLowerCase().includes(q) || + (p.user_id || '').toLowerCase().includes(q) + ) + }, [items, search]) + + const toggleUnlock = async (item: CertificationProgressItem) => { + setBusyUser(item.user_id) + try { + await setCertificationUnlock(item.user_id, !item.unlocked) + setItems(prev => prev.map(p => + p.user_id === item.user_id ? { ...p, unlocked: !item.unlocked } : p + )) + } finally { + setBusyUser(null) + } + } + + const handleExport = () => { + downloadCSV('certifications.csv', + ['User', 'Email', 'Level', 'Total XP', 'Modules Completed', 'Modules Total', 'Certified', 'Certified At', 'Streak', 'Last Activity', 'Unlocked'], + filtered.map(p => [ + p.name || p.user_id, p.email, + p.level, p.total_xp, + p.modules_completed, p.modules_total, + p.certified ? 'yes' : 'no', + p.certified_at, + p.streak_days, + p.last_activity_date, + p.unlocked ? 'yes' : 'no', + ]) + ) + } + + if (loading) return
Loading certification progress...
+ + return ( +
+
+

Certifications

+

+ Users who have started the Vandal Workflow Architect certification and where they are in the program. +

+
+ +
+ +
+ + +
+ +
+
+ Certification Progress ({filtered.length}) +
+ {filtered.length === 0 ? ( +
No users have started the certification yet.
+ ) : ( + + + + + + + + + + + + + + {filtered.map(p => { + const pct = p.modules_total > 0 ? (p.modules_completed / p.modules_total) * 100 : 0 + return ( + + + + + + + + + + ) + })} + +
UserLevelXPModulesCertifiedLast ActiveDebug Unlock
+
+ +
+
{p.name || 'Unknown'}
+
{p.email || p.user_id}
+
+
+
+ + {p.level} + + {formatNumber(p.total_xp)} +
+
+
+
+ + {p.modules_completed}/{p.modules_total} + +
+
+ {p.certified ? ( + + Yes + + ) : ( + + )} + + {p.last_activity_date || formatDate(p.updated_at)} + + +
+ )} +
+ +
+ Note: The unlock toggle is a debugging aid. It lets a user select any unit + in the certification program without completing the prerequisites — it does not mark + modules as completed or grant XP. +
+
+ ) +} diff --git a/frontend/src/components/admin/ConfigTab.tsx b/frontend/src/components/admin/ConfigTab.tsx new file mode 100644 index 00000000..1bd36b9f --- /dev/null +++ b/frontend/src/components/admin/ConfigTab.tsx @@ -0,0 +1,2770 @@ +import { useEffect, useState, useCallback } from 'react' +import { + ShieldCheck, Users, Settings, + Palette, Cpu, Lock, Globe, Plus, Trash2, Pencil, + RefreshCw, + CheckCircle2, XCircle, + ChevronDown, ChevronUp, Play, AlertCircle, + X, Star, +} from 'lucide-react' +import { useConfirm } from '../shared/useConfirm' +import { getThemeConfig, updateThemeConfig } from '../../api/config' +import type { ThemeConfig } from '../../api/config' +import { useBranding, DEFAULT_ORG_NAME, DEFAULT_ICON_URL } from '../../contexts/BrandingContext' +import { + getSystemConfig, updateSystemConfig, updateCompliancePolicyConfig, + addModel, updateModel, deleteModel, setDefaultModel, testOcr, testModel, testPrompt, probeModel, getReadiness, addOAuthProvider, + updateOAuthProvider, deleteOAuthProvider, updateAuthMethods, parseSamlMetadata, +} from '../../api/admin' +import type { TestPromptResult, ModelTestResult, ReadinessReport, ReadinessItem } from '../../api/admin' +import type { + SystemConfigData, +} from '../../api/admin' +import { fileToConstrainedDataUrl } from '../../utils/imageResize' +import { ModelCharacterBars } from '../ModelEffortPicker' +import type { ModelInfo } from '../../types/workflow' + +function applyThemeToDOM(theme: ThemeConfig) { + const root = document.documentElement + root.style.setProperty('--highlight-color', theme.highlight_color) + root.style.setProperty('--ui-radius', theme.ui_radius) +} + +const MAX_LOGO_BYTES = 500_000 // matches backend cap on the encoded data URL + +// ────────────────────────────────────────── +// Model connectivity diagnostics +// ────────────────────────────────────────── + +// Renders the step-by-step result of a model "Test" — on success, why the +// hook-up is healthy (protocol, endpoint, latency, tokens, the actual reply); +// on failure, a classified error with a plain-English cause and suggested fix. +function ModelTestDiagnostics({ result }: { result: ModelTestResult }) { + const [showRaw, setShowRaw] = useState(false) + const accent = result.ok ? '#16a34a' : '#dc2626' + return ( +
+
+ {result.summary} +
+ + {/* Step-by-step checks */} +
+ {result.checks.map((c, idx) => ( +
+ {c.ok + ? + : } + + {c.label}: {c.detail} + +
+ ))} +
+ + {/* Success facts */} + {result.ok && ( +
+ {result.protocol && } + {result.endpoint && } + {typeof result.latency_ms === 'number' && } + {result.tokens?.total != null && } +
+ )} + {result.ok && result.response_preview && ( +
+ reply: {result.response_preview} +
+ )} + + {/* Failure guidance */} + {!result.ok && result.error && ( +
+
+ +
+
{result.error.title}
+
{result.error.why}
+
+
+
+ Try this: {result.error.fix} +
+ {result.error.raw && ( +
+ + {showRaw && ( +
+                  {result.error.raw}
+                
+ )} +
+ )} +
+ )} +
+ ) +} + +function DiagFact({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + {value} + + ) +} + +// ────────────────────────────────────────── +// Setup readiness checklist +// ────────────────────────────────────────── + +// A graded "is this install set up" surface. A dismissible banner auto-shows +// while a blocker (no working LLM) is unresolved; the full checklist always +// lives at the top of the config page. `onJump` scrolls to the relevant +// section so each item is one click from being fixed. +function SetupChecklist({ report, onJump, onDismiss }: { report: ReadinessReport; onJump: (target: string) => void; onDismiss?: () => void }) { + const sevColor: Record = { blocker: '#dc2626', recommended: '#d97706', optional: '#6b7280' } + const statusPill = (item: ReadinessItem) => { + if (item.status === 'configured') return { label: 'Done', bg: '#dcfce7', fg: '#166534' } + if (item.status === 'incomplete') return { label: 'Needs attention', bg: '#fef9c3', fg: '#854d0e' } + return item.severity === 'blocker' + ? { label: 'Required', bg: '#fee2e2', fg: '#991b1b' } + : { label: 'Recommended', bg: '#ffedd5', fg: '#9a3412' } + } + return ( +
+
+ {report.ready + ? + : } + + {report.ready ? 'System ready' : 'Finish setting up your workspace'} + + {!report.ready && report.blockers_remaining > 0 && ( + + {report.blockers_remaining} blocker{report.blockers_remaining > 1 ? 's' : ''} left + + )} +
+ {onDismiss && ( + + )} +
+
+ {report.items.map(item => { + const pill = statusPill(item) + const done = item.status === 'configured' + return ( +
+
+ {done + ? + :
} +
+
+
+ {item.title} + {pill.label} +
+
{item.summary}
+ {!done &&
Unlocks: {item.unlocks}
} +
+ {!done && ( + + )} +
+ ) + })} +
+
+ ) +} + +// ────────────────────────────────────────── +// Config Tab +// ────────────────────────────────────────── + +// The editable shape of a single LLM model config row. +type ModelDraft = { + name: string + tag: string + external: boolean + thinking: boolean + endpoint: string + api_protocol: string + api_key: string + speed: string + tier: string + privacy: string + supports_structured: boolean + multimodal: boolean + supports_pdf: boolean + context_window: number + // Optional per-model overrides. 0 = unset (backend uses system default / + // computed value). + request_timeout_seconds: number + response_reserve_tokens: number +} + +const EMPTY_MODEL_DRAFT: ModelDraft = { + name: '', tag: '', external: false, thinking: false, endpoint: '', api_protocol: '', api_key: '', + speed: '', tier: '', privacy: '', supports_structured: true, multimodal: false, supports_pdf: false, + context_window: 128000, request_timeout_seconds: 0, response_reserve_tokens: 0, +} + +// Provider presets power the "Add a Model" wizard. Selecting one fills in the +// technical fields (protocol, endpoint, external/privacy flags, sensible +// capability defaults) so admins only supply a model name and, for hosted APIs, +// a key. `apply` is merged into the draft; everything stays editable under +// "Advanced settings". +type ModelProviderPreset = { + id: string + label: string + blurb: string + needsKey: boolean + needsEndpoint: boolean + keyPlaceholder?: string + keyHelp?: string + namePlaceholder: string + nameSuggestions?: string[] + endpointPlaceholder?: string + apply: Partial +} + +const MODEL_PROVIDERS: ModelProviderPreset[] = [ + { + id: 'google', + label: 'Google (Gemini)', + blurb: "Gemini models via Google AI Studio. Native integration — just a model name and key.", + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'AIza… (AI Studio API key)', + keyHelp: 'Create a key at aistudio.google.com → API keys.', + namePlaceholder: 'gemini-2.5-flash', + nameSuggestions: ['gemini-2.5-flash', 'gemini-2.5-pro'], + apply: { api_protocol: 'google', external: true, privacy: 'external', endpoint: '', tag: 'google', multimodal: true, supports_pdf: true, context_window: 1048576 }, + }, + { + id: 'openai', + label: 'OpenAI', + blurb: 'GPT models from the OpenAI API.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-…', + namePlaceholder: 'gpt-4o', + nameSuggestions: ['gpt-4o', 'gpt-4o-mini'], + apply: { api_protocol: 'openai', external: true, privacy: 'external', endpoint: 'https://api.openai.com/v1', tag: 'openai', multimodal: true }, + }, + { + id: 'anthropic', + label: 'Anthropic (Claude)', + blurb: 'Claude models via the native Anthropic API.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-ant-…', + namePlaceholder: 'claude-…', + apply: { api_protocol: 'anthropic', external: true, privacy: 'external', endpoint: '', tag: 'anthropic', multimodal: true }, + }, + { + id: 'openrouter', + label: 'OpenRouter', + blurb: 'Any model routed through OpenRouter.', + needsKey: true, + needsEndpoint: false, + keyPlaceholder: 'sk-or-…', + namePlaceholder: 'anthropic/claude-…', + apply: { api_protocol: 'openrouter', external: true, privacy: 'external', endpoint: '', tag: 'openrouter' }, + }, + { + id: 'ollama', + label: 'Ollama (self-hosted)', + blurb: 'A model served locally by Ollama.', + needsKey: false, + needsEndpoint: true, + namePlaceholder: 'llama3.1', + nameSuggestions: ['llama3.1', 'mistral'], + endpointPlaceholder: 'http://localhost:11434/v1', + apply: { api_protocol: 'ollama', external: false, privacy: 'internal', endpoint: 'http://localhost:11434/v1', tag: 'ollama' }, + }, + { + id: 'vllm', + label: 'vLLM (self-hosted)', + blurb: 'A model served by your own vLLM instance.', + needsKey: false, + needsEndpoint: true, + namePlaceholder: 'qwen3', + nameSuggestions: ['qwen3'], + endpointPlaceholder: 'http://localhost:8000/v1', + apply: { api_protocol: 'vllm', external: false, privacy: 'internal', endpoint: '', tag: 'vllm' }, + }, + { + id: 'custom', + label: 'Custom / OpenAI-compatible', + blurb: 'Any other OpenAI-compatible endpoint. Full manual control.', + needsKey: true, + needsEndpoint: true, + keyPlaceholder: 'API key (if required)', + namePlaceholder: 'model name', + endpointPlaceholder: 'https://…/v1', + apply: { api_protocol: 'openai', external: true, privacy: 'external', endpoint: '', tag: 'custom' }, + }, +] + +// Best-effort match of an existing saved model back to a provider preset, so the +// Edit flow lands on the right guided fields. +function inferProviderId(m: { api_protocol?: string; external?: boolean; endpoint?: string }): string { + const proto = (m.api_protocol || '').toLowerCase() + if (proto === 'google') return 'google' + if (proto === 'anthropic') return 'anthropic' + if (proto === 'openrouter') return 'openrouter' + if (proto === 'ollama') return 'ollama' + if (proto === 'vllm') return 'vllm' + if (proto === 'openai' && (m.endpoint || '').includes('api.openai.com')) return 'openai' + return 'custom' +} + +export function ConfigTab() { + const confirm = useConfirm() + const [cfg, setCfg] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + // Theme state + const branding = useBranding() + const [themeColor, setThemeColor] = useState('#eab308') + const [themeRadius, setThemeRadius] = useState(12) + const [themeOrgName, setThemeOrgName] = useState('') + const [themeLogo, setThemeLogo] = useState('') + const [themeLogoError, setThemeLogoError] = useState(null) + const [themeIcon, setThemeIcon] = useState('') + const [themeIconError, setThemeIconError] = useState(null) + const [themeIconHideInNav, setThemeIconHideInNav] = useState(false) + const [themeSaving, setThemeSaving] = useState(false) + const [themeSaved, setThemeSaved] = useState(false) + + // Extraction config + const [extractionMode, setExtractionMode] = useState('one_pass') + const [chunkingEnabled, setChunkingEnabled] = useState(false) + const [maxKeysPerChunk, setMaxKeysPerChunk] = useState(10) + const [repetitionEnabled, setRepetitionEnabled] = useState(false) + const [onePassThinking, setOnePassThinking] = useState(true) + const [onePassStructured, setOnePassStructured] = useState(true) + const [onePassModel, setOnePassModel] = useState('') + const [twoPassP1Thinking, setTwoPassP1Thinking] = useState(true) + const [twoPassP1Structured, setTwoPassP1Structured] = useState(false) + const [twoPassP1Model, setTwoPassP1Model] = useState('') + const [twoPassP2Thinking, setTwoPassP2Thinking] = useState(false) + const [twoPassP2Structured, setTwoPassP2Structured] = useState(true) + const [twoPassP2Model, setTwoPassP2Model] = useState('') + const [useImages, setUseImages] = useState(false) + + // Quality config + const [requireValidation, setRequireValidation] = useState(false) + const [minAccuracy, setMinAccuracy] = useState(70) + const [minConsistency, setMinConsistency] = useState(80) + const [minWorkflowGrade, setMinWorkflowGrade] = useState('C') + const [excellentThreshold, setExcellentThreshold] = useState(90) + const [goodThreshold, setGoodThreshold] = useState(70) + const [fairThreshold, setFairThreshold] = useState(50) + + // Endpoints + const [ocrEndpoint, setOcrEndpoint] = useState('') + const [ocrApiKey, setOcrApiKey] = useState('') + const [ocrTesting, setOcrTesting] = useState(false) + const [ocrTestResult, setOcrTestResult] = useState<{ ok: boolean; message: string } | null>(null) + const [modelTesting, setModelTesting] = useState(null) + const [modelTestResults, setModelTestResults] = useState>({}) + const [expandedModelTest, setExpandedModelTest] = useState(null) + + // System readiness / setup checklist + const [readiness, setReadiness] = useState(null) + const [setupDismissed, setSetupDismissed] = useState(false) + const refreshReadiness = useCallback(async () => { + try { + setReadiness(await getReadiness()) + } catch { + // Readiness is advisory — never block the config page on it. + } + }, []) + + // Prompt playground + const [playgroundModel, setPlaygroundModel] = useState('') + const [playgroundSystem, setPlaygroundSystem] = useState('') + const [playgroundUser, setPlaygroundUser] = useState('') + const [playgroundSending, setPlaygroundSending] = useState(false) + const [playgroundResult, setPlaygroundResult] = useState(null) + const [playgroundError, setPlaygroundError] = useState(null) + + // Auth + const [authMethods, setAuthMethods] = useState(['password']) + const [authSaving, setAuthSaving] = useState(false) + + // Add/edit model form + const [showModelForm, setShowModelForm] = useState(false) + const [editingModelIndex, setEditingModelIndex] = useState(null) + const [savingModel, setSavingModel] = useState(false) + const [newModel, setNewModel] = useState({ ...EMPTY_MODEL_DRAFT }) + const [probingContext, setProbingContext] = useState(false) + const [probeResult, setProbeResult] = useState<{ ok: boolean; message: string } | null>(null) + // Add-a-Model wizard: step 1 = pick provider, step 2 = configure + save + test. + const [wizardStep, setWizardStep] = useState<1 | 2>(1) + const [wizardProviderId, setWizardProviderId] = useState(null) + const [modelTest, setModelTest] = useState(null) + const [wizardTesting, setWizardTesting] = useState(false) + + // Support contacts + const [supportContacts, setSupportContacts] = useState<{ user_id: string; email: string; name: string }[]>([]) + const [showAddContact, setShowAddContact] = useState(false) + const [newContact, setNewContact] = useState({ user_id: '', email: '', name: '' }) + + // Compliance activation + const [complianceEnabled, setComplianceEnabled] = useState(false) + const [complianceCheckOnUpload, setComplianceCheckOnUpload] = useState(true) + const [complianceRules, setComplianceRules] = useState('') + const [complianceChunkSize, setComplianceChunkSize] = useState(8000) + const [complianceChunkOverlap, setComplianceChunkOverlap] = useState(200) + const [complianceSaving, setComplianceSaving] = useState(false) + const [complianceSaved, setComplianceSaved] = useState(false) + + // Retention policy + type RetentionPolicyForm = { retention_days: number; soft_delete_grace_days: number; warning_days_before?: number } + const [retentionEnabled, setRetentionEnabled] = useState(false) + const [retentionPolicies, setRetentionPolicies] = useState>({}) + const [activityRetentionDays, setActivityRetentionDays] = useState(180) + const [chatRetentionDays, setChatRetentionDays] = useState(365) + const [workflowResultRetentionDays, setWorkflowResultRetentionDays] = useState(365) + const [staleActivityMinutes, setStaleActivityMinutes] = useState(30) + const [retentionSaving, setRetentionSaving] = useState(false) + const [retentionSaved, setRetentionSaved] = useState(false) + + // Add/edit provider form + const [showAddProvider, setShowAddProvider] = useState(false) + const [newProvider, setNewProvider] = useState({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + const [editingProviderIndex, setEditingProviderIndex] = useState(null) + const [editingProvider, setEditingProvider] = useState({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + const [samlMeta, setSamlMeta] = useState('') + const [samlMetaBusy, setSamlMetaBusy] = useState(false) + const [samlMetaError, setSamlMetaError] = useState('') + const [providerError, setProviderError] = useState('') + + /** Return a message if the provider form is missing a required field, else ''. */ + const providerValidationError = (p: { provider: string; display_name: string; client_id: string; idp_entity_id: string; idp_sso_url: string; idp_x509_cert: string }): string => { + if (!p.display_name.trim()) return 'Display name is required.' + if (p.provider === 'saml') { + if (!p.idp_entity_id.trim() || !p.idp_sso_url.trim() || !p.idp_x509_cert.trim()) { + return 'SAML requires the IdP Entity ID, SSO URL, and x509 certificate (use "Fetch & fill" to import them).' + } + } else if (!p.client_id.trim()) { + return 'Client ID is required.' + } + return '' + } + + const handleImportSamlMetadata = async () => { + const raw = samlMeta.trim() + if (!raw) return + setSamlMetaBusy(true) + setSamlMetaError('') + try { + const body = raw.startsWith('<') ? { metadata_xml: raw } : { metadata_url: raw } + const idp = await parseSamlMetadata(body) + setNewProvider(p => ({ ...p, idp_entity_id: idp.idp_entity_id, idp_sso_url: idp.idp_sso_url, idp_x509_cert: idp.idp_x509_cert })) + } catch (e) { + setSamlMetaError(e instanceof Error ? e.message : 'Could not read metadata') + } finally { + setSamlMetaBusy(false) + } + } + + useEffect(() => { void refreshReadiness() }, [refreshReadiness]) + + useEffect(() => { + setLoading(true) + getSystemConfig().then(c => { + setCfg(c) + setThemeColor(c.highlight_color || '#eab308') + setThemeRadius(parseInt(c.ui_radius) || 12) + setOcrEndpoint(c.ocr_endpoint || '') + setOcrApiKey(c.ocr_api_key || '') + setAuthMethods(c.auth_methods || ['password']) + setSupportContacts((c as unknown as Record).support_contacts as typeof supportContacts || []) + // Extraction config + const ec = c.extraction_config || {} + setExtractionMode((ec as Record).mode as string || 'one_pass') + const chunking = (ec as Record).chunking as Record || {} + setChunkingEnabled(!!chunking.enabled) + setMaxKeysPerChunk((chunking.max_keys_per_chunk as number) || 10) + setRepetitionEnabled(!!((ec as Record).repetition as Record)?.enabled) + setUseImages(!!(ec as Record).use_images) + const onePass = (ec as Record).one_pass as Record || {} + setOnePassThinking(onePass.thinking !== false) + setOnePassStructured((onePass.structured_output ?? onePass.structured) !== false) + setOnePassModel((onePass.model as string) || '') + const twoPass = (ec as Record).two_pass as Record || {} + const pass1 = (twoPass.pass1 as Record ?? twoPass.pass_1 as Record) || {} + const pass2 = (twoPass.pass2 as Record ?? twoPass.pass_2 as Record) || {} + setTwoPassP1Thinking(pass1.thinking !== false) + setTwoPassP1Structured(!!(pass1.structured_output ?? pass1.structured)) + setTwoPassP1Model((pass1.model as string) || '') + setTwoPassP2Thinking(!!(pass2.thinking)) + setTwoPassP2Structured((pass2.structured_output ?? pass2.structured) !== false) + setTwoPassP2Model((pass2.model as string) || '') + // Quality config + const qc = (c.quality_config || {}) as Record + const gates = (qc.verification_gates || {}) as Record + setRequireValidation(!!gates.require_validation) + setMinAccuracy(Math.round(((gates.min_extraction_accuracy as number) ?? 0.7) * 100)) + setMinConsistency(Math.round(((gates.min_extraction_consistency as number) ?? 0.8) * 100)) + setMinWorkflowGrade((gates.min_workflow_grade as string) || 'C') + const tiers = (qc.quality_tiers || {}) as Record> + setExcellentThreshold((tiers.excellent?.min_score as number) ?? 90) + setGoodThreshold((tiers.good?.min_score as number) ?? 70) + setFairThreshold((tiers.fair?.min_score as number) ?? 50) + // Compliance config + const comp = c.compliance_config || ({} as Partial) + setComplianceEnabled(!!comp.enabled) + setComplianceCheckOnUpload(comp.check_on_upload !== false) + setComplianceRules(comp.rules || '') + setComplianceChunkSize(comp.chunk_size || 8000) + setComplianceChunkOverlap(comp.chunk_overlap ?? 200) + // Retention config + const rc = (c.retention_config || {}) as Record + setRetentionEnabled(!!rc.enabled) + setRetentionPolicies((rc.policies as Record) || {}) + setActivityRetentionDays((rc.activity_retention_days as number) ?? 180) + setChatRetentionDays((rc.chat_retention_days as number) ?? 365) + setWorkflowResultRetentionDays((rc.workflow_result_retention_days as number) ?? 365) + setStaleActivityMinutes((rc.activity_stale_threshold_minutes as number) ?? 30) + }).catch(() => {}).finally(() => setLoading(false)) + + getThemeConfig().then(t => { + setThemeColor(t.highlight_color) + setThemeRadius(parseInt(t.ui_radius) || 12) + setThemeOrgName(t.org_name || '') + setThemeLogo(t.logo_data_url || '') + setThemeIcon(t.icon_data_url || '') + setThemeIconHideInNav(!!t.icon_hide_in_nav) + }).catch(() => {}) + }, []) + + const handleSaveConfig = async () => { + setSaving(true) + setSaved(false) + setError(null) + try { + await updateSystemConfig({ + extraction_config: { + mode: extractionMode, + one_pass: { thinking: onePassThinking, structured: onePassStructured, model: onePassModel || '' }, + two_pass: { + pass_1: { thinking: twoPassP1Thinking, structured: twoPassP1Structured, model: twoPassP1Model || '' }, + pass_2: { thinking: twoPassP2Thinking, structured: twoPassP2Structured, model: twoPassP2Model || '' }, + }, + chunking: { enabled: chunkingEnabled, max_keys_per_chunk: maxKeysPerChunk }, + repetition: { enabled: repetitionEnabled }, + use_images: useImages, + }, + quality_config: { + verification_gates: { + require_validation: requireValidation, + min_extraction_accuracy: minAccuracy / 100, + min_extraction_consistency: minConsistency / 100, + min_workflow_grade: minWorkflowGrade, + }, + quality_tiers: { + excellent: { min_score: excellentThreshold }, + good: { min_score: goodThreshold }, + fair: { min_score: fairThreshold }, + }, + }, + ocr_endpoint: ocrEndpoint, + ocr_api_key: ocrApiKey, + }) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const handleSaveTheme = async () => { + setThemeSaving(true) + setThemeSaved(false) + try { + const updated = await updateThemeConfig({ + highlight_color: themeColor, + ui_radius: `${themeRadius}px`, + org_name: themeOrgName.trim(), + logo_data_url: themeLogo, + icon_data_url: themeIcon, + icon_hide_in_nav: themeIconHideInNav, + }) + applyThemeToDOM(updated) + await branding.refresh() + setThemeSaved(true) + setTimeout(() => setThemeSaved(false), 3000) + } finally { + setThemeSaving(false) + } + } + + const handleLogoFile = async (file: File | null) => { + setThemeLogoError(null) + if (!file) return + if (!file.type.startsWith('image/')) { + setThemeLogoError('Please choose an image file (PNG, SVG, JPG).') + return + } + try { + // Auto-downscale oversized raster images so large exports "just work". + const dataUrl = await fileToConstrainedDataUrl(file, { maxBytes: MAX_LOGO_BYTES, maxDimension: 1024 }) + // Safety net for the rare oversized SVG, which is passed through unresized. + if (dataUrl.length > MAX_LOGO_BYTES) { + setThemeLogoError(`Image too large — keep encoded size under ${Math.round(MAX_LOGO_BYTES / 1024)} KB.`) + return + } + setThemeLogo(dataUrl) + } catch { + setThemeLogoError('Could not process the selected image. Try a different file.') + } + } + + const handleIconFile = async (file: File | null) => { + setThemeIconError(null) + if (!file) return + if (!file.type.startsWith('image/')) { + setThemeIconError('Please choose an image file (PNG, SVG, JPG).') + return + } + try { + // Icons/mascots are square-ish and small in the UI, so a tighter max + // dimension keeps them well under the cap while staying crisp. + const dataUrl = await fileToConstrainedDataUrl(file, { maxBytes: MAX_LOGO_BYTES, maxDimension: 512 }) + // Safety net for the rare oversized SVG, which is passed through unresized. + if (dataUrl.length > MAX_LOGO_BYTES) { + setThemeIconError(`Image too large — keep encoded size under ${Math.round(MAX_LOGO_BYTES / 1024)} KB.`) + return + } + setThemeIcon(dataUrl) + } catch { + setThemeIconError('Could not process the selected image. Try a different file.') + } + } + + const handleProbeContextWindow = async () => { + setProbingContext(true) + setProbeResult(null) + try { + const result = await probeModel({ + name: newModel.name, + endpoint: newModel.endpoint, + api_protocol: newModel.api_protocol, + api_key: newModel.api_key, + existing_model_index: editingModelIndex, + }) + if (result.context_window && result.context_window > 0) { + setNewModel(prev => ({ ...prev, context_window: result.context_window as number })) + setProbeResult({ ok: true, message: `Detected ${result.context_window.toLocaleString()} tokens (${result.source}).` }) + } else { + setProbeResult({ ok: false, message: result.detail || `No context length reported (${result.source}).` }) + } + } catch (e) { + setProbeResult({ ok: false, message: e instanceof Error ? e.message : 'Probe failed' }) + } finally { + setProbingContext(false) + } + } + + // Open the wizard fresh for a new model (provider-picker step). + const openAddModelWizard = () => { + setNewModel({ ...EMPTY_MODEL_DRAFT }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(null) + setWizardStep(1) + setEditingModelIndex(null) + setError(null) + setShowModelForm(true) + } + + const closeModelForm = () => { + setNewModel({ ...EMPTY_MODEL_DRAFT }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(null) + setWizardStep(1) + setShowModelForm(false) + setEditingModelIndex(null) + setError(null) + } + + // Wizard step 1 → 2: apply the provider's preset onto a clean draft. Starting + // from EMPTY (keeping only a model name the admin may have typed) prevents a + // previously-picked provider's flags — e.g. Google's 1M context window — from + // leaking in when they switch providers via "Change". + const selectProvider = (p: ModelProviderPreset) => { + setWizardProviderId(p.id) + setNewModel(prev => ({ ...EMPTY_MODEL_DRAFT, name: prev.name, ...p.apply })) + setProbeResult(null) + setModelTest(null) + setError(null) + setWizardStep(2) + } + + const handleSaveModel = async () => { + if (!newModel.name.trim()) { + setError('Enter a model name') + return + } + if (!newModel.tag.trim()) { + setError('A tag is required (set one under Advanced settings)') + return + } + setSavingModel(true) + setError(null) + setModelTest(null) + try { + let res + let savedIndex: number + if (editingModelIndex !== null) { + res = await updateModel(editingModelIndex, newModel) + savedIndex = editingModelIndex + } else { + res = await addModel(newModel) + savedIndex = res.models.length - 1 + } + if (cfg) { + const resDefault = (res as { default_model?: string }).default_model + setCfg({ + ...cfg, + available_models: res.models, + ...(resDefault !== undefined ? { default_model: resDefault } : {}), + }) + } + // The model is now saved — subsequent edits/tests target its index. + setEditingModelIndex(savedIndex) + void refreshReadiness() + // Auto-run a connection test so the admin gets a clear pass/fail without + // having to know where the test button lives. + setWizardTesting(true) + try { + const t = await testModel(savedIndex) + setModelTest(t) + } catch (e) { + setModelTest({ + ok: false, + checks: [], + summary: 'Saved, but the connection test could not run.', + error: { category: 'client', title: 'Test request failed', why: e instanceof Error ? e.message : 'Unknown error', fix: 'The model is saved. Re-run the test from the model list, or check your network.', raw: '' }, + }) + } finally { + setWizardTesting(false) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save model') + } finally { + setSavingModel(false) + } + } + + const handleEditModel = (index: number) => { + const m = cfg?.available_models[index] + if (!m) return + setNewModel({ + name: m.name, + tag: m.tag, + external: m.external, + thinking: m.thinking, + endpoint: m.endpoint || '', + api_protocol: m.api_protocol || '', + api_key: m.api_key || '', + speed: m.speed || '', + tier: m.tier || '', + privacy: m.privacy || '', + supports_structured: m.supports_structured !== false, + multimodal: !!m.multimodal, + supports_pdf: !!m.supports_pdf, + context_window: typeof m.context_window === 'number' && m.context_window > 0 ? m.context_window : 128000, + request_timeout_seconds: typeof m.request_timeout_seconds === 'number' && m.request_timeout_seconds > 0 ? m.request_timeout_seconds : 0, + response_reserve_tokens: typeof m.response_reserve_tokens === 'number' && m.response_reserve_tokens > 0 ? m.response_reserve_tokens : 0, + }) + setProbeResult(null) + setModelTest(null) + setWizardProviderId(inferProviderId(m)) + setWizardStep(2) // edit skips the provider picker + setEditingModelIndex(index) + setShowModelForm(true) + } + + const handleDeleteModel = async (index: number) => { + const model = cfg?.available_models?.[index] + const ok = await confirm({ + title: 'Delete model?', + message: ( + <> + Are you sure you want to delete the model {model?.name || 'this model'}? Workflows and chats configured to use it will fail until reconfigured. + + ), + confirmLabel: 'Delete', + destructive: true, + }) + if (!ok) return + try { + const res = await deleteModel(index) + if (cfg) { + const models = [...cfg.available_models] + models.splice(index, 1) + setCfg({ + ...cfg, + available_models: models, + ...(res.default_model !== undefined ? { default_model: res.default_model } : {}), + }) + } + // Dropping a model can clear the only configured LLM — re-grade setup. + setModelTestResults(prev => { const next = { ...prev }; delete next[index]; return next }) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete model') + } + } + + const handleSetDefaultModel = async (name: string) => { + try { + // Toggle off if clicking the current default. + const next = cfg?.default_model === name ? '' : name + const res = await setDefaultModel(next) + if (cfg) setCfg({ ...cfg, default_model: res.default_model }) + void refreshReadiness() + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to set default model') + } + } + + const handleTestOcr = async () => { + setOcrTesting(true) + setOcrTestResult(null) + try { + // Send the form's current values so unsaved edits are what gets tested; + // an untouched key field holds the "***" sentinel, meaning the saved key. + const res = await testOcr({ ocr_endpoint: ocrEndpoint, ocr_api_key: ocrApiKey }) + setOcrTestResult({ ok: true, message: res.message }) + } catch (e) { + setOcrTestResult({ ok: false, message: e instanceof Error ? e.message : 'Test failed' }) + } finally { + setOcrTesting(false) + } + } + + const handleTestModel = async (index: number) => { + setModelTesting(index) + setModelTestResults(prev => { const next = { ...prev }; delete next[index]; return next }) + try { + const res = await testModel(index) + setModelTestResults(prev => ({ ...prev, [index]: res })) + // Auto-expand so the admin sees the breakdown — especially on failure. + setExpandedModelTest(index) + // A successful test means readiness may have changed. + if (res.ok) void refreshReadiness() + } catch (e) { + // Transport-level failure (network/permission) — synthesize a result. + const message = e instanceof Error ? e.message : 'Test failed' + setModelTestResults(prev => ({ + ...prev, + [index]: { + ok: false, + checks: [{ label: 'Request', ok: false, detail: message }], + summary: message, + error: { category: 'transport', title: 'Could not run the test', why: message, fix: 'Check that you are still signed in as an admin and the backend is reachable.', raw: message }, + }, + })) + setExpandedModelTest(index) + } finally { + setModelTesting(null) + } + } + + const handleSendPlaygroundPrompt = async () => { + if (!playgroundUser.trim()) return + setPlaygroundSending(true) + setPlaygroundError(null) + setPlaygroundResult(null) + try { + const res = await testPrompt({ + model_name: playgroundModel || cfg?.default_model || '', + system_prompt: playgroundSystem, + user_prompt: playgroundUser, + }) + setPlaygroundResult(res) + } catch (e) { + setPlaygroundError(e instanceof Error ? e.message : 'Request failed') + } finally { + setPlaygroundSending(false) + } + } + + const handleSaveAuthMethods = async () => { + setAuthSaving(true) + try { + await updateAuthMethods(authMethods) + void refreshReadiness() + } finally { + setAuthSaving(false) + } + } + + const handleAddProvider = async () => { + const validationError = providerValidationError(newProvider) + if (validationError) { setProviderError(validationError); return } + setProviderError('') + try { + await addOAuthProvider(newProvider as unknown as Record) + // Refresh config + const c = await getSystemConfig() + setCfg(c) + setNewProvider({ provider: 'oauth', display_name: '', client_id: '', client_secret: '', redirect_uri: '', tenant_id: '', idp_entity_id: '', idp_sso_url: '', idp_x509_cert: '' }) + setSamlMeta('') + setShowAddProvider(false) + } catch (e) { + setProviderError(e instanceof Error ? e.message : 'Failed to add provider') + } + } + + const handleDeleteProvider = async (index: number) => { + const provider = cfg?.oauth_providers?.[index] as Record | undefined + const name = (provider?.display_name as string) || (provider?.provider as string) || 'this provider' + const ok = await confirm({ + title: 'Delete OAuth provider?', + message: ( + <> + Are you sure you want to delete {name}? Users authenticating through this provider will no longer be able to sign in via it. + + ), + confirmLabel: 'Delete', + destructive: true, + }) + if (!ok) return + try { + await deleteOAuthProvider(index) + const c = await getSystemConfig() + setCfg(c) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete provider') + } + } + + const handleEditProvider = (index: number) => { + const p = cfg?.oauth_providers?.[index] as Record | undefined + if (!p) return + setProviderError('') + setEditingProviderIndex(index) + setEditingProvider({ + provider: (p.provider as string) || 'oauth', + display_name: (p.display_name as string) || '', + client_id: (p.client_id as string) || '', + client_secret: '***', + redirect_uri: (p.redirect_uri as string) || '', + tenant_id: (p.tenant_id as string) || '', + idp_entity_id: (p.idp_entity_id as string) || '', + idp_sso_url: (p.idp_sso_url as string) || '', + idp_x509_cert: (p.idp_x509_cert as string) || '', + }) + setShowAddProvider(false) + } + + const handleUpdateProvider = async () => { + if (editingProviderIndex === null) return + const validationError = providerValidationError(editingProvider) + if (validationError) { setProviderError(validationError); return } + setProviderError('') + try { + await updateOAuthProvider(editingProviderIndex, editingProvider as unknown as Record) + const c = await getSystemConfig() + setCfg(c) + setEditingProviderIndex(null) + } catch (e) { + setProviderError(e instanceof Error ? e.message : 'Failed to update provider') + } + } + + const saveSupportContacts = async (contacts: typeof supportContacts) => { + try { + await updateSystemConfig({ support_contacts: contacts } as Record) + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save support contacts') + } + } + + if (loading) return
Loading config...
+ + const sectionStyle = { + background: '#fff', border: '1px solid #e5e7eb', borderRadius: 'var(--ui-radius, 12px)', overflow: 'hidden' as const, + } + const sectionHeaderStyle = { + padding: '14px 20px', borderBottom: '1px solid #e5e7eb', fontSize: 15, fontWeight: 600 as const, + display: 'flex', alignItems: 'center', gap: 10, + } + const sectionBodyStyle = { padding: 20 } + const labelStyle = { display: 'block', fontSize: 13, fontWeight: 500 as const, color: '#374151', marginBottom: 6 } + const inputStyle = { + width: '100%', padding: '8px 12px', borderRadius: 'var(--ui-radius, 12px)', border: '1px solid #d1d5db', + fontSize: 14, outline: 'none', + } + const checkStyle = { marginRight: 8, accentColor: 'var(--highlight-color, #eab308)' } + + return ( +
+ {/* Sticky save bar */} +
+ + System Configuration + +
+ {saved && Configuration saved!} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Setup readiness — auto-shows while a blocker is unresolved; once the + system is ready it can be dismissed for the session. */} + {readiness && !(readiness.ready && setupDismissed) && ( + { + const id = `cfg-${target}` + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + // First-run: if the admin is being sent to connect their first model, + // drop them straight into the guided wizard instead of leaving them to + // find the "Add Model" button. Only when none exists and it isn't open. + if (target === 'models' && !(cfg?.available_models && cfg.available_models.length > 0) && !showModelForm) { + openAddModelWizard() + } + }} + onDismiss={readiness.ready ? () => setSetupDismissed(true) : undefined} + /> + )} + + {/* Available Models */} +
+
+ Available Models +
+ +
+
+ {cfg?.available_models && cfg.available_models.length > 0 ? ( +
+ {cfg.available_models.map((m, i) => { + const test = modelTestResults[i] + const expanded = expandedModelTest === i + return ( +
+
+
+ {/* Identity & capability badges */} +
+ {m.name} + {m.tag} + {cfg?.default_model === m.name && ( + + Default + + )} + {m.external && ( + External + )} + {m.thinking && ( + Thinking + )} + {m.multimodal && ( + Multimodal + )} + {m.supports_pdf && ( + PDF Input + )} + {m.api_protocol && ( + {m.api_protocol} + )} + {m.api_key && ( + API Key ✓ + )} + {m.endpoint && ( + {m.endpoint} + )} +
+ {/* Characteristic bars (replaces speed / tier / privacy pills) */} + +
+
+ {test && ( + + )} + + + + +
+
+ {expanded && test && } +
+ ) + })} +
+ ) : ( +
No models configured.
+ )} + + {showModelForm && (() => { + const prov = MODEL_PROVIDERS.find(p => p.id === wizardProviderId) ?? null + const isEditing = editingModelIndex !== null + const needsKey = prov?.needsKey ?? true + const needsEndpoint = prov?.needsEndpoint ?? false + const secondaryBtn = { + padding: '8px 16px', borderRadius: 'var(--ui-radius, 12px)', border: '1px solid #d1d5db', + background: '#fff', fontSize: 13, cursor: 'pointer', + } as const + const primaryBtn = { + padding: '8px 16px', borderRadius: 'var(--ui-radius, 12px)', border: 'none', + background: 'var(--highlight-color, #eab308)', color: 'var(--highlight-text-color, #000)', + fontSize: 13, fontWeight: 600, cursor: 'pointer', + } as const + const checkboxLabel = { display: 'flex', alignItems: 'center', fontSize: 14, cursor: 'pointer' } as const + return ( +
+
+
{isEditing ? 'Edit model' : 'Add a model'}
+ {!isEditing && ( +
+ {wizardStep === 1 ? 'Step 1 of 2 · Choose a provider' : 'Step 2 of 2 · Configure'} +
+ )} +
+ + {/* STEP 1 — provider picker (new models only) */} + {wizardStep === 1 && !isEditing && ( + <> +
+ Choose where this model runs — we’ll fill in the technical settings for you. +
+
+ {MODEL_PROVIDERS.map(p => ( + + ))} +
+
+ +
+ + )} + + {/* STEP 2 — configure, save, test */} + {wizardStep === 2 && ( + <> + {prov && ( +
+ {prov.label} + {!isEditing && ( + + )} +
+ )} + + {/* Guided fields */} +
+
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, name: v })) }} placeholder={prov?.namePlaceholder ?? 'model name'} style={inputStyle} /> + {prov?.nameSuggestions && prov.nameSuggestions.length > 0 && ( +
+ {prov.nameSuggestions.map(s => ( + + ))} +
+ )} +
+ + {needsKey && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, api_key: v })) }} placeholder={prov?.keyPlaceholder ?? 'API key'} style={inputStyle} /> + {prov?.keyHelp &&
{prov.keyHelp}
} +
+ )} + + {needsEndpoint && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, endpoint: v })) }} placeholder={prov?.endpointPlaceholder ?? 'https://…/v1'} style={inputStyle} /> +
+ )} + +
+ + +
+
+ + {/* Advanced settings — everything from the old form lives here */} +
+ Advanced settings +
+
+
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, tag: v })) }} placeholder="provider" style={inputStyle} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {!needsEndpoint && ( +
+ + { const v = e.target.value; setNewModel(prev => ({ ...prev, endpoint: v })) }} placeholder="https://..." style={inputStyle} /> +
+ )} +
+ +
+ +
+ { + const v = parseInt(e.target.value, 10) + setNewModel(prev => ({ ...prev, context_window: Number.isFinite(v) && v > 0 ? v : 0 })) + setProbeResult(null) + }} + placeholder="e.g. 65536" + style={{ ...inputStyle, flex: 1 }} + /> + +
+
+ The serving cap (e.g. vLLM’s --max-model-len), not the model card’s theoretical max. Compaction and the oversize-doc check use this to decide what fits. +
+ {probeResult && ( +
+ {probeResult.message} +
+ )} +
+ +
+
+ + { const v = parseInt(e.target.value, 10); setNewModel(prev => ({ ...prev, request_timeout_seconds: Number.isFinite(v) && v > 0 ? v : 0 })) }} + placeholder="system default" + style={inputStyle} + /> +
+ Overrides the shared LLM timeout for this model — raise it for slow self-hosted models. Blank = system default. +
+
+
+ + { const v = parseInt(e.target.value, 10); setNewModel(prev => ({ ...prev, response_reserve_tokens: Number.isFinite(v) && v > 0 ? v : 0 })) }} + placeholder="auto" + style={inputStyle} + /> +
+ Tokens reserved for the model’s answer; also caps runaway reasoning. More output room means less input room. Blank = scaled to the context window. +
+
+
+ +
+ + + {newModel.multimodal && ( + + )} +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {wizardTesting && ( +
+ Testing connection… +
+ )} + {modelTest && !wizardTesting && ( +
+ +
+ )} + +
+ {!modelTest ? ( + <> + + {!isEditing && } + + + ) : ( + <> + + + + )} +
+ + )} +
+ ) + })()} +
+
+ + {/* Prompt Playground */} +
+
+ Prompt Playground + + — send a prompt to a configured model and see the raw round-trip + +
+
+
+
+
+ +