import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { getAISettings, updateAISettings, testAIConnection, getSystemPrompts, updateSystemPrompt, type SystemPromptsData, } from "../api/client"; type Provider = "anthropic" | "ollama" | "lmstudio"; type Tab = "general" | "system-prompts"; // ── Static per-service documentation ────────────────────────────────────────── interface ServiceDoc { description: string; placeholders: { name: string; required: boolean; description: string }[]; requiredResponseKeys: string[]; notes: string[]; } const SERVICE_DOCS: Record = { doc_service: { description: "Extracts structured data from uploaded PDF documents. The system prompt instructs the model to return ONLY a JSON object. The user template is sent once per document with the extracted text injected at {text}.", placeholders: [ { name: "{text}", required: true, description: "The first 50 000 characters of text extracted from the PDF. Must be present in the user template.", }, ], requiredResponseKeys: [ "title", "document_type", "total_amount", "currency", "vendor_name", "customer_name", "billing_address", "customer_address", "invoice_number", "invoice_date", "due_date", "tags", "line_items", "suggested_categories", ], notes: [ 'The system prompt should instruct the model to return ONLY a JSON object — no markdown, no code fences, no explanation.', 'document_type must be one of: invoice, bill, receipt, order, expense, revenue, unknown.', 'tags and suggested_categories must be JSON arrays of strings.', 'line_items must be a JSON array of objects, each with keys description and amount.', 'Missing keys will be stored as null — the service will not error on absent optional fields.', ], }, }; // ── Shared layout helpers ────────────────────────────────────────────────────── function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{children}
); } const inputStyle: React.CSSProperties = { width: "100%", padding: "7px 10px", fontSize: 14, border: "1px solid #ccc", borderRadius: 4, boxSizing: "border-box", }; const textareaStyle: React.CSSProperties = { ...inputStyle, fontFamily: "monospace", fontSize: 13, resize: "vertical", minHeight: 120, }; // ── Service documentation panel ──────────────────────────────────────────────── function ServiceDocsPanel({ serviceId }: { serviceId: string }) { const [open, setOpen] = useState(false); const doc = SERVICE_DOCS[serviceId]; if (!doc) return null; return (
{open && (

{doc.description}

{doc.placeholders.length > 0 && ( <>

Template placeholders

{doc.placeholders.map((p) => ( ))}
Placeholder Required Description
{p.name} {p.required ? ( Yes ) : ( "No" )} {p.description}
)} {doc.requiredResponseKeys.length > 0 && ( <>

Expected JSON response keys

{doc.requiredResponseKeys.map((k) => ( {k} ))}
)} {doc.notes.length > 0 && ( <>

Notes

    {doc.notes.map((n, i) => (
  • {n}
  • ))}
)}
)}
); } const thStyle: React.CSSProperties = { padding: "5px 10px", textAlign: "left", fontWeight: 600, fontSize: 12, borderBottom: "1px solid #ddd", }; const tdStyle: React.CSSProperties = { padding: "5px 10px", verticalAlign: "top", borderBottom: "1px solid #eee", }; const codeStyle: React.CSSProperties = { background: "#ececec", padding: "1px 5px", borderRadius: 3, fontFamily: "monospace", fontSize: 12, }; // ── General tab ──────────────────────────────────────────────────────────────── function GeneralTab() { const { data: rawSettings, isLoading } = useQuery({ queryKey: ["aiSettings"], queryFn: getAISettings, }); const [provider, setProvider] = useState("lmstudio"); const [anthropicKey, setAnthropicKey] = useState(""); const [anthropicModel, setAnthropicModel] = useState(""); const [ollamaUrl, setOllamaUrl] = useState(""); const [ollamaModel, setOllamaModel] = useState(""); const [ollamaKey, setOllamaKey] = useState(""); const [lmstudioUrl, setLmstudioUrl] = useState(""); const [lmstudioModel, setLmstudioModel] = useState(""); const [lmstudioKey, setLmstudioKey] = useState(""); const [testResult, setTestResult] = useState<{ ok: boolean; response?: string; error?: string; } | null>(null); useEffect(() => { if (!rawSettings) return; const s = rawSettings as Record; if (s.provider) setProvider(s.provider as Provider); const ant = s.anthropic as Record | undefined; if (ant?.api_key) setAnthropicKey(ant.api_key); if (ant?.model) setAnthropicModel(ant.model); const oll = s.ollama as Record | undefined; if (oll?.base_url) setOllamaUrl(oll.base_url); if (oll?.model) setOllamaModel(oll.model); if (oll?.api_key) setOllamaKey(oll.api_key); const lms = s.lmstudio as Record | undefined; if (lms?.base_url) setLmstudioUrl(lms.base_url); if (lms?.model) setLmstudioModel(lms.model); if (lms?.api_key) setLmstudioKey(lms.api_key); }, [rawSettings]); const aiMut = useMutation({ mutationFn: updateAISettings }); const testMut = useMutation({ mutationFn: testAIConnection, onSuccess: (data) => setTestResult(data), }); const save = () => { aiMut.mutate({ provider, anthropic_api_key: anthropicKey, anthropic_model: anthropicModel, ollama_base_url: ollamaUrl, ollama_model: ollamaModel, ollama_api_key: ollamaKey, lmstudio_base_url: lmstudioUrl, lmstudio_model: lmstudioModel, lmstudio_api_key: lmstudioKey, }); }; if (isLoading) return
Loading…
; return (
{provider === "anthropic" && ( <> setAnthropicKey(e.target.value)} placeholder="sk-ant-… (leave blank to keep current)" style={inputStyle} /> setAnthropicModel(e.target.value)} placeholder="claude-haiku-4-5-20251001" style={inputStyle} /> )} {provider === "ollama" && ( <> setOllamaUrl(e.target.value)} placeholder="http://192.168.1.x:11434/v1" style={inputStyle} /> setOllamaModel(e.target.value)} placeholder="llama3.2" style={inputStyle} /> setOllamaKey(e.target.value)} placeholder="ollama" style={inputStyle} /> )} {provider === "lmstudio" && ( <> setLmstudioUrl(e.target.value)} placeholder="http://192.168.1.x:1234/v1" style={inputStyle} /> setLmstudioModel(e.target.value)} placeholder="local-model" style={inputStyle} /> setLmstudioKey(e.target.value)} placeholder="" style={inputStyle} /> )}
{aiMut.isSuccess && (

Settings saved.

)} {aiMut.isError && (

Failed to save settings.

)} {testResult && (
{testResult.ok ? ( <>Connected. Response: {testResult.response} ) : ( <>Connection failed: {testResult.error} )}
)}
); } // ── System Prompts tab ───────────────────────────────────────────────────────── function ServicePromptCard({ serviceId, label, initialSystem, initialUserTemplate, defaultSystem, defaultUserTemplate, }: { serviceId: string; label: string; initialSystem: string; initialUserTemplate: string; defaultSystem: string; defaultUserTemplate: string; }) { const queryClient = useQueryClient(); const [system, setSystem] = useState(initialSystem); const [userTemplate, setUserTemplate] = useState(initialUserTemplate); const [showResetConfirm, setShowResetConfirm] = useState(false); const isAtDefault = system === defaultSystem && userTemplate === defaultUserTemplate; const handleReset = () => { setSystem(defaultSystem); setUserTemplate(defaultUserTemplate); setShowResetConfirm(false); }; const mut = useMutation({ mutationFn: () => updateSystemPrompt(serviceId, { system, user_template: userTemplate }), onSuccess: (data) => queryClient.setQueryData(["systemPrompts"], data), }); return (

{label}