bc7a74062d
Each service prompt card now shows: - A collapsible how-to panel with placeholder docs, required JSON response keys, and usage notes - A "Reset to Default" button (with confirmation step) that restores the built-in prompt without saving, letting the admin review first - A "Using the built-in default prompt" indicator when unchanged Backend includes default_system / default_user_template in the system-prompts API response so the frontend never duplicates defaults. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
671 lines
20 KiB
TypeScript
671 lines
20 KiB
TypeScript
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<string, ServiceDoc> = {
|
|
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 (
|
|
<section style={{ marginBottom: 36 }}>
|
|
<h2 style={{ fontSize: 18, marginBottom: 16 }}>{title}</h2>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#555" }}>
|
|
{label}
|
|
</label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<button
|
|
onClick={() => setOpen((v) => !v)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
fontSize: 13,
|
|
color: "#555",
|
|
padding: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 11 }}>{open ? "▼" : "▶"}</span>
|
|
How-to & required keys
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
style={{
|
|
marginTop: 10,
|
|
padding: "14px 16px",
|
|
background: "#f8f9fa",
|
|
border: "1px solid #e0e0e0",
|
|
borderRadius: 5,
|
|
fontSize: 13,
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
<p style={{ marginBottom: 10 }}>{doc.description}</p>
|
|
|
|
{doc.placeholders.length > 0 && (
|
|
<>
|
|
<p style={{ fontWeight: 600, marginBottom: 6 }}>Template placeholders</p>
|
|
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12 }}>
|
|
<thead>
|
|
<tr style={{ background: "#eee" }}>
|
|
<th style={thStyle}>Placeholder</th>
|
|
<th style={thStyle}>Required</th>
|
|
<th style={thStyle}>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{doc.placeholders.map((p) => (
|
|
<tr key={p.name}>
|
|
<td style={tdStyle}>
|
|
<code style={codeStyle}>{p.name}</code>
|
|
</td>
|
|
<td style={{ ...tdStyle, textAlign: "center" }}>
|
|
{p.required ? (
|
|
<span style={{ color: "#c00", fontWeight: 600 }}>Yes</span>
|
|
) : (
|
|
"No"
|
|
)}
|
|
</td>
|
|
<td style={tdStyle}>{p.description}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
|
|
{doc.requiredResponseKeys.length > 0 && (
|
|
<>
|
|
<p style={{ fontWeight: 600, marginBottom: 6 }}>Expected JSON response keys</p>
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 12 }}>
|
|
{doc.requiredResponseKeys.map((k) => (
|
|
<code key={k} style={codeStyle}>
|
|
{k}
|
|
</code>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{doc.notes.length > 0 && (
|
|
<>
|
|
<p style={{ fontWeight: 600, marginBottom: 6 }}>Notes</p>
|
|
<ul style={{ paddingLeft: 18, margin: 0 }}>
|
|
{doc.notes.map((n, i) => (
|
|
<li key={i} style={{ marginBottom: 4 }}>
|
|
{n}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<Provider>("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<string, unknown>;
|
|
if (s.provider) setProvider(s.provider as Provider);
|
|
const ant = s.anthropic as Record<string, string> | undefined;
|
|
if (ant?.api_key) setAnthropicKey(ant.api_key);
|
|
if (ant?.model) setAnthropicModel(ant.model);
|
|
const oll = s.ollama as Record<string, string> | 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<string, string> | 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 <div style={{ padding: 16 }}>Loading…</div>;
|
|
|
|
return (
|
|
<Section title="AI Provider">
|
|
<Field label="Provider">
|
|
<select
|
|
value={provider}
|
|
onChange={(e) => setProvider(e.target.value as Provider)}
|
|
style={inputStyle}
|
|
>
|
|
<option value="anthropic">Anthropic (cloud)</option>
|
|
<option value="ollama">Ollama (local)</option>
|
|
<option value="lmstudio">LM Studio (local)</option>
|
|
</select>
|
|
</Field>
|
|
|
|
{provider === "anthropic" && (
|
|
<>
|
|
<Field label="API Key">
|
|
<input
|
|
type="password"
|
|
value={anthropicKey}
|
|
onChange={(e) => setAnthropicKey(e.target.value)}
|
|
placeholder="sk-ant-… (leave blank to keep current)"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Model">
|
|
<input
|
|
value={anthropicModel}
|
|
onChange={(e) => setAnthropicModel(e.target.value)}
|
|
placeholder="claude-haiku-4-5-20251001"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
{provider === "ollama" && (
|
|
<>
|
|
<Field label="Base URL">
|
|
<input
|
|
value={ollamaUrl}
|
|
onChange={(e) => setOllamaUrl(e.target.value)}
|
|
placeholder="http://192.168.1.x:11434/v1"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Model">
|
|
<input
|
|
value={ollamaModel}
|
|
onChange={(e) => setOllamaModel(e.target.value)}
|
|
placeholder="llama3.2"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="API Key (usually 'ollama')">
|
|
<input
|
|
value={ollamaKey}
|
|
onChange={(e) => setOllamaKey(e.target.value)}
|
|
placeholder="ollama"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
{provider === "lmstudio" && (
|
|
<>
|
|
<Field label="Base URL">
|
|
<input
|
|
value={lmstudioUrl}
|
|
onChange={(e) => setLmstudioUrl(e.target.value)}
|
|
placeholder="http://192.168.1.x:1234/v1"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="Model">
|
|
<input
|
|
value={lmstudioModel}
|
|
onChange={(e) => setLmstudioModel(e.target.value)}
|
|
placeholder="local-model"
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
<Field label="API Key (can be empty)">
|
|
<input
|
|
value={lmstudioKey}
|
|
onChange={(e) => setLmstudioKey(e.target.value)}
|
|
placeholder=""
|
|
style={inputStyle}
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
<div style={{ display: "flex", gap: 10, marginTop: 16 }}>
|
|
<button
|
|
onClick={save}
|
|
disabled={aiMut.isPending}
|
|
style={{
|
|
padding: "8px 16px",
|
|
cursor: "pointer",
|
|
background: "#222",
|
|
color: "#fff",
|
|
borderRadius: 4,
|
|
border: "none",
|
|
}}
|
|
>
|
|
{aiMut.isPending ? "Saving…" : "Save"}
|
|
</button>
|
|
<button
|
|
onClick={() => testMut.mutate()}
|
|
disabled={testMut.isPending}
|
|
style={{ padding: "8px 16px", cursor: "pointer", borderRadius: 4, border: "1px solid #ccc" }}
|
|
>
|
|
{testMut.isPending ? "Testing…" : "Test Connection"}
|
|
</button>
|
|
</div>
|
|
|
|
{aiMut.isSuccess && (
|
|
<p style={{ marginTop: 8, fontSize: 13, color: "#2a9d8f" }}>Settings saved.</p>
|
|
)}
|
|
{aiMut.isError && (
|
|
<p style={{ marginTop: 8, fontSize: 13, color: "#c00" }}>Failed to save settings.</p>
|
|
)}
|
|
|
|
{testResult && (
|
|
<div
|
|
style={{
|
|
marginTop: 10,
|
|
padding: 10,
|
|
borderRadius: 4,
|
|
background: testResult.ok ? "#e8f5e9" : "#fdecea",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{testResult.ok ? (
|
|
<>Connected. Response: <em>{testResult.response}</em></>
|
|
) : (
|
|
<>Connection failed: {testResult.error}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<div
|
|
style={{
|
|
border: "1px solid #e0e0e0",
|
|
borderRadius: 6,
|
|
padding: 20,
|
|
marginBottom: 24,
|
|
}}
|
|
>
|
|
<h3 style={{ fontSize: 16, marginBottom: 14 }}>{label}</h3>
|
|
|
|
<ServiceDocsPanel serviceId={serviceId} />
|
|
|
|
<Field label="System Prompt">
|
|
<textarea
|
|
value={system}
|
|
onChange={(e) => setSystem(e.target.value)}
|
|
style={{ ...textareaStyle, minHeight: 80 }}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="User Prompt Template">
|
|
<textarea
|
|
value={userTemplate}
|
|
onChange={(e) => setUserTemplate(e.target.value)}
|
|
style={{ ...textareaStyle, minHeight: 220 }}
|
|
/>
|
|
</Field>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
|
|
<button
|
|
onClick={() => mut.mutate()}
|
|
disabled={mut.isPending}
|
|
style={{
|
|
padding: "8px 16px",
|
|
cursor: "pointer",
|
|
background: "#222",
|
|
color: "#fff",
|
|
borderRadius: 4,
|
|
border: "none",
|
|
}}
|
|
>
|
|
{mut.isPending ? "Saving…" : "Save"}
|
|
</button>
|
|
|
|
{!isAtDefault && !showResetConfirm && (
|
|
<button
|
|
onClick={() => setShowResetConfirm(true)}
|
|
style={{
|
|
padding: "8px 16px",
|
|
cursor: "pointer",
|
|
borderRadius: 4,
|
|
border: "1px solid #ccc",
|
|
background: "transparent",
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
Reset to Default
|
|
</button>
|
|
)}
|
|
|
|
{showResetConfirm && (
|
|
<span style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span style={{ color: "#555" }}>Discard changes and restore the built-in default?</span>
|
|
<button
|
|
onClick={handleReset}
|
|
style={{
|
|
padding: "4px 12px",
|
|
cursor: "pointer",
|
|
borderRadius: 4,
|
|
border: "1px solid #c00",
|
|
color: "#c00",
|
|
background: "transparent",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
Yes, reset
|
|
</button>
|
|
<button
|
|
onClick={() => setShowResetConfirm(false)}
|
|
style={{
|
|
padding: "4px 12px",
|
|
cursor: "pointer",
|
|
borderRadius: 4,
|
|
border: "1px solid #ccc",
|
|
background: "transparent",
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</span>
|
|
)}
|
|
|
|
{mut.isSuccess && (
|
|
<span style={{ fontSize: 13, color: "#2a9d8f" }}>Saved.</span>
|
|
)}
|
|
{mut.isError && (
|
|
<span style={{ fontSize: 13, color: "#c00" }}>Failed to save.</span>
|
|
)}
|
|
</div>
|
|
|
|
{isAtDefault && (
|
|
<p style={{ marginTop: 8, fontSize: 12, color: "#aaa" }}>
|
|
Using the built-in default prompt.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SystemPromptsTab() {
|
|
const { data, isLoading, isError } = useQuery<SystemPromptsData>({
|
|
queryKey: ["systemPrompts"],
|
|
queryFn: getSystemPrompts,
|
|
});
|
|
|
|
if (isLoading) return <div style={{ padding: 16 }}>Loading…</div>;
|
|
if (isError || !data)
|
|
return <div style={{ padding: 16, color: "#c00" }}>Failed to load system prompts.</div>;
|
|
|
|
return (
|
|
<Section title="Service System Prompts">
|
|
<p style={{ fontSize: 13, color: "#666", marginBottom: 20 }}>
|
|
Each service sends its own system prompt to the AI model. Changes take effect within 30 seconds (config cache TTL).
|
|
</p>
|
|
{Object.entries(data).map(
|
|
([serviceId, { label, system, user_template, default_system, default_user_template }]) => (
|
|
<ServicePromptCard
|
|
key={serviceId}
|
|
serviceId={serviceId}
|
|
label={label}
|
|
initialSystem={system}
|
|
initialUserTemplate={user_template}
|
|
defaultSystem={default_system}
|
|
defaultUserTemplate={default_user_template}
|
|
/>
|
|
)
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// ── Page root ──────────────────────────────────────────────────────────────────
|
|
|
|
const tabBarStyle: React.CSSProperties = {
|
|
display: "flex",
|
|
gap: 0,
|
|
borderBottom: "1px solid #e0e0e0",
|
|
marginBottom: 32,
|
|
};
|
|
|
|
function TabButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
style={{
|
|
padding: "10px 20px",
|
|
fontSize: 14,
|
|
cursor: "pointer",
|
|
border: "none",
|
|
borderBottom: active ? "2px solid #222" : "2px solid transparent",
|
|
background: "transparent",
|
|
fontWeight: active ? 600 : 400,
|
|
color: active ? "#111" : "#666",
|
|
marginBottom: -1,
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function AIAdminSettingsPage() {
|
|
const [tab, setTab] = useState<Tab>("general");
|
|
|
|
return (
|
|
<div style={{ padding: 32, maxWidth: 700, margin: "0 auto" }}>
|
|
<h1 style={{ fontSize: 24, marginBottom: 28 }}>AI Service — Settings</h1>
|
|
|
|
<div style={tabBarStyle}>
|
|
<TabButton active={tab === "general"} onClick={() => setTab("general")}>
|
|
General
|
|
</TabButton>
|
|
<TabButton active={tab === "system-prompts"} onClick={() => setTab("system-prompts")}>
|
|
System Prompts
|
|
</TabButton>
|
|
</div>
|
|
|
|
{tab === "general" && <GeneralTab />}
|
|
{tab === "system-prompts" && <SystemPromptsTab />}
|
|
</div>
|
|
);
|
|
}
|