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:
@@ -219,3 +219,23 @@ export const updateDocumentLimits = (max_pdf_mb: number) =>
|
||||
|
||||
export const getDocumentLimits = () =>
|
||||
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
|
||||
|
||||
// --- System Prompts (admin only) ---
|
||||
export interface ServiceSystemPrompt {
|
||||
label: string;
|
||||
system: string;
|
||||
user_template: string;
|
||||
}
|
||||
|
||||
export type SystemPromptsData = Record<string, ServiceSystemPrompt>;
|
||||
|
||||
export const getSystemPrompts = () =>
|
||||
api.get<SystemPromptsData>("/settings/system-prompts").then((r) => r.data);
|
||||
|
||||
export const updateSystemPrompt = (
|
||||
serviceId: string,
|
||||
data: { system: string; user_template: string }
|
||||
) =>
|
||||
api
|
||||
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
|
||||
.then((r) => r.data);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user