358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { Link } from "react-router-dom";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||
import {
|
||
aiSettingsApi,
|
||
type AIConfigListItem,
|
||
type AIConfigCreate,
|
||
type AIConfigUpdate,
|
||
} from "@/lib/api/client";
|
||
import { Loader2, Zap, Plus, Pencil, Trash2, CheckCircle } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
const PROVIDERS = ["OpenAI", "DeepSeek", "Custom"];
|
||
|
||
export default function SettingsAIPage() {
|
||
const [list, setList] = useState<AIConfigListItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [testing, setTesting] = useState<string | null>(null);
|
||
|
||
const [formName, setFormName] = useState("");
|
||
const [provider, setProvider] = useState("OpenAI");
|
||
const [apiKey, setApiKey] = useState("");
|
||
const [apiKeyConfigured, setApiKeyConfigured] = useState(false);
|
||
const [baseUrl, setBaseUrl] = useState("");
|
||
const [modelName, setModelName] = useState("gpt-4o-mini");
|
||
const [temperature, setTemperature] = useState("0.2");
|
||
const [systemPromptOverride, setSystemPromptOverride] = useState("");
|
||
|
||
const loadList = useCallback(async () => {
|
||
try {
|
||
const data = await aiSettingsApi.list();
|
||
setList(data);
|
||
} catch {
|
||
toast.error("加载模型列表失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadList();
|
||
}, [loadList]);
|
||
|
||
const openAdd = () => {
|
||
setEditingId(null);
|
||
setFormName("");
|
||
setProvider("OpenAI");
|
||
setApiKey("");
|
||
setApiKeyConfigured(false);
|
||
setBaseUrl("");
|
||
setModelName("gpt-4o-mini");
|
||
setTemperature("0.2");
|
||
setSystemPromptOverride("");
|
||
setDialogOpen(true);
|
||
};
|
||
|
||
const openEdit = async (id: string) => {
|
||
try {
|
||
const c = await aiSettingsApi.getById(id);
|
||
setEditingId(id);
|
||
setFormName(c.name || "");
|
||
setProvider(c.provider || "OpenAI");
|
||
setApiKey("");
|
||
setApiKeyConfigured(!!(c.api_key && c.api_key.length > 0));
|
||
setBaseUrl(c.base_url || "");
|
||
setModelName(c.model_name || "gpt-4o-mini");
|
||
setTemperature(String(c.temperature ?? 0.2));
|
||
setSystemPromptOverride(c.system_prompt_override || "");
|
||
setDialogOpen(true);
|
||
} catch {
|
||
toast.error("加载配置失败");
|
||
}
|
||
};
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
if (editingId) {
|
||
const payload: AIConfigUpdate = {
|
||
name: formName.trim() || undefined,
|
||
provider: provider || undefined,
|
||
base_url: baseUrl.trim() || undefined,
|
||
model_name: modelName.trim() || undefined,
|
||
temperature: parseFloat(temperature),
|
||
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||
};
|
||
if (apiKey.trim()) payload.api_key = apiKey.trim();
|
||
await aiSettingsApi.update(editingId, payload);
|
||
toast.success("已更新");
|
||
} else {
|
||
const payload: AIConfigCreate = {
|
||
name: formName.trim() || undefined,
|
||
provider: provider || undefined,
|
||
api_key: apiKey.trim() || undefined,
|
||
base_url: baseUrl.trim() || undefined,
|
||
model_name: modelName.trim() || undefined,
|
||
temperature: parseFloat(temperature),
|
||
system_prompt_override: systemPromptOverride.trim() || undefined,
|
||
};
|
||
await aiSettingsApi.create(payload);
|
||
toast.success("已添加");
|
||
}
|
||
setDialogOpen(false);
|
||
await loadList();
|
||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleActivate = async (id: string) => {
|
||
try {
|
||
await aiSettingsApi.activate(id);
|
||
toast.success("已选用该模型");
|
||
await loadList();
|
||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "选用失败");
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm("确定删除该模型配置?")) return;
|
||
try {
|
||
await aiSettingsApi.delete(id);
|
||
toast.success("已删除");
|
||
await loadList();
|
||
window.dispatchEvent(new CustomEvent("ai-settings-changed"));
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "删除失败");
|
||
}
|
||
};
|
||
|
||
const handleTest = async (id: string) => {
|
||
setTesting(id);
|
||
try {
|
||
const res = await aiSettingsApi.test(id);
|
||
toast.success(res.message ? `连接成功:${res.message}` : "连接成功");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "连接失败");
|
||
} finally {
|
||
setTesting(null);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="p-6 flex items-center gap-2 text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
加载中…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="p-6 max-w-4xl">
|
||
<div className="mb-4">
|
||
<Link to="/settings" className="text-sm text-muted-foreground hover:text-foreground">
|
||
← 设置
|
||
</Link>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<CardTitle>已配置的模型</CardTitle>
|
||
<p className="text-sm text-muted-foreground mt-1">
|
||
在此查看、选用或编辑多套 AI 模型配置;需求解析与测试将使用当前选用的配置。
|
||
</p>
|
||
</div>
|
||
<Button onClick={openAdd}>
|
||
<Plus className="h-4 w-4" />
|
||
<span className="ml-2">添加模型</span>
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{list.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground py-4">
|
||
暂无模型配置。点击「添加模型」填写 API Key、模型名称等,保存后即可在列表中选用。
|
||
</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>名称</TableHead>
|
||
<TableHead>提供商</TableHead>
|
||
<TableHead>模型</TableHead>
|
||
<TableHead>API Key</TableHead>
|
||
<TableHead className="w-[200px]">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{list.map((item) => (
|
||
<TableRow key={item.id}>
|
||
<TableCell className="font-medium">
|
||
{item.name || "未命名"}
|
||
{item.is_active && (
|
||
<span className="ml-2 text-xs text-primary inline-flex items-center gap-0.5">
|
||
<CheckCircle className="h-3.5 w-3.5" /> 当前选用
|
||
</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>{item.provider}</TableCell>
|
||
<TableCell>{item.model_name || "—"}</TableCell>
|
||
<TableCell>{item.api_key_configured ? "已配置" : "未配置"}</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1 flex-wrap">
|
||
{!item.is_active && (
|
||
<Button variant="outline" size="sm" onClick={() => void handleActivate(item.id)}>
|
||
选用
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" size="sm" onClick={() => void openEdit(item.id)} title="编辑">
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => void handleTest(item.id)}
|
||
disabled={testing === item.id}
|
||
title="测试连接"
|
||
>
|
||
{testing === item.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => void handleDelete(item.id)}
|
||
title="删除"
|
||
className="text-destructive"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{editingId ? "编辑模型配置" : "添加模型配置"}</DialogTitle>
|
||
</DialogHeader>
|
||
<form onSubmit={handleSave} className="space-y-4">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="ai-name">配置名称(便于区分)</Label>
|
||
<Input
|
||
id="ai-name"
|
||
value={formName}
|
||
onChange={(e) => setFormName(e.target.value)}
|
||
placeholder="如:OpenAI 生产、DeepSeek 备用"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="provider">提供商</Label>
|
||
<Select value={provider} onValueChange={setProvider}>
|
||
<SelectTrigger id="provider">
|
||
<SelectValue placeholder="选择提供商" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PROVIDERS.map((p) => (
|
||
<SelectItem key={p} value={p}>
|
||
{p}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="api_key">API Key</Label>
|
||
<Input
|
||
id="api_key"
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
placeholder={apiKeyConfigured ? "已配置,输入新值以修改" : "请输入 API Key"}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="base_url">Base URL(可选)</Label>
|
||
<Input
|
||
id="base_url"
|
||
type="url"
|
||
value={baseUrl}
|
||
onChange={(e) => setBaseUrl(e.target.value)}
|
||
placeholder="https://api.openai.com/v1"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="model_name">模型名称</Label>
|
||
<Input
|
||
id="model_name"
|
||
value={modelName}
|
||
onChange={(e) => setModelName(e.target.value)}
|
||
placeholder="gpt-4o-mini / deepseek-chat 等"
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="temperature">Temperature (0–2)</Label>
|
||
<Input
|
||
id="temperature"
|
||
type="number"
|
||
min={0}
|
||
max={2}
|
||
step={0.1}
|
||
value={temperature}
|
||
onChange={(e) => setTemperature(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="system_prompt_override">系统提示词覆盖(可选)</Label>
|
||
<textarea
|
||
id="system_prompt_override"
|
||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||
value={systemPromptOverride}
|
||
onChange={(e) => setSystemPromptOverride(e.target.value)}
|
||
placeholder="留空则使用默认"
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||
取消
|
||
</Button>
|
||
<Button type="submit" disabled={saving}>
|
||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||
{editingId ? "保存" : "添加"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|