add model config and update settings

This commit is contained in:
qixinbo
2026-03-14 19:56:34 +08:00
parent 98d99ded37
commit 6089192575
9 changed files with 642 additions and 202 deletions
+67 -9
View File
@@ -1,17 +1,31 @@
import json
import os
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException, Body
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
from pydantic import BaseModel, Field
from app.core.security import SECRET_KEY, ALGORITHM
router = APIRouter()
security = HTTPBearer()
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "llm_config.json")
class CurrentUser(BaseModel):
id: int
username: str
is_admin: bool = False
class LLMConfig(BaseModel):
id: str = Field(..., description="Unique identifier for the LLM configuration")
name: Optional[str] = Field(None, description="Display name")
provider: str = Field(..., description="Provider name (e.g., openai, azure, anthropic)")
model: str = Field(..., description="Model name (e.g., gpt-4, claude-3-opus)")
model_type: Optional[str] = Field(None, description="Model type")
base_model: Optional[str] = Field(None, description="Base model")
protocol_type: Optional[str] = Field(None, description="Protocol type")
api_key: Optional[str] = Field(None, description="API Key for the provider")
api_base: Optional[str] = Field(None, description="Base URL for the API")
extra_headers: Optional[Dict[str, str]] = Field(None, description="Extra headers for the request")
@@ -19,21 +33,52 @@ class LLMConfig(BaseModel):
class LLMConfigCreate(BaseModel):
id: str
name: Optional[str] = None
provider: str
model: str
model_type: Optional[str] = None
base_model: Optional[str] = None
protocol_type: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
extra_headers: Optional[Dict[str, str]] = None
is_active: bool = True
class LLMConfigUpdate(BaseModel):
name: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
model_type: Optional[str] = None
base_model: Optional[str] = None
protocol_type: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
extra_headers: Optional[Dict[str, str]] = None
is_active: Optional[bool] = None
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise unauthorized
user_id = payload.get("id")
username = payload.get("sub")
is_admin = bool(payload.get("is_admin", False))
if user_id is None or username is None:
raise unauthorized
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
def _load_data() -> List[Dict[str, Any]]:
if not os.path.exists(DATA_FILE):
return []
@@ -48,37 +93,50 @@ def _save_data(data: List[Dict[str, Any]]):
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
def _sanitize_config(item: Dict[str, Any], is_admin: bool) -> Dict[str, Any]:
config = item.copy()
if not is_admin:
config["api_key"] = None
return config
@router.get("/llm", response_model=List[LLMConfig])
def list_llm_configs():
def list_llm_configs(current_user: CurrentUser = Depends(get_current_user)):
data = _load_data()
return [LLMConfig(**item) for item in data]
return [LLMConfig(**_sanitize_config(item, current_user.is_admin)) for item in data]
@router.get("/llm/{config_id}", response_model=LLMConfig)
def get_llm_config(config_id: str):
def get_llm_config(config_id: str, current_user: CurrentUser = Depends(get_current_user)):
data = _load_data()
for item in data:
if item["id"] == config_id:
return LLMConfig(**item)
return LLMConfig(**_sanitize_config(item, current_user.is_admin))
raise HTTPException(status_code=404, detail="LLM configuration not found")
@router.post("/llm", response_model=LLMConfig)
def create_llm_config(config: LLMConfigCreate):
def create_llm_config(config: LLMConfigCreate, _: CurrentUser = Depends(get_admin_user)):
data = _load_data()
if any(item["id"] == config.id for item in data):
raise HTTPException(status_code=400, detail="LLM configuration with this ID already exists")
new_config = config.dict()
if new_config.get("is_active"):
for item in data:
item["is_active"] = False
data.append(new_config)
_save_data(data)
return LLMConfig(**new_config)
@router.put("/llm/{config_id}", response_model=LLMConfig)
def update_llm_config(config_id: str, config: LLMConfigUpdate):
def update_llm_config(config_id: str, config: LLMConfigUpdate, _: CurrentUser = Depends(get_admin_user)):
data = _load_data()
for i, item in enumerate(data):
if item["id"] == config_id:
updated_item = item.copy()
update_data = config.dict(exclude_unset=True)
if update_data.get("is_active"):
for j in range(len(data)):
data[j]["is_active"] = False
updated_item.update(update_data)
data[i] = updated_item
_save_data(data)
@@ -86,7 +144,7 @@ def update_llm_config(config_id: str, config: LLMConfigUpdate):
raise HTTPException(status_code=404, detail="LLM configuration not found")
@router.delete("/llm/{config_id}")
def delete_llm_config(config_id: str):
def delete_llm_config(config_id: str, _: CurrentUser = Depends(get_admin_user)):
data = _load_data()
initial_len = len(data)
data = [item for item in data if item["id"] != config_id]
+20
View File
@@ -0,0 +1,20 @@
[
{
"id": "m1773487590",
"provider": "zhipuai",
"model": "glm-4-7-251222",
"api_key": "secret",
"api_base": "https://ark.cn-beijing.volces.com/api/v3",
"extra_headers": null,
"is_active": true
},
{
"id": "deny1",
"provider": "openai",
"model": "gpt-4o",
"api_key": null,
"api_base": "https://api.openai.com/v1",
"extra_headers": null,
"is_active": false
}
]
Binary file not shown.
+9
View File
@@ -6,6 +6,7 @@ import { Skills } from "./pages/Skills";
import { Settings } from "./pages/Settings";
import { Users } from "./pages/Users";
import { Login } from "./pages/Login";
import { ModelConfigs } from "./pages/ModelConfigs";
import { useAuthStore } from "./store/authStore";
// Protected Route Component
@@ -82,6 +83,14 @@ function App() {
</MainLayout>
</ProtectedRoute>
} />
<Route path="/model-configs" element={
<ProtectedRoute>
<MainLayout>
<ModelConfigs />
</MainLayout>
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
);
+27 -14
View File
@@ -1,7 +1,7 @@
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings } from "lucide-react";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
@@ -138,20 +138,33 @@ function SidebarBody() {
}}
>
<Settings className="h-4 w-4 text-zinc-500" />
Settings
</button>
{user?.is_admin && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
onClick={() => {
navigate("/users");
setShowUserMenu(false);
}}
>
<User className="h-4 w-4" />
User Management
</button>
<>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
onClick={() => {
navigate("/model-configs");
setShowUserMenu(false);
}}
>
<Brain className="h-4 w-4 text-zinc-500" />
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
onClick={() => {
navigate("/users");
setShowUserMenu(false);
}}
>
<User className="h-4 w-4" />
</button>
</>
)}
<div className="h-px bg-zinc-100 my-1 mx-2" />
@@ -160,7 +173,7 @@ function SidebarBody() {
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
onClick={handleLogout}
>
Log out
退
</button>
</div>
)}
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { Loader2, Plus, RefreshCw, Search, Trash2, Pencil, Eye, EyeOff, Brain } from "lucide-react";
import { useAuthStore } from "@/store/authStore";
interface ModelConfig {
id: string;
name?: string;
provider: string;
model: string;
model_type?: string;
base_model?: string;
protocol_type?: string;
api_key?: string;
api_base?: string;
extra_headers?: Record<string, string>;
is_active: boolean;
}
const defaultForm: Omit<ModelConfig, "id"> = {
name: "",
provider: "openai",
model: "",
model_type: "LLM",
base_model: "",
protocol_type: "OpenAI",
api_key: "",
api_base: "",
extra_headers: {},
is_active: true,
};
export function ModelConfigs() {
const { user } = useAuthStore();
const isAdmin = !!user?.is_admin;
const [configs, setConfigs] = useState<ModelConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState("");
const [extraConfigText, setExtraConfigText] = useState("{}");
const [form, setForm] = useState<Omit<ModelConfig, "id">>(defaultForm);
const fetchConfigs = async () => {
setIsLoading(true);
try {
const data = await api.get<ModelConfig[]>("/api/v1/llm");
setConfigs(data);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchConfigs();
}, []);
const filteredConfigs = useMemo(() => {
const value = keyword.trim().toLowerCase();
if (!value) return configs;
return configs.filter((item) =>
[item.name, item.model, item.provider, item.base_model].filter(Boolean).some((v) => String(v).toLowerCase().includes(value))
);
}, [configs, keyword]);
const openCreate = () => {
setEditingId(null);
setForm(defaultForm);
setExtraConfigText("{}");
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const openEdit = (item: ModelConfig) => {
setEditingId(item.id);
setForm({
name: item.name || "",
provider: item.provider || "openai",
model: item.model || "",
model_type: item.model_type || "LLM",
base_model: item.base_model || "",
protocol_type: item.protocol_type || "OpenAI",
api_key: item.api_key || "",
api_base: item.api_base || "",
extra_headers: item.extra_headers || {},
is_active: item.is_active,
});
setExtraConfigText(JSON.stringify(item.extra_headers || {}, null, 2));
setError("");
setShowApiKey(false);
setDialogOpen(true);
};
const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!form.model || !form.provider || !form.api_base) {
setError("请填写必填项");
return;
}
setIsSaving(true);
setError("");
try {
let extraHeaders: Record<string, string> = {};
if (extraConfigText.trim()) {
try {
const parsed = JSON.parse(extraConfigText);
if (parsed && typeof parsed === "object") extraHeaders = parsed;
} catch (err) {
setError("额外配置必须是有效的JSON");
setIsSaving(false);
return;
}
}
const payload = {
...form,
extra_headers: extraHeaders,
name: form.name || form.model,
model_type: form.model_type || "大语言模型",
base_model: form.base_model || form.model,
protocol_type: form.protocol_type || "OpenAI",
};
if (editingId) {
await api.put(`/api/v1/llm/${editingId}`, payload);
} else {
const id = `${Date.now()}`;
await api.post("/api/v1/llm", { ...payload, id });
}
setDialogOpen(false);
await fetchConfigs();
} catch (e: any) {
setError(e.message || "保存配置失败");
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm("确认删除该模型吗?")) return;
try {
await api.delete(`/api/v1/llm/${id}`);
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
const handleSetDefault = async (item: ModelConfig) => {
if (!isAdmin || item.is_active) return;
try {
await api.put(`/api/v1/llm/${item.id}`, { is_active: true });
await fetchConfigs();
} catch (e) {
console.error(e);
}
};
if (!isAdmin) {
return (
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden items-center justify-center">
<div className="text-zinc-500 text-lg">访使</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
<div className="flex items-center gap-2 text-zinc-700 font-medium">
<Brain className="h-5 w-5 text-indigo-500" />
</div>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索模型..." className="w-[200px] pl-9 h-8 text-sm" />
</div>
<Button variant="outline" size="icon" className="h-8 w-8 text-zinc-500" onClick={fetchConfigs}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button className="h-8 px-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="bg-white rounded-xl border border-zinc-200 shadow-sm overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="h-6 w-6 animate-spin text-zinc-400" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredConfigs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center h-24 text-zinc-500">
</TableCell>
</TableRow>
) : (
filteredConfigs.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.name || item.model}
</TableCell>
<TableCell className="capitalize">{item.provider}</TableCell>
<TableCell className="text-zinc-500 font-mono text-xs">{item.model}</TableCell>
<TableCell className="text-zinc-500">{item.model_type || "大语言模型"}</TableCell>
<TableCell>
<span
onClick={() => handleSetDefault(item)}
className={`inline-flex px-2 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors ${item.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200'}`}
title={item.is_active ? "当前默认模型" : "点击设为默认"}
>
{item.is_active ? '默认' : '设为默认'}
</span>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-500 hover:text-indigo-600"
onClick={() => openEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-zinc-500 hover:text-red-600"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<form onSubmit={handleSave}>
<DialogHeader>
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-2">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="如:GPT-4" />
</div>
<div className="space-y-2">
<Label> *</Label>
<Select value={form.provider} onValueChange={(v) => setForm((p) => ({ ...p, provider: v || "openai" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="zhipuai">ZhipuAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="local">Local</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Input value={form.model || ""} onChange={(e) => setForm((p) => ({ ...p, model: e.target.value }))} placeholder="如:gpt-4-turbo" required />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={form.base_model || ""} onChange={(e) => setForm((p) => ({ ...p, base_model: e.target.value }))} placeholder="可选" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Select value={form.model_type || "大语言模型"} onValueChange={(v) => setForm((p) => ({ ...p, model_type: v || "大语言模型" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="大语言模型"></SelectItem>
<SelectItem value="多模态模型"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={form.protocol_type || "OpenAI"} onValueChange={(v) => setForm((p) => ({ ...p, protocol_type: v || "OpenAI" }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="OpenAI">OpenAI</SelectItem>
<SelectItem value="Anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>API *</Label>
<Input value={form.api_base || ""} onChange={(e) => setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="如:https://api.openai.com/v1" required />
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showApiKey ? "text" : "password"}
value={form.api_key || ""}
onChange={(e) => setForm((p) => ({ ...p, api_key: e.target.value }))}
className="pr-10"
placeholder="不修改请留空"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
onClick={() => setShowApiKey((v) => !v)}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label> (JSON)</Label>
<Textarea value={extraConfigText} onChange={(e) => setExtraConfigText(e.target.value)} className="min-h-[80px] font-mono text-xs" placeholder='{"timeout": "60"}' />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button type="submit" disabled={isSaving} className="bg-indigo-600 hover:bg-indigo-700 text-white">
{isSaving ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
+113 -157
View File
@@ -3,188 +3,144 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Save, Loader2 } from "lucide-react";
import { api } from "@/lib/api";
interface LLMConfig {
id: string;
provider: string;
model: string;
api_key?: string;
api_base?: string;
is_active: boolean;
}
import { useAuthStore } from "@/store/authStore";
export function Settings() {
const [configId, setConfigId] = useState<string | null>(null);
const [apiKey, setApiKey] = useState('');
const [provider, setProvider] = useState('openai');
const [model, setModel] = useState('gpt-4-turbo');
const [baseUrl, setBaseUrl] = useState('https://api.openai.com/v1');
const [enableVoice, setEnableVoice] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { user, updateUser } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
setIsLoading(true);
try {
const configs = await api.get<LLMConfig[]>('/api/v1/llm');
const activeConfig = configs.find(c => c.is_active) || configs[0];
if (activeConfig) {
setConfigId(activeConfig.id);
setProvider(activeConfig.provider);
setModel(activeConfig.model);
setApiKey(activeConfig.api_key || '');
setBaseUrl(activeConfig.api_base || '');
}
} catch (error) {
console.error("Failed to fetch LLM config", error);
} finally {
setIsLoading(false);
if (user) {
setEmail(user.email || '');
}
};
}, [user]);
const isPasswordMismatch = password !== '' && confirmPassword !== '' && password !== confirmPassword;
const handleSave = async () => {
setError('');
setSuccess('');
if (isPasswordMismatch) {
setError("两次输入的密码不一致");
return;
}
setIsSaving(true);
try {
const configData = {
provider,
model,
api_key: apiKey,
api_base: baseUrl,
is_active: true
const updateData: any = {
email: email
};
if (configId) {
await api.put(`/api/v1/llm/${configId}`, configData);
} else {
const newId = Date.now().toString();
await api.post('/api/v1/llm', { ...configData, id: newId });
setConfigId(newId);
if (password) {
updateData.password = password;
}
alert("Settings saved successfully!");
} catch (error) {
if (user && user.id) {
const response = await api.put<any>(`/api/v1/users/${user.id}`, updateData);
let successMsg = "个人设置保存成功!";
if (password) {
successMsg = "个人设置及密码修改成功!";
}
setSuccess(successMsg);
setPassword('');
setConfirmPassword('');
// Update global state with new email
updateUser({ email: response.email });
}
} catch (error: any) {
console.error("Failed to save settings", error);
alert("Failed to save settings");
setError(error.message || "保存设置失败");
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="p-6 h-full overflow-y-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-muted-foreground">Configure AI model and application preferences</p>
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
<div className="flex items-center gap-2 text-zinc-700 font-medium">
<Save className="h-5 w-5 text-indigo-500" />
</div>
</div>
<div className="grid gap-6 max-w-2xl">
<Card>
<CardHeader>
<CardTitle>LLM Configuration</CardTitle>
<CardDescription>Manage your Large Language Model settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Select value={provider} onValueChange={(val) => val && setProvider(val)}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="local">Local (Ollama/LM Studio)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Select value={model} onValueChange={(val) => val && setModel(val)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-4-turbo">GPT-4 Turbo</SelectItem>
<SelectItem value="gpt-4o">GPT-4o</SelectItem>
<SelectItem value="gpt-3.5-turbo">GPT-3.5 Turbo</SelectItem>
<SelectItem value="claude-3-opus">Claude 3 Opus</SelectItem>
<SelectItem value="claude-3-sonnet">Claude 3 Sonnet</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="base-url">Base URL (Optional)</Label>
<Input
id="base-url"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Interface Settings</CardTitle>
<CardDescription>Customize your experience</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="voice-mode">Voice Mode</Label>
<p className="text-sm text-muted-foreground">Enable voice input and output</p>
<div className="flex-1 p-6 overflow-auto">
<div className="grid gap-6 max-w-2xl mx-auto">
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-md p-3">{error}</div>}
{success && <div className="text-sm text-emerald-600 bg-emerald-50 border border-emerald-100 rounded-md p-3">{success}</div>}
<Card className="border-zinc-200 shadow-sm">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={user?.username || ''}
disabled
className="bg-zinc-50 text-zinc-500"
/>
<p className="text-xs text-zinc-400"></p>
</div>
<Switch
id="voice-mode"
checked={enableVoice}
onCheckedChange={setEnableVoice}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="dark-mode">Dark Mode</Label>
<p className="text-sm text-muted-foreground">Toggle dark/light theme</p>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Switch id="dark-mode" defaultChecked />
</div>
</CardContent>
<CardFooter>
<Button onClick={handleSave} className="ml-auto" disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</CardFooter>
</Card>
<div className="space-y-2 pt-4 border-t border-zinc-100">
<Label htmlFor="new-password"></Label>
<Input
id="new-password"
type="password"
placeholder="如不修改请留空"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password"></Label>
<Input
id="confirm-password"
type="password"
placeholder="如不修改请留空"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setError('');
}}
/>
{isPasswordMismatch && <p className="text-sm text-red-600"></p>}
</div>
</CardContent>
<CardFooter className="bg-zinc-50/50 border-t border-zinc-100 pt-6">
<Button onClick={handleSave} className="ml-auto bg-indigo-600 hover:bg-indigo-700 text-white" disabled={isSaving || isPasswordMismatch}>
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
+22 -22
View File
@@ -87,7 +87,7 @@ export function Users() {
} else {
// Create
if (!formData.password) {
setError("Password is required for new users");
setError("新建用户必须填写密码");
return;
}
await api.post("/api/v1/users", formData);
@@ -95,12 +95,12 @@ export function Users() {
setIsDialogOpen(false);
fetchUsers();
} catch (err: any) {
setError(err.message || "An error occurred");
setError(err.message || "发生错误");
}
};
const handleDelete = async (id: number) => {
if (window.confirm("Are you sure you want to delete this user?")) {
if (window.confirm("确认删除该用户吗?")) {
try {
await api.delete(`/api/v1/users/${id}`);
fetchUsers();
@@ -115,22 +115,22 @@ export function Users() {
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
<div className="flex items-center gap-2 text-zinc-700 font-medium">
<UsersIcon className="h-5 w-5 text-indigo-500" />
User Management
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger className="inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 bg-indigo-600 hover:bg-indigo-700 text-white rounded-md px-3" onClick={() => handleOpenDialog()}>
<Plus className="h-4 w-4" />
Add User
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{editingUser ? "Edit User" : "Add New User"}</DialogTitle>
<DialogTitle>{editingUser ? "编辑用户" : "添加新用户"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{error && <div className="text-red-500 text-sm">{error}</div>}
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username"></Label>
<Input
id="username"
value={formData.username}
@@ -139,7 +139,7 @@ export function Users() {
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
@@ -150,7 +150,7 @@ export function Users() {
</div>
{!editingUser && (
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
@@ -161,7 +161,7 @@ export function Users() {
</div>
)}
<div className="flex items-center justify-between">
<Label htmlFor="is_active">Active</Label>
<Label htmlFor="is_active"></Label>
<Switch
id="is_active"
checked={formData.is_active}
@@ -169,7 +169,7 @@ export function Users() {
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="is_admin">Admin</Label>
<Label htmlFor="is_admin"></Label>
<Switch
id="is_admin"
checked={formData.is_admin}
@@ -179,10 +179,10 @@ export function Users() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white">
Save
</Button>
</DialogFooter>
</form>
@@ -201,19 +201,19 @@ export function Users() {
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created At</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center h-24 text-zinc-500">
No users found.
</TableCell>
</TableRow>
) : (
@@ -224,12 +224,12 @@ export function Users() {
<TableCell>{user.email}</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_active ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-600'}`}>
{user.is_active ? 'Active' : 'Inactive'}
{user.is_active ? '正常' : '禁用'}
</span>
</TableCell>
<TableCell>
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${user.is_admin ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
{user.is_admin ? 'Admin' : 'User'}
{user.is_admin ? '管理员' : '普通用户'}
</span>
</TableCell>
<TableCell className="text-zinc-500">
+8
View File
@@ -12,6 +12,7 @@ interface AuthState {
token: string | null;
isAuthenticated: boolean;
login: (user: User, token: string) => void;
updateUser: (user: Partial<User>) => void;
logout: () => void;
}
@@ -24,6 +25,13 @@ export const useAuthStore = create<AuthState>((set) => ({
localStorage.setItem('token', token);
set({ user, token, isAuthenticated: true });
},
updateUser: (updatedUser) => set((state) => {
const user = state.user ? { ...state.user, ...updatedUser } : null;
if (user) {
localStorage.setItem('user', JSON.stringify(user));
}
return { user };
}),
logout: () => {
localStorage.removeItem('user');
localStorage.removeItem('token');