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:
@@ -47,13 +47,46 @@ class AIServiceConfig(BaseModel):
|
||||
|
||||
# ── Doc service config schemas ─────────────────────────────────────────────────
|
||||
|
||||
_DOC_SYSTEM_PROMPT_DEFAULT = (
|
||||
"You are a financial document analysis assistant. "
|
||||
"Given the text extracted from a PDF document, return ONLY a JSON object "
|
||||
"with no markdown, no code fences, and no explanation."
|
||||
)
|
||||
|
||||
_DOC_USER_TEMPLATE_DEFAULT = (
|
||||
'Analyze the following document text and return a JSON object with exactly these keys:\n'
|
||||
'title (a short, descriptive human-readable title for this document, e.g. "ACME Corp Invoice April 2026", "Office Supplies Receipt", "Q1 Flower Delivery Order"),\n'
|
||||
'document_type (one of: invoice, bill, receipt, order, expense, revenue, unknown),\n'
|
||||
'total_amount (string or null),\n'
|
||||
'currency (string or null),\n'
|
||||
'vendor_name (string or null),\n'
|
||||
'customer_name (string or null),\n'
|
||||
'billing_address (string or null),\n'
|
||||
'customer_address (string or null),\n'
|
||||
'invoice_number (string or null),\n'
|
||||
'invoice_date (string or null),\n'
|
||||
'due_date (string or null),\n'
|
||||
'tags (array of short keyword strings describing the document),\n'
|
||||
'line_items (array of objects, each with keys: description, amount),\n'
|
||||
'suggested_categories (array of 2 to 5 short category name strings a user might want to file this document under, e.g. "Utilities", "Travel", "Software Subscriptions", "Client Invoices").\n'
|
||||
'\n'
|
||||
'Document text:\n'
|
||||
'{text}'
|
||||
)
|
||||
|
||||
|
||||
class DocumentsConfig(BaseModel):
|
||||
max_pdf_bytes: int = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class DocServiceSystemPrompts(BaseModel):
|
||||
system: str = _DOC_SYSTEM_PROMPT_DEFAULT
|
||||
user_template: str = _DOC_USER_TEMPLATE_DEFAULT
|
||||
|
||||
|
||||
class DocServiceConfig(BaseModel):
|
||||
documents: DocumentsConfig = DocumentsConfig()
|
||||
system_prompts: DocServiceSystemPrompts = DocServiceSystemPrompts()
|
||||
|
||||
|
||||
# ── Masking ────────────────────────────────────────────────────────────────────
|
||||
@@ -134,3 +167,45 @@ def _merge_api_key(new_key: str, existing_key: str) -> str:
|
||||
if not new_key or "••••" in new_key:
|
||||
return existing_key
|
||||
return new_key
|
||||
|
||||
|
||||
# ── System prompts helpers ─────────────────────────────────────────────────────
|
||||
|
||||
# Registry of all services that have editable system prompts.
|
||||
# key = service identifier, value = human-readable label
|
||||
SYSTEM_PROMPT_SERVICES: dict[str, str] = {
|
||||
"doc_service": "Document Service",
|
||||
}
|
||||
|
||||
|
||||
def load_all_system_prompts() -> dict:
|
||||
"""Return {service_id: {label, system, user_template}} for all registered services."""
|
||||
result: dict = {}
|
||||
for service_id, label in SYSTEM_PROMPT_SERVICES.items():
|
||||
config = load_service_config(service_id)
|
||||
prompts = config.get("system_prompts", {})
|
||||
defaults = _get_service_prompt_defaults(service_id)
|
||||
result[service_id] = {
|
||||
"label": label,
|
||||
"system": prompts.get("system", defaults["system"]),
|
||||
"user_template": prompts.get("user_template", defaults["user_template"]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def save_service_system_prompts(service_id: str, system: str, user_template: str) -> None:
|
||||
"""Persist updated system prompts into the service's config file."""
|
||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||
raise ValueError(f"Unknown service: {service_id!r}")
|
||||
config = load_service_config(service_id)
|
||||
config.setdefault("system_prompts", {})
|
||||
config["system_prompts"]["system"] = system
|
||||
config["system_prompts"]["user_template"] = user_template
|
||||
save_service_config(service_id, config)
|
||||
|
||||
|
||||
def _get_service_prompt_defaults(service_id: str) -> dict:
|
||||
if service_id == "doc_service":
|
||||
d = DocServiceSystemPrompts()
|
||||
return {"system": d.system, "user_template": d.user_template}
|
||||
return {"system": "", "user_template": ""}
|
||||
|
||||
@@ -11,13 +11,16 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.app_config import (
|
||||
SYSTEM_PROMPT_SERVICES,
|
||||
_merge_api_key,
|
||||
load_ai_service_config,
|
||||
load_ai_service_config_masked,
|
||||
load_all_system_prompts,
|
||||
load_doc_service_config,
|
||||
load_doc_service_config_masked,
|
||||
save_ai_service_config,
|
||||
save_doc_service_config,
|
||||
save_service_system_prompts,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.deps import get_current_admin
|
||||
@@ -45,6 +48,11 @@ class LimitsUpdate(BaseModel):
|
||||
max_pdf_mb: int
|
||||
|
||||
|
||||
class SystemPromptUpdate(BaseModel):
|
||||
system: str
|
||||
user_template: str
|
||||
|
||||
|
||||
# ── AI settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -142,3 +150,29 @@ async def update_documents_limits(
|
||||
config.documents.max_pdf_bytes = body.max_pdf_mb * 1024 * 1024
|
||||
await asyncio.to_thread(save_doc_service_config, config)
|
||||
return load_doc_service_config_masked()
|
||||
|
||||
|
||||
# ── System prompts ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system-prompts")
|
||||
async def get_system_prompts(
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Return all editable system prompts, keyed by service id."""
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
|
||||
|
||||
@router.patch("/system-prompts/{service_id}")
|
||||
async def update_system_prompt(
|
||||
service_id: str,
|
||||
body: SystemPromptUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Update the system prompts for a single service."""
|
||||
if service_id not in SYSTEM_PROMPT_SERVICES:
|
||||
raise HTTPException(status_code=404, detail=f"No system prompts registered for {service_id!r}")
|
||||
await asyncio.to_thread(
|
||||
save_service_system_prompts, service_id, body.system, body.user_template
|
||||
)
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
|
||||
@@ -59,3 +59,22 @@
|
||||
- `frontend/src/pages/DocumentAdminSettingsPage.tsx` — removed Nav
|
||||
- `frontend/src/pages/AIAdminSettingsPage.tsx` — removed Nav
|
||||
- `frontend/STATUS.md` — added component inventory table; updated What it is; updated Future work checklist
|
||||
|
||||
---
|
||||
|
||||
# 2026-04-17 — Per-service system prompts with AI Settings tab view
|
||||
|
||||
**Timestamp:** 2026-04-17T12:00:00
|
||||
|
||||
**Summary:** Added runtime-editable system prompts per service, stored in each service's config file on the shared volume. The AI Service Settings page now has a tab view (General / System Prompts).
|
||||
|
||||
**Files Added / Modified / Deleted**
|
||||
|
||||
- `backend/app/core/app_config.py` — Added `DocServiceSystemPrompts` model, updated `DocServiceConfig`, added `load_all_system_prompts`, `save_service_system_prompts`, `SYSTEM_PROMPT_SERVICES` registry
|
||||
- `backend/app/routers/settings.py` — Added `SystemPromptUpdate` schema, `GET /system-prompts` and `PATCH /system-prompts/{service_id}` endpoints
|
||||
- `features/doc-service/app/services/config_reader.py` — Added `_DEFAULT_SYSTEM_PROMPT`, `_DEFAULT_USER_TEMPLATE`, and `system_prompts` key to `_DEFAULT_CONFIG`
|
||||
- `features/doc-service/app/services/ai_client.py` — Loads system prompt and user template dynamically from config at runtime; falls back to defaults
|
||||
- `frontend/src/api/client.ts` — Added `ServiceSystemPrompt`, `SystemPromptsData` types, `getSystemPrompts` and `updateSystemPrompt` API functions
|
||||
- `frontend/src/pages/AIAdminSettingsPage.tsx` — Refactored to tab view (General | System Prompts); System Prompts tab shows per-service editable textarea cards
|
||||
- `features/ai-service/STATUS.md` — Documented system prompts architecture
|
||||
- `features/doc-service/STATUS.md` — Documented runtime prompt loading
|
||||
|
||||
@@ -101,6 +101,12 @@ Callers (doc-service, future services)
|
||||
|
||||
---
|
||||
|
||||
## System prompts
|
||||
|
||||
Each feature service (doc-service, future services) owns its own system prompt, stored in that service's config JSON on the shared volume. The backend settings API (`GET/PATCH /api/settings/system-prompts`) aggregates and edits them. The AI Service Settings UI exposes a **System Prompts** tab for editing all registered service prompts at runtime.
|
||||
|
||||
---
|
||||
|
||||
## Future work
|
||||
|
||||
- [ ] TLS support for LM Studio / Ollama (`ssl_verify`, `ca_bundle` config)
|
||||
|
||||
@@ -72,6 +72,8 @@ categories many-to-many via category_assignments
|
||||
|
||||
### AI extraction (via ai-service)
|
||||
|
||||
System prompt and user prompt template are loaded at runtime from `doc_service_config.json` (`system_prompts` key). Defaults are built into the service and used as fallback if the config key is absent. Changes made via the AI Settings UI take effect within 30 seconds (config cache TTL).
|
||||
|
||||
Prompt sends the first 50 000 chars of extracted text. Expected JSON response includes:
|
||||
- `title` — suggested human-readable title
|
||||
- `document_type` — invoice / bill / receipt / order / expense / revenue / unknown
|
||||
|
||||
@@ -4,7 +4,11 @@ import json
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.prompts import SYSTEM_PROMPT, USER_PROMPT_TEMPLATE
|
||||
from app.services.config_reader import (
|
||||
_DEFAULT_SYSTEM_PROMPT,
|
||||
_DEFAULT_USER_TEMPLATE,
|
||||
load_doc_config,
|
||||
)
|
||||
|
||||
_client = httpx.AsyncClient(timeout=120.0)
|
||||
|
||||
@@ -19,9 +23,14 @@ async def classify_document(text: str) -> dict:
|
||||
Returns the parsed JSON result dict.
|
||||
Raises AIServiceError on HTTP errors or unexpected response shapes.
|
||||
"""
|
||||
config = await load_doc_config()
|
||||
prompts = config.get("system_prompts", {})
|
||||
system_prompt = prompts.get("system") or _DEFAULT_SYSTEM_PROMPT
|
||||
user_template = prompts.get("user_template") or _DEFAULT_USER_TEMPLATE
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": USER_PROMPT_TEMPLATE.format(text=text[:50_000])},
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_template.format(text=text[:50_000])},
|
||||
]
|
||||
|
||||
try:
|
||||
|
||||
@@ -14,8 +14,39 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
_DEFAULT_SYSTEM_PROMPT = (
|
||||
"You are a financial document analysis assistant. "
|
||||
"Given the text extracted from a PDF document, return ONLY a JSON object "
|
||||
"with no markdown, no code fences, and no explanation."
|
||||
)
|
||||
|
||||
_DEFAULT_USER_TEMPLATE = (
|
||||
'Analyze the following document text and return a JSON object with exactly these keys:\n'
|
||||
'title (a short, descriptive human-readable title for this document, e.g. "ACME Corp Invoice April 2026", "Office Supplies Receipt", "Q1 Flower Delivery Order"),\n'
|
||||
'document_type (one of: invoice, bill, receipt, order, expense, revenue, unknown),\n'
|
||||
'total_amount (string or null),\n'
|
||||
'currency (string or null),\n'
|
||||
'vendor_name (string or null),\n'
|
||||
'customer_name (string or null),\n'
|
||||
'billing_address (string or null),\n'
|
||||
'customer_address (string or null),\n'
|
||||
'invoice_number (string or null),\n'
|
||||
'invoice_date (string or null),\n'
|
||||
'due_date (string or null),\n'
|
||||
'tags (array of short keyword strings describing the document),\n'
|
||||
'line_items (array of objects, each with keys: description, amount),\n'
|
||||
'suggested_categories (array of 2 to 5 short category name strings a user might want to file this document under, e.g. "Utilities", "Travel", "Software Subscriptions", "Client Invoices").\n'
|
||||
'\n'
|
||||
'Document text:\n'
|
||||
'{text}'
|
||||
)
|
||||
|
||||
_DEFAULT_CONFIG: dict = {
|
||||
"documents": {"max_pdf_bytes": 20 * 1024 * 1024},
|
||||
"system_prompts": {
|
||||
"system": _DEFAULT_SYSTEM_PROMPT,
|
||||
"user_template": _DEFAULT_USER_TEMPLATE,
|
||||
},
|
||||
}
|
||||
|
||||
_cache: dict | None = None
|
||||
|
||||
@@ -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