Files
AiTool/frontend_spa/src/pages/settings/ai.tsx
2026-03-18 17:01:10 +08:00

358 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (02)</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>
);
}