diff --git a/src/app/(dashboard)/einstellungen/ai-settings.tsx b/src/app/(dashboard)/einstellungen/ai-settings.tsx new file mode 100644 index 0000000..dacd721 --- /dev/null +++ b/src/app/(dashboard)/einstellungen/ai-settings.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface AISettings { + provider: string; + model: string; + ollamaUrl: string; + ollamaModel: string; +} + +export default function AISettingsForm() { + const [settings, setSettings] = useState({ + provider: 'anthropic', + model: '', + ollamaUrl: 'http://localhost:11434', + ollamaModel: 'llama3', + }); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/settings/ai') + .then((r) => r.json()) + .then((data) => { + setSettings(data); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMessage(null); + + try { + const res = await fetch('/api/settings/ai', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + + if (res.ok) { + setMessage({ type: 'success', text: 'AI-Einstellungen gespeichert.' }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error ?? 'Fehler beim Speichern.' }); + } + } catch { + setMessage({ type: 'error', text: 'Netzwerkfehler.' }); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+

Lade AI-Einstellungen...

+
+ ); + } + + return ( +
+

AI-Provider

+ +
+
+ + +
+ + {settings.provider !== 'ollama' && ( +
+ + setSettings({ ...settings, model: e.target.value })} + placeholder={settings.provider === 'anthropic' ? 'claude-sonnet-4-20250514' : 'gpt-4o'} + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+ )} + + {settings.provider === 'ollama' && ( + <> +
+ + setSettings({ ...settings, ollamaUrl: e.target.value })} + placeholder="http://localhost:11434" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+
+ + setSettings({ ...settings, ollamaModel: e.target.value })} + placeholder="llama3" + className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted/50" + /> +
+

+ Ollama muss lokal oder auf dem Server laufen. Die OpenAI-kompatible API wird verwendet. +

