All checks were successful
Test / rust-clippy (pull_request) Successful in 20m48s
Test / frontend-tests (pull_request) Successful in 2m10s
Test / frontend-typecheck (pull_request) Successful in 2m12s
Test / rust-fmt-check (pull_request) Successful in 4m40s
Test / rust-tests (pull_request) Successful in 22m14s
The normalizeApiFormat helper (which mapped the legacy format identifier to custom_rest) was removed but still referenced in 4 call sites. Replace each call with the underlying value directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Plus, Pencil, Trash2, CheckCircle, XCircle, Zap } from "lucide-react";
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
Button,
|
|
Input,
|
|
Label,
|
|
Badge,
|
|
Select,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SelectContent,
|
|
SelectItem,
|
|
Separator,
|
|
} from "@/components/ui";
|
|
import { useSettingsStore } from "@/stores/settingsStore";
|
|
import {
|
|
testProviderConnectionCmd,
|
|
saveAiProviderCmd,
|
|
loadAiProvidersCmd,
|
|
deleteAiProviderCmd,
|
|
type ProviderConfig,
|
|
} from "@/lib/tauriCommands";
|
|
|
|
export const CUSTOM_REST_MODELS = [
|
|
"ChatGPT4o",
|
|
"ChatGPT4o-mini",
|
|
"ChatGPT-o3-mini",
|
|
"Gemini-2_0-Flash-001",
|
|
"Gemini-2_5-Flash",
|
|
"Claude-Sonnet-3_7",
|
|
"Openai-gpt-4_1-mini",
|
|
"Openai-o4-mini",
|
|
"Claude-Sonnet-4",
|
|
"ChatGPT-o3-pro",
|
|
"OpenAI-ChatGPT-4_1",
|
|
"OpenAI-GPT-4_1-Nano",
|
|
"ChatGPT-5",
|
|
"VertexGemini",
|
|
"ChatGPT-5_1",
|
|
"ChatGPT-5_1-chat",
|
|
"ChatGPT-5_2-Chat",
|
|
"Gemini-3_Pro-Preview",
|
|
"Gemini-3_1-flash-lite-preview",
|
|
] as const;
|
|
|
|
export const CUSTOM_MODEL_OPTION = "__custom_model__";
|
|
export const CUSTOM_REST_FORMAT = "custom_rest";
|
|
|
|
const emptyProvider: ProviderConfig = {
|
|
name: "",
|
|
provider_type: "openai",
|
|
api_url: "",
|
|
api_key: "",
|
|
model: "",
|
|
max_tokens: 4096,
|
|
temperature: 0.7,
|
|
custom_endpoint_path: undefined,
|
|
custom_auth_header: undefined,
|
|
custom_auth_prefix: undefined,
|
|
api_format: undefined,
|
|
session_id: undefined,
|
|
user_id: undefined,
|
|
};
|
|
|
|
export default function AIProviders() {
|
|
const {
|
|
ai_providers,
|
|
active_provider,
|
|
addProvider,
|
|
updateProvider,
|
|
removeProvider,
|
|
setActiveProvider,
|
|
setProviders,
|
|
} = useSettingsStore();
|
|
|
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [form, setForm] = useState<ProviderConfig>({ ...emptyProvider });
|
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
|
const [isTesting, setIsTesting] = useState(false);
|
|
const [isCustomModel, setIsCustomModel] = useState(false);
|
|
const [customModelInput, setCustomModelInput] = useState("");
|
|
|
|
// Load providers from database on mount
|
|
// Note: Auto-testing of active provider is handled in App.tsx on startup
|
|
useEffect(() => {
|
|
const loadProviders = async () => {
|
|
try {
|
|
const providers = await loadAiProvidersCmd();
|
|
setProviders(providers);
|
|
} catch (err) {
|
|
console.error("Failed to load AI providers:", err);
|
|
}
|
|
};
|
|
loadProviders();
|
|
}, [setProviders]);
|
|
|
|
const startAdd = () => {
|
|
setForm({ ...emptyProvider });
|
|
setEditIndex(null);
|
|
setIsAdding(true);
|
|
setTestResult(null);
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
};
|
|
|
|
const startEdit = (index: number) => {
|
|
const provider = ai_providers[index];
|
|
const apiFormat = provider.api_format;
|
|
const nextForm = { ...provider, api_format: apiFormat };
|
|
|
|
setForm(nextForm);
|
|
setEditIndex(index);
|
|
setIsAdding(true);
|
|
setTestResult(null);
|
|
|
|
const isCustomRestProvider =
|
|
nextForm.provider_type === "custom" && apiFormat === CUSTOM_REST_FORMAT;
|
|
const knownModel = CUSTOM_REST_MODELS.includes(nextForm.model as (typeof CUSTOM_REST_MODELS)[number]);
|
|
|
|
if (isCustomRestProvider && !knownModel) {
|
|
setIsCustomModel(true);
|
|
setCustomModelInput(nextForm.model);
|
|
} else {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name || !form.api_url || !form.model) return;
|
|
|
|
try {
|
|
// Save to database
|
|
await saveAiProviderCmd(form);
|
|
|
|
// Update local state
|
|
if (editIndex != null) {
|
|
updateProvider(editIndex, form);
|
|
} else {
|
|
addProvider(form);
|
|
}
|
|
|
|
setIsAdding(false);
|
|
setEditIndex(null);
|
|
setForm({ ...emptyProvider });
|
|
} catch (err) {
|
|
console.error("Failed to save provider:", err);
|
|
setTestResult({ success: false, message: `Failed to save: ${err}` });
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setIsAdding(false);
|
|
setEditIndex(null);
|
|
setForm({ ...emptyProvider });
|
|
setTestResult(null);
|
|
};
|
|
|
|
const handleRemove = async (index: number) => {
|
|
const provider = ai_providers[index];
|
|
try {
|
|
await deleteAiProviderCmd(provider.name);
|
|
removeProvider(index);
|
|
} catch (err) {
|
|
console.error("Failed to delete provider:", err);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
setIsTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const response = await testProviderConnectionCmd(form);
|
|
setTestResult({ success: true, message: `OK: ${response.content.slice(0, 100)}` });
|
|
} catch (err) {
|
|
setTestResult({ success: false, message: String(err) });
|
|
} finally {
|
|
setIsTesting(false);
|
|
}
|
|
};
|
|
|
|
const maskApiKey = (key?: string) => {
|
|
if (!key) return "Not set";
|
|
if (key.length <= 8) return "****";
|
|
return key.slice(0, 4) + "..." + key.slice(-4);
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">AI Providers</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Configure AI model providers for triage and document generation.
|
|
</p>
|
|
</div>
|
|
{!isAdding && (
|
|
<Button onClick={startAdd}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Provider
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider list */}
|
|
{ai_providers.length === 0 && !isAdding && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center">
|
|
<p className="text-muted-foreground">No providers configured yet.</p>
|
|
<Button className="mt-3" onClick={startAdd}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add your first provider
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{ai_providers.map((provider, idx) => (
|
|
<Card key={`${provider.name}-${idx}`}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{provider.name}</span>
|
|
<Badge variant="secondary">{provider.provider_type}</Badge>
|
|
{active_provider === provider.name && (
|
|
<Badge variant="success">Active</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{provider.api_url} | Model: {provider.model} | Key: {maskApiKey(provider.api_key)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{active_provider !== provider.name && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setActiveProvider(provider.name)}
|
|
>
|
|
<Zap className="w-3 h-3 mr-1" />
|
|
Set Active
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => startEdit(idx)}>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(idx)}
|
|
>
|
|
<Trash2 className="w-3 h-3 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
|
|
{/* Add/Edit form */}
|
|
{isAdding && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">
|
|
{editIndex != null ? "Edit Provider" : "Add Provider"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={form.name}
|
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
placeholder="My OpenAI"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Type</Label>
|
|
<Select
|
|
value={form.provider_type}
|
|
onValueChange={(v) => {
|
|
const type = v as ProviderConfig["provider_type"];
|
|
const defaults: Partial<ProviderConfig> =
|
|
type === "ollama"
|
|
? { api_url: "http://localhost:11434", api_key: "", model: "llama3.2:3b" }
|
|
: type === "openai"
|
|
? { api_url: "https://api.openai.com/v1" }
|
|
: type === "anthropic"
|
|
? { api_url: "https://api.anthropic.com" }
|
|
: {};
|
|
setForm({ ...form, provider_type: type, ...defaults });
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="openai">OpenAI</SelectItem>
|
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
|
<SelectItem value="ollama">Ollama</SelectItem>
|
|
<SelectItem value="azure">Azure OpenAI</SelectItem>
|
|
<SelectItem value="custom">Custom</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>API URL</Label>
|
|
<Input
|
|
value={form.api_url}
|
|
onChange={(e) => setForm({ ...form, api_url: e.target.value })}
|
|
placeholder="https://api.openai.com/v1"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>API Key</Label>
|
|
<Input
|
|
type="password"
|
|
value={form.api_key ?? ""}
|
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
|
placeholder="sk-..."
|
|
/>
|
|
</div>
|
|
{!(form.provider_type === "custom" && form.api_format === CUSTOM_REST_FORMAT) && (
|
|
<div className="space-y-2">
|
|
<Label>Model</Label>
|
|
<Input
|
|
value={form.model}
|
|
onChange={(e) => setForm({ ...form, model: e.target.value })}
|
|
placeholder="gpt-4o"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Max Tokens</Label>
|
|
<Input
|
|
type="number"
|
|
value={form.max_tokens ?? 4096}
|
|
onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Temperature</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="2"
|
|
value={form.temperature ?? 0.7}
|
|
onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom provider format options */}
|
|
{form.provider_type === "custom" && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>API Format</Label>
|
|
<Select
|
|
value={form.api_format ?? "openai"}
|
|
onValueChange={(v) => {
|
|
const format = v;
|
|
const defaults =
|
|
format === CUSTOM_REST_FORMAT
|
|
? {
|
|
custom_endpoint_path: "",
|
|
custom_auth_header: "",
|
|
custom_auth_prefix: "",
|
|
}
|
|
: {
|
|
custom_endpoint_path: "/chat/completions",
|
|
custom_auth_header: "Authorization",
|
|
custom_auth_prefix: "Bearer ",
|
|
};
|
|
setForm({ ...form, api_format: format, ...defaults });
|
|
if (format !== CUSTOM_REST_FORMAT) {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="openai">OpenAI Compatible</SelectItem>
|
|
<SelectItem value={CUSTOM_REST_FORMAT}>Custom REST</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Select the API format. Custom REST uses a non-OpenAI request/response structure.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Endpoint Path</Label>
|
|
<Input
|
|
value={form.custom_endpoint_path ?? ""}
|
|
onChange={(e) =>
|
|
setForm({ ...form, custom_endpoint_path: e.target.value })
|
|
}
|
|
placeholder="/chat/completions"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Path appended to API URL. Leave empty if URL includes full path.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Auth Header Name</Label>
|
|
<Input
|
|
value={form.custom_auth_header ?? ""}
|
|
onChange={(e) =>
|
|
setForm({ ...form, custom_auth_header: e.target.value })
|
|
}
|
|
placeholder="Authorization"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Header name for authentication (e.g., "Authorization" or "x-api-key")
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Auth Prefix</Label>
|
|
<Input
|
|
value={form.custom_auth_prefix ?? ""}
|
|
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
|
|
placeholder="Bearer "
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for Custom REST)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Custom REST specific: User ID field */}
|
|
{form.api_format === CUSTOM_REST_FORMAT && (
|
|
<div className="space-y-2">
|
|
<Label>Email Address</Label>
|
|
<Input
|
|
value={form.user_id ?? ""}
|
|
onChange={(e) => setForm({ ...form, user_id: e.target.value })}
|
|
placeholder="user@example.com"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Optional: Email address for usage tracking. If omitted, costs are attributed to the API key owner.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom REST specific: model dropdown with custom option */}
|
|
{form.api_format === CUSTOM_REST_FORMAT && (
|
|
<div className="space-y-2">
|
|
<Label>Model</Label>
|
|
<Select
|
|
value={isCustomModel ? CUSTOM_MODEL_OPTION : form.model}
|
|
onValueChange={(value) => {
|
|
if (value === CUSTOM_MODEL_OPTION) {
|
|
setIsCustomModel(true);
|
|
if (CUSTOM_REST_MODELS.includes(form.model as (typeof CUSTOM_REST_MODELS)[number])) {
|
|
setForm({ ...form, model: "" });
|
|
setCustomModelInput("");
|
|
}
|
|
} else {
|
|
setIsCustomModel(false);
|
|
setCustomModelInput("");
|
|
setForm({ ...form, model: value });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a model..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CUSTOM_REST_MODELS.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value={CUSTOM_MODEL_OPTION}>Custom model...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{isCustomModel && (
|
|
<Input
|
|
value={customModelInput}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setCustomModelInput(value);
|
|
setForm({ ...form, model: value });
|
|
}}
|
|
placeholder="Enter custom model ID"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Test result */}
|
|
{testResult && (
|
|
<div
|
|
className={`flex items-center gap-2 rounded-md p-3 text-sm ${
|
|
testResult.success
|
|
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300"
|
|
: "bg-destructive/10 text-destructive"
|
|
}`}
|
|
>
|
|
{testResult.success ? (
|
|
<CheckCircle className="w-4 h-4" />
|
|
) : (
|
|
<XCircle className="w-4 h-4" />
|
|
)}
|
|
{testResult.message}
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={handleSave}>Save</Button>
|
|
<Button variant="outline" onClick={handleTest} disabled={isTesting}>
|
|
{isTesting ? "Testing..." : "Test Connection"}
|
|
</Button>
|
|
<Button variant="ghost" onClick={handleCancel}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|