feat: add Ollama/local LLM provider support
Add Ollama as a third AI provider option alongside Anthropic and OpenAI. Uses the OpenAI-compatible API endpoint that Ollama exposes, configured via OLLAMA_URL and OLLAMA_MODEL env vars. Provider selection is now tenant-aware via DB settings, with env var fallback. - New provider type 'ollama' in AIProvider union - Tenant-aware getModelForTenant() reads AI config from tenant settings jsonb - Admin settings UI on /einstellungen for provider/model selection - API route GET/PATCH /api/settings/ai for tenant AI config - Updated all AI call sites (analysis, structured-analysis, contracts) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
141
src/app/(dashboard)/einstellungen/ai-settings.tsx
Normal file
141
src/app/(dashboard)/einstellungen/ai-settings.tsx
Normal file
@@ -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<AISettings>({
|
||||
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 (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<p className="text-sm text-muted">Lade AI-Einstellungen...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">AI-Provider</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Provider</label>
|
||||
<select
|
||||
value={settings.provider}
|
||||
onChange={(e) => setSettings({ ...settings, provider: e.target.value })}
|
||||
className="w-full rounded-lg border border-card-border bg-background px-3 py-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
<option value="ollama">Ollama (Lokal)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{settings.provider !== 'ollama' && (
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Modell (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.model}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.provider === 'ollama' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Ollama URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ollamaUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-muted mb-1">Ollama Modell</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ollamaModel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
Ollama muss lokal oder auf dem Server laufen. Die OpenAI-kompatible API wird verwendet.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className={`mt-3 text-sm ${message.type === 'success' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
admin: 'Administrator',
|
||||
@@ -70,6 +71,8 @@ export default async function EinstellungenPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && <AISettingsForm />}
|
||||
|
||||
{isAdmin && tenantUsers.length > 0 && (
|
||||
<div className="bg-card-bg border border-card-border rounded-xl p-6">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Benutzer</h3>
|
||||
|
||||
66
src/app/api/settings/ai/route.ts
Normal file
66
src/app/api/settings/ai/route.ts
Normal file
@@ -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<AIProvider>(['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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<AIProvider, string> = {
|
||||
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<string, string> | 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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user