Add per-service system prompts with AI Settings tab view

Each feature service owns its system prompt in its config JSON on the
shared volume. The AI Settings page now has General and System Prompts
tabs — admins can view and edit any service's prompts at runtime with
changes taking effect within 30 s (config cache TTL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-17 15:11:40 +02:00
parent 3a501f7e05
commit 1d01cc3b0e
9 changed files with 522 additions and 146 deletions
+323 -143
View File
@@ -1,8 +1,18 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { getAISettings, updateAISettings, testAIConnection } from "../api/client";
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";
// ── Shared layout helpers ──────────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -33,7 +43,16 @@ const inputStyle: React.CSSProperties = {
boxSizing: "border-box",
};
export default function AIAdminSettingsPage() {
const textareaStyle: React.CSSProperties = {
...inputStyle,
fontFamily: "monospace",
resize: "vertical",
minHeight: 120,
};
// ── General tab ────────────────────────────────────────────────────────────────
function GeneralTab() {
const { data: rawSettings, isLoading } = useQuery({
queryKey: ["aiSettings"],
queryFn: getAISettings,
@@ -48,7 +67,6 @@ export default function AIAdminSettingsPage() {
const [lmstudioUrl, setLmstudioUrl] = useState("");
const [lmstudioModel, setLmstudioModel] = useState("");
const [lmstudioKey, setLmstudioKey] = useState("");
const [testResult, setTestResult] = useState<{
ok: boolean;
response?: string;
@@ -58,7 +76,6 @@ export default function AIAdminSettingsPage() {
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);
@@ -74,7 +91,6 @@ export default function AIAdminSettingsPage() {
}, [rawSettings]);
const aiMut = useMutation({ mutationFn: updateAISettings });
const testMut = useMutation({
mutationFn: testAIConnection,
onSuccess: (data) => setTestResult(data),
@@ -94,151 +110,315 @@ export default function AIAdminSettingsPage() {
});
};
if (isLoading) {
return <div style={{ padding: 32 }}>Loading</div>;
}
if (isLoading) return <div style={{ padding: 16 }}>Loading</div>;
return (
<>
<div style={{ padding: 32, maxWidth: 600, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>AI Service Settings</h1>
<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>
<Section title="AI Provider">
<Field label="Provider">
<select
value={provider}
onChange={(e) => setProvider(e.target.value as Provider)}
{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}
>
<option value="anthropic">Anthropic (cloud)</option>
<option value="ollama">Ollama (local)</option>
<option value="lmstudio">LM Studio (local)</option>
</select>
/>
</Field>
<Field label="Model">
<input
value={anthropicModel}
onChange={(e) => setAnthropicModel(e.target.value)}
placeholder="claude-haiku-4-5-20251001"
style={inputStyle}
/>
</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 === "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>
</>
)}
{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>
<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,
}: {
serviceId: string;
label: string;
initialSystem: string;
initialUserTemplate: string;
}) {
const queryClient = useQueryClient();
const [system, setSystem] = useState(initialSystem);
const [userTemplate, setUserTemplate] = useState(initialUserTemplate);
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: 16 }}>{label}</h3>
<Field label="System Prompt">
<textarea
value={system}
onChange={(e) => setSystem(e.target.value)}
style={{ ...textareaStyle, minHeight: 80 }}
/>
</Field>
<Field label="User Prompt Template">
<p style={{ fontSize: 12, color: "#888", marginBottom: 6 }}>
Use <code style={{ background: "#f4f4f4", padding: "1px 4px", borderRadius: 3 }}>{"{text}"}</code> as the placeholder for the document text.
</p>
<textarea
value={userTemplate}
onChange={(e) => setUserTemplate(e.target.value)}
style={{ ...textareaStyle, minHeight: 220 }}
/>
</Field>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 12 }}>
<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>
{mut.isSuccess && (
<span style={{ fontSize: 13, color: "#2a9d8f" }}>Saved.</span>
)}
{mut.isError && (
<span style={{ fontSize: 13, color: "#c00" }}>Failed to save.</span>
)}
</div>
</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 }]) => (
<ServicePromptCard
key={serviceId}
serviceId={serviceId}
label={label}
initialSystem={system}
initialUserTemplate={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>
);
}