fix:优化项目内容
This commit is contained in:
357
frontend_spa/src/pages/settings/ai.tsx
Normal file
357
frontend_spa/src/pages/settings/ai.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user