Files
rag_agent/rag-web-ui/frontend/src/app/dashboard/api-keys/page.tsx

573 lines
21 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, Edit, KeyRound, List, Loader2, Plus, Trash2 } from "lucide-react";
import DashboardLayout from "@/components/layout/dashboard-layout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/components/ui/use-toast";
import { api } from "@/lib/api";
interface ModelConfig {
id: number;
user_id: number;
name: string;
provider: string;
api_base: string | null;
chat_model: string;
embedding_model: string;
is_active: boolean;
has_api_key: boolean;
api_key_masked: string;
last_used_at: string | null;
created_at: string;
updated_at: string;
}
interface ProviderOption {
provider: string;
label: string;
default_api_base: string;
default_chat_model: string;
default_embedding_model: string;
chat_models: string[];
embedding_models: string[];
requires_api_key: boolean;
supports_custom_api_base: boolean;
}
interface ProviderOptionsResponse {
providers: ProviderOption[];
defaults: {
provider: string;
api_base: string;
chat_model: string;
embedding_model: string;
};
}
interface ModelConfigForm {
name: string;
provider: string;
api_key: string;
api_base: string;
chat_model: string;
embedding_model: string;
is_active: boolean;
}
const DEFAULT_PROVIDER_OPTIONS: ProviderOptionsResponse = {
providers: [
{
provider: "dashscope",
label: "DashScope",
default_api_base: "https://dashscope.aliyuncs.com/compatible-mode/v1",
default_chat_model: "qwen3-max",
default_embedding_model: "text-embedding-v4",
chat_models: ["qwen3-max", "qwen-plus", "qwen-turbo", "qwen-max"],
embedding_models: ["text-embedding-v4", "text-embedding-v3", "text-embedding-v2"],
requires_api_key: true,
supports_custom_api_base: true,
},
{
provider: "openai",
label: "OpenAI",
default_api_base: "https://api.openai.com/v1",
default_chat_model: "gpt-4o",
default_embedding_model: "text-embedding-3-small",
chat_models: ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini"],
embedding_models: ["text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002"],
requires_api_key: true,
supports_custom_api_base: true,
},
{
provider: "openai_compatible",
label: "OpenAI Compatible",
default_api_base: "",
default_chat_model: "qwen3-max",
default_embedding_model: "text-embedding-v4",
chat_models: ["qwen3-max", "deepseek-chat", "gpt-4o-mini"],
embedding_models: ["text-embedding-v4", "text-embedding-3-small"],
requires_api_key: true,
supports_custom_api_base: true,
},
{
provider: "ollama",
label: "Ollama",
default_api_base: "http://localhost:11434",
default_chat_model: "deepseek-r1:7b",
default_embedding_model: "nomic-embed-text",
chat_models: ["deepseek-r1:7b", "llama3.1", "qwen2.5"],
embedding_models: ["nomic-embed-text", "mxbai-embed-large"],
requires_api_key: false,
supports_custom_api_base: true,
},
],
defaults: {
provider: "dashscope",
api_base: "https://dashscope.aliyuncs.com/compatible-mode/v1",
chat_model: "qwen3-max",
embedding_model: "text-embedding-v4",
},
};
export default function APIKeysPage() {
const [configs, setConfigs] = useState<ModelConfig[]>([]);
const [options, setOptions] = useState<ProviderOptionsResponse>(DEFAULT_PROVIDER_OPTIONS);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isProviderDialogOpen, setIsProviderDialogOpen] = useState(false);
const [editingConfig, setEditingConfig] = useState<ModelConfig | null>(null);
const [form, setForm] = useState<ModelConfigForm>({
name: "",
provider: DEFAULT_PROVIDER_OPTIONS.defaults.provider,
api_key: "",
api_base: DEFAULT_PROVIDER_OPTIONS.defaults.api_base,
chat_model: DEFAULT_PROVIDER_OPTIONS.defaults.chat_model,
embedding_model: DEFAULT_PROVIDER_OPTIONS.defaults.embedding_model,
is_active: true,
});
const { toast } = useToast();
const selectedProvider = useMemo(
() => options.providers.find((item) => item.provider === form.provider),
[form.provider, options.providers]
);
const fetchPageData = async () => {
setIsLoading(true);
try {
const [providerData, configData] = await Promise.all([
api.get("/api/model-configs/providers"),
api.get("/api/model-configs"),
]);
setOptions({
providers: providerData.providers?.length ? providerData.providers : DEFAULT_PROVIDER_OPTIONS.providers,
defaults: providerData.defaults || DEFAULT_PROVIDER_OPTIONS.defaults,
});
setConfigs(configData);
if (!editingConfig) {
setForm((current) => ({
...current,
provider: providerData.defaults?.provider || DEFAULT_PROVIDER_OPTIONS.defaults.provider,
api_base: providerData.defaults?.api_base || DEFAULT_PROVIDER_OPTIONS.defaults.api_base,
chat_model: providerData.defaults?.chat_model || DEFAULT_PROVIDER_OPTIONS.defaults.chat_model,
embedding_model:
providerData.defaults?.embedding_model || DEFAULT_PROVIDER_OPTIONS.defaults.embedding_model,
}));
}
} catch (error) {
toast({
title: "错误",
description: "获取模型配置失败",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchPageData();
}, []);
const resetFormForCreate = () => {
const defaults = options.defaults || DEFAULT_PROVIDER_OPTIONS.defaults;
setEditingConfig(null);
setForm({
name: "",
provider: defaults.provider,
api_key: "",
api_base: defaults.api_base,
chat_model: defaults.chat_model,
embedding_model: defaults.embedding_model,
is_active: true,
});
setIsEditorOpen(true);
};
const openEditDialog = (config: ModelConfig) => {
setEditingConfig(config);
setForm({
name: config.name,
provider: config.provider,
api_key: "",
api_base: config.api_base || "",
chat_model: config.chat_model,
embedding_model: config.embedding_model,
is_active: config.is_active,
});
setIsEditorOpen(true);
};
const updateProvider = (provider: string) => {
const option = options.providers.find((item) => item.provider === provider);
setForm((current) => ({
...current,
provider,
api_base: option?.default_api_base || "",
chat_model: option?.default_chat_model || current.chat_model,
embedding_model: option?.default_embedding_model || current.embedding_model,
}));
};
const saveConfig = async () => {
if (!form.name.trim()) {
toast({ title: "错误", description: "请输入配置名称", variant: "destructive" });
return;
}
if (!editingConfig && selectedProvider?.requires_api_key && !form.api_key.trim()) {
toast({ title: "错误", description: "请输入 API 密钥", variant: "destructive" });
return;
}
setIsSaving(true);
try {
const payload: Record<string, unknown> = {
name: form.name.trim(),
provider: form.provider,
api_base: form.api_base.trim() || null,
chat_model: form.chat_model.trim(),
embedding_model: form.embedding_model.trim(),
is_active: form.is_active,
};
if (form.api_key.trim()) {
payload.api_key = form.api_key.trim();
}
if (editingConfig) {
const updated = await api.put(`/api/model-configs/${editingConfig.id}`, payload);
setConfigs((current) => current.map((item) => (item.id === updated.id ? updated : item)));
} else {
const created = await api.post("/api/model-configs", {
...payload,
api_key: form.api_key.trim(),
});
setConfigs((current) => [created, ...current]);
}
setIsEditorOpen(false);
toast({ title: "成功", description: "模型配置已保存" });
fetchPageData();
} catch (error: any) {
toast({
title: "错误",
description: error?.message || "保存模型配置失败",
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const toggleActive = async (config: ModelConfig) => {
try {
const updated = await api.put(`/api/model-configs/${config.id}`, {
is_active: !config.is_active,
});
setConfigs((current) =>
current.map((item) =>
item.id === updated.id
? updated
: updated.is_active
? { ...item, is_active: false }
: item
)
);
toast({ title: "成功", description: "启用状态已更新" });
} catch (error: any) {
toast({
title: "错误",
description: error?.message || "更新启用状态失败",
variant: "destructive",
});
}
};
const deleteConfig = async (id: number) => {
try {
await api.delete(`/api/model-configs/${id}`);
setConfigs((current) => current.filter((item) => item.id !== id));
toast({ title: "成功", description: "模型配置已删除" });
} catch (error: any) {
toast({
title: "错误",
description: error?.message || "删除模型配置失败",
variant: "destructive",
});
}
};
const providerLabel = (provider: string) =>
options.providers.find((item) => item.provider === provider)?.label || provider;
const formatDate = (value: string | null) =>
value ? new Date(value).toLocaleString("zh-CN") : "未使用";
return (
<DashboardLayout>
<div className="container mx-auto py-10">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">API </h1>
</div>
<div className="flex gap-3">
<Dialog open={isProviderDialogOpen} onOpenChange={setIsProviderDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<List className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
{options.providers.map((provider) => (
<div key={provider.provider} className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">{provider.label}</div>
<Badge variant={provider.requires_api_key ? "default" : "secondary"}>
{provider.requires_api_key ? "需要密钥" : "本地服务"}
</Badge>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2">
<div>
<div className="mb-1 text-slate-500"></div>
<div className="flex flex-wrap gap-2">
{provider.chat_models.map((model) => (
<Badge key={model} variant="outline">
{model}
</Badge>
))}
</div>
</div>
<div>
<div className="mb-1 text-slate-500">Embedding </div>
<div className="flex flex-wrap gap-2">
{provider.embedding_models.map((model) => (
<Badge key={model} variant="outline">
{model}
</Badge>
))}
</div>
</div>
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
<Dialog open={isEditorOpen} onOpenChange={setIsEditorOpen}>
<DialogTrigger asChild>
<Button onClick={resetFormForCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{editingConfig ? "编辑模型配置" : "创建模型配置"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="例如:我的 DashScope"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<select
value={form.provider}
onChange={(event) => updateProvider(event.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{options.providers.map((provider) => (
<option key={provider.provider} value={provider.provider}>
{provider.label}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="api-key">API </Label>
<Input
id="api-key"
type="password"
value={form.api_key}
onChange={(event) => setForm({ ...form, api_key: event.target.value })}
placeholder={editingConfig ? "留空则不修改" : "请输入 API 密钥"}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="api-base">API Base</Label>
<Input
id="api-base"
value={form.api_base}
onChange={(event) => setForm({ ...form, api_base: event.target.value })}
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="chat-model"></Label>
<Input
id="chat-model"
list="chat-model-options"
value={form.chat_model}
onChange={(event) => setForm({ ...form, chat_model: event.target.value })}
/>
<datalist id="chat-model-options">
{(selectedProvider?.chat_models || []).map((model) => (
<option key={model} value={model} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="embedding-model">Embedding </Label>
<Input
id="embedding-model"
list="embedding-model-options"
value={form.embedding_model}
onChange={(event) =>
setForm({ ...form, embedding_model: event.target.value })
}
/>
<datalist id="embedding-model-options">
{(selectedProvider?.embedding_models || []).map((model) => (
<option key={model} value={model} />
))}
</datalist>
</div>
</div>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<Label htmlFor="active"></Label>
<Switch
id="active"
checked={form.is_active}
onCheckedChange={(value) => setForm({ ...form, is_active: value })}
/>
</div>
</div>
<DialogFooter>
<Button onClick={saveConfig} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Embedding </TableHead>
<TableHead>API </TableHead>
<TableHead></TableHead>
<TableHead>使</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-slate-500">
...
</TableCell>
</TableRow>
) : configs.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-slate-500">
API
</TableCell>
</TableRow>
) : (
configs.map((config) => (
<TableRow key={config.id}>
<TableCell className="font-medium">{config.name}</TableCell>
<TableCell>{providerLabel(config.provider)}</TableCell>
<TableCell className="font-mono text-sm">{config.chat_model}</TableCell>
<TableCell className="font-mono text-sm">{config.embedding_model}</TableCell>
<TableCell>
<div className="flex items-center gap-2 font-mono text-sm">
<KeyRound className="h-4 w-4 text-slate-400" />
{config.api_key_masked || "本地服务"}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={config.is_active}
onCheckedChange={() => toggleActive(config)}
/>
{config.is_active && (
<Badge className="gap-1">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)}
</div>
</TableCell>
<TableCell>{formatDate(config.last_used_at)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEditDialog(config)}>
<Edit className="mr-1 h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => deleteConfig(config.id)}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</DashboardLayout>
);
}