+ + )} +
+ + {message && ( +

+ {message.text} +

+ )} + + +
+ ); +} diff --git a/src/app/(dashboard)/einstellungen/page.tsx b/src/app/(dashboard)/einstellungen/page.tsx index ea19b7e..bc677fe 100644 --- a/src/app/(dashboard)/einstellungen/page.tsx +++ b/src/app/(dashboard)/einstellungen/page.tsx @@ -4,6 +4,7 @@ import { hasPermission } from '@/lib/auth/rbac'; import { db } from '@/lib/db'; import { tenants, users } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; +import AISettingsForm from './ai-settings'; const ROLE_LABELS: Record = { admin: 'Administrator', @@ -70,6 +71,8 @@ export default async function EinstellungenPage() { + {isAdmin && } + {isAdmin && tenantUsers.length > 0 && (

Benutzer

diff --git a/src/app/api/settings/ai/route.ts b/src/app/api/settings/ai/route.ts new file mode 100644 index 0000000..12c5fa3 --- /dev/null +++ b/src/app/api/settings/ai/route.ts @@ -0,0 +1,66 @@ +// GET /api/settings/ai — Read current AI provider settings for tenant +// PATCH /api/settings/ai — Update AI provider settings (admin only) + +import { db } from '@/lib/db'; +import { tenants } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { requirePermission } from '@/lib/auth/rbac'; +import type { AIProvider } from '@/lib/ai/providers'; + +const VALID_PROVIDERS = new Set(['anthropic', 'openai', 'ollama']); + +export async function GET() { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + + const [tenant] = await db + .select({ settings: tenants.settings }) + .from(tenants) + .where(eq(tenants.id, auth.ctx.tenantId)) + .limit(1); + + const settings = tenant?.settings ?? {}; + return Response.json({ + provider: (settings.aiProvider as string) ?? process.env.AI_PROVIDER ?? 'anthropic', + model: (settings.aiModel as string) ?? process.env.AI_MODEL ?? '', + ollamaUrl: (settings.ollamaUrl as string) ?? process.env.OLLAMA_URL ?? 'http://localhost:11434', + ollamaModel: (settings.ollamaModel as string) ?? process.env.OLLAMA_MODEL ?? 'llama3', + }); +} + +export async function PATCH(request: Request) { + const auth = await requirePermission('settings:manage'); + if ('response' in auth) return auth.response; + + const body = await request.json(); + const { provider, model, ollamaUrl, ollamaModel } = body; + + if (provider && !VALID_PROVIDERS.has(provider)) { + return Response.json( + { error: `Ungültiger Provider. Erlaubt: ${[...VALID_PROVIDERS].join(', ')}` }, + { status: 400 }, + ); + } + + const [tenant] = await db + .select({ settings: tenants.settings }) + .from(tenants) + .where(eq(tenants.id, auth.ctx.tenantId)) + .limit(1); + + const current = (tenant?.settings ?? {}) as Record; + const updated = { + ...current, + ...(provider !== undefined && { aiProvider: provider }), + ...(model !== undefined && { aiModel: model }), + ...(ollamaUrl !== undefined && { ollamaUrl }), + ...(ollamaModel !== undefined && { ollamaModel }), + }; + + await db + .update(tenants) + .set({ settings: updated, updatedAt: new Date() }) + .where(eq(tenants.id, auth.ctx.tenantId)); + + return Response.json({ ok: true }); +} diff --git a/src/lib/ai/analysis.ts b/src/lib/ai/analysis.ts index c30d672..7072f26 100644 --- a/src/lib/ai/analysis.ts +++ b/src/lib/ai/analysis.ts @@ -1,7 +1,7 @@ // Core analysis service — orchestrates norm/decision lookup, prompt assembly, and AI generation import { streamText, generateText } from 'ai'; -import { getModel, getProvider } from './providers'; +import { getModelForTenant } from './providers'; import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts'; import { ANALYSIS_MODES } from './modes'; import { AnalyseMode } from '@/types'; @@ -108,7 +108,7 @@ export async function runAnalysis(input: AnalysisInput) { const contextBlock = buildContextBlock(normContext, decisionContext); - const model = getModel(); + const { model, provider, modelId } = await getModelForTenant(input.tenantId); // Create the analysis record (status: in_progress) const [analysis] = await db @@ -121,8 +121,8 @@ export async function runAnalysis(input: AnalysisInput) { status: 'in_progress', title: input.title, query: input.query, - aiProvider: getProvider(), - aiModel: process.env.AI_MODEL ?? 'default', + aiProvider: provider, + aiModel: modelId, sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), @@ -182,7 +182,7 @@ export async function runAnalysisSync(input: AnalysisInput) { ? `${contextBlock}\n\n---\n\n## Rechtsfrage\n\n${input.query}` : input.query; - const model = getModel(); + const { model, provider, modelId } = await getModelForTenant(input.tenantId); const [analysis] = await db .insert(analyses) @@ -194,8 +194,8 @@ export async function runAnalysisSync(input: AnalysisInput) { status: 'in_progress', title: input.title, query: input.query, - aiProvider: getProvider(), - aiModel: process.env.AI_MODEL ?? 'default', + aiProvider: provider, + aiModel: modelId, sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), diff --git a/src/lib/ai/providers/index.ts b/src/lib/ai/providers/index.ts index fbdf786..72b3524 100644 --- a/src/lib/ai/providers/index.ts +++ b/src/lib/ai/providers/index.ts @@ -1,31 +1,69 @@ // AI Provider abstraction via Vercel AI SDK v6 -// Supports: Anthropic, OpenAI — selected via AI_PROVIDER env var +// Supports: Anthropic, OpenAI, Ollama — selected via AI_PROVIDER env var or tenant settings import { anthropic } from '@ai-sdk/anthropic'; -import { openai } from '@ai-sdk/openai'; +import { createOpenAI, openai } from '@ai-sdk/openai'; import type { LanguageModel } from 'ai'; +import { db } from '@/lib/db'; +import { tenants } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; -export type AIProvider = 'anthropic' | 'openai'; +export type AIProvider = 'anthropic' | 'openai' | 'ollama'; const DEFAULT_MODELS: Record = { anthropic: 'claude-sonnet-4-20250514', openai: 'gpt-4o', + ollama: 'llama3', }; export function getProvider(): AIProvider { const p = process.env.AI_PROVIDER; if (p === 'openai') return 'openai'; + if (p === 'ollama') return 'ollama'; return 'anthropic'; } +function buildModel(provider: AIProvider, modelId: string, ollamaUrl?: string): LanguageModel { + switch (provider) { + case 'anthropic': + return anthropic(modelId); + case 'openai': + return openai(modelId); + case 'ollama': { + const baseURL = ollamaUrl ?? process.env.OLLAMA_URL ?? 'http://localhost:11434'; + const ollama = createOpenAI({ + baseURL: `${baseURL}/v1`, + apiKey: 'ollama', + }); + return ollama(modelId); + } + } +} + export function getModel(provider?: AIProvider, modelId?: string): LanguageModel { const p = provider ?? getProvider(); const id = modelId ?? process.env.AI_MODEL ?? DEFAULT_MODELS[p]; - - switch (p) { - case 'anthropic': - return anthropic(id); - case 'openai': - return openai(id); - } + return buildModel(p, id); +} + +/** Tenant-aware model getter: reads AI settings from tenant DB, falls back to env vars. */ +export async function getModelForTenant(tenantId: string): Promise<{ model: LanguageModel; provider: AIProvider; modelId: string }> { + const [tenant] = await db + .select({ settings: tenants.settings }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + const s = tenant?.settings as Record | undefined; + const provider: AIProvider = (['anthropic', 'openai', 'ollama'].includes(s?.aiProvider ?? '') ? s!.aiProvider : getProvider()) as AIProvider; + + let modelId: string; + if (provider === 'ollama') { + modelId = s?.ollamaModel ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODELS.ollama; + } else { + modelId = s?.aiModel || process.env.AI_MODEL || DEFAULT_MODELS[provider]; + } + + const ollamaUrl = s?.ollamaUrl; + return { model: buildModel(provider, modelId, ollamaUrl), provider, modelId }; } diff --git a/src/lib/ai/structured-analysis.ts b/src/lib/ai/structured-analysis.ts index 584ecd6..64f0cdf 100644 --- a/src/lib/ai/structured-analysis.ts +++ b/src/lib/ai/structured-analysis.ts @@ -2,7 +2,7 @@ // Returns structured data for frontend consumption with Quellenrang integration import { generateText } from 'ai'; -import { getModel, getProvider } from './providers'; +import { getModelForTenant } from './providers'; import { SYSTEM_PROMPTS, buildContextBlock, type AnalysisModeKey } from './prompts'; import { ANALYSIS_MODES } from './modes'; import { STRUCTURED_OUTPUT_INSTRUCTION, type StructuredAnalysisOutput } from './structured-output'; @@ -135,7 +135,7 @@ export async function runStructuredAnalysis( // Add structured output instruction to system prompt const systemPrompt = SYSTEM_PROMPTS[systemPromptKey] + STRUCTURED_OUTPUT_INSTRUCTION; - const model = getModel(); + const { model, provider, modelId } = await getModelForTenant(input.tenantId); // Create analysis record const [analysis] = await db @@ -148,8 +148,8 @@ export async function runStructuredAnalysis( status: 'in_progress', title: input.title, query: input.query, - aiProvider: getProvider(), - aiModel: process.env.AI_MODEL ?? 'default', + aiProvider: provider, + aiModel: modelId, sources: { normIds: normContext.map((n) => n.id), decisionIds: decisionContext.map((d) => d.id), diff --git a/src/lib/contracts/index.ts b/src/lib/contracts/index.ts index 4ef94a9..b17f339 100644 --- a/src/lib/contracts/index.ts +++ b/src/lib/contracts/index.ts @@ -11,7 +11,7 @@ import { } from '@/lib/db/schema'; import { eq, and, desc } from 'drizzle-orm'; import { generateText } from 'ai'; -import { getModel } from '@/lib/ai/providers'; +import { getModelForTenant } from '@/lib/ai/providers'; const ALLOWED_MIME_TYPES = new Set([ 'application/pdf', @@ -208,7 +208,7 @@ export async function analyzeContractClauses(tenantId: string, documentId: strin .join('\n\n') : 'Keine Standardklauseln verfügbar.'; - const model = getModel(); + const { model } = await getModelForTenant(tenantId); const { text: analysisResult } = await generateText({ model,