Add shared ai-service container as AI provider intermediary

All feature containers now POST messages to ai-service (port 8010) instead
of calling AI providers directly. ai-service routes to LM Studio, Ollama,
or Anthropic based on /config/ai_service_config.json. doc-service AI
providers removed; replaced by httpx ai_client.py. Backend settings
restructured to /api/settings/ai. Frontend gets dedicated AIAdminSettingsPage
and AI Service card in AppsPage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 12:30:45 +02:00
parent 52a2967f61
commit 88c1ea297e
47 changed files with 1354 additions and 497 deletions
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Nav from "../components/Nav";
import { getAISettings, updateAISettings, testAIConnection } from "../api/client";
type Provider = "anthropic" | "ollama" | "lmstudio";
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",
};
export default function AIAdminSettingsPage() {
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 (
<>
<Nav />
<div style={{ padding: 32 }}>Loading</div>
</>
);
}
return (
<>
<Nav />
<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>
{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>
</div>
</>
);
}
+9 -1
View File
@@ -21,6 +21,14 @@ const APPS: AppCard[] = [
path: "/apps/documents",
settingsPath: "/apps/documents/settings/admin",
},
{
slug: "ai",
name: "AI Service",
description: "Shared AI provider for all features. Configure model, credentials, and connection.",
status: "available",
path: "",
settingsPath: "/apps/ai/settings/admin",
},
];
export default function AppsPage() {
@@ -55,7 +63,7 @@ export default function AppsPage() {
</div>
<p style={{ margin: 0, color: "#555", fontSize: 14 }}>{app.description}</p>
<div style={{ display: "flex", gap: 8, marginTop: "auto" }}>
{app.status === "available" && (
{app.status === "available" && app.path && (
<Link
to={app.path}
style={{
+22 -232
View File
@@ -1,43 +1,10 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import Nav from "../components/Nav";
import {
getDocumentSettings,
updateDocumentAISettings,
testDocumentAIConnection,
updateDocumentLimits,
} from "../api/client";
type Provider = "anthropic" | "ollama" | "lmstudio";
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>
);
}
import { getDocumentLimits, updateDocumentLimits } from "../api/client";
const inputStyle: React.CSSProperties = {
width: "100%",
width: 120,
padding: "7px 10px",
fontSize: 14,
border: "1px solid #ccc",
@@ -47,78 +14,25 @@ const inputStyle: React.CSSProperties = {
export default function DocumentAdminSettingsPage() {
const { data: rawSettings, isLoading } = useQuery({
queryKey: ["docSettings"],
queryFn: getDocumentSettings,
queryKey: ["docLimits"],
queryFn: getDocumentLimits,
});
const [provider, setProvider] = useState<Provider>("anthropic");
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 [maxPdfMb, setMaxPdfMb] = useState(20);
const [testResult, setTestResult] = useState<{
ok: boolean;
response?: string;
error?: string;
} | null>(null);
// Populate form from loaded settings
useEffect(() => {
if (!rawSettings) return;
const s = rawSettings as Record<string, unknown>;
const ai = s.ai as Record<string, unknown> | undefined;
const docs = s.documents as Record<string, unknown> | undefined;
if (ai?.provider) setProvider(ai.provider as Provider);
const ant = ai?.anthropic as Record<string, string> | undefined;
if (ant?.api_key) setAnthropicKey(ant.api_key);
if (ant?.model) setAnthropicModel(ant.model);
const oll = ai?.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 = ai?.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);
if (typeof docs?.max_pdf_bytes === "number") {
setMaxPdfMb(Math.round((docs.max_pdf_bytes as number) / (1024 * 1024)));
}
}, [rawSettings]);
const aiMut = useMutation({
mutationFn: updateDocumentAISettings,
});
const testMut = useMutation({
mutationFn: testDocumentAIConnection,
onSuccess: (data) => setTestResult(data),
});
const limitsMut = useMutation({
mutationFn: (mb: number) => updateDocumentLimits(mb),
});
const saveAI = () => {
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 (
<>
@@ -134,164 +48,40 @@ export default function DocumentAdminSettingsPage() {
<div style={{ padding: 32, maxWidth: 600, margin: "0 auto" }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>Documents 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>
{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={saveAI}
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>
<Section title="Upload Limits">
<Field label="Max file size (MB)">
<section>
<h2 style={{ fontSize: 18, marginBottom: 16 }}>Upload Limits</h2>
<div style={{ marginBottom: 12 }}>
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "#555" }}>
Max file size (MB)
</label>
<input
type="number"
min={1}
max={200}
value={maxPdfMb}
onChange={(e) => setMaxPdfMb(Number(e.target.value))}
style={{ ...inputStyle, width: 120 }}
style={inputStyle}
/>
</Field>
</div>
<button
onClick={() => limitsMut.mutate(maxPdfMb)}
disabled={limitsMut.isPending}
style={{ padding: "8px 16px", cursor: "pointer", background: "#222", color: "#fff", borderRadius: 4, border: "none", marginTop: 8 }}
style={{
padding: "8px 16px",
cursor: "pointer",
background: "#222",
color: "#fff",
borderRadius: 4,
border: "none",
marginTop: 8,
}}
>
{limitsMut.isPending ? "Saving…" : "Save"}
</button>
{limitsMut.isSuccess && (
<p style={{ marginTop: 8, fontSize: 13, color: "#2a9d8f" }}>Limits saved.</p>
)}
</Section>
</section>
</div>
</>
);