From 60891925753cf3087085a1da6ca6f43c0af8358f Mon Sep 17 00:00:00 2001 From: qixinbo Date: Sat, 14 Mar 2026 19:56:34 +0800 Subject: [PATCH] add model config and update settings --- backend/app/api/llm.py | 76 +++++- backend/data/llm_config.json | 20 ++ backend/dataclaw.db | Bin 20480 -> 20480 bytes frontend/src/App.tsx | 9 + frontend/src/components/Sidebar.tsx | 41 +-- frontend/src/pages/ModelConfigs.tsx | 376 ++++++++++++++++++++++++++++ frontend/src/pages/Settings.tsx | 270 +++++++++----------- frontend/src/pages/Users.tsx | 44 ++-- frontend/src/store/authStore.ts | 8 + 9 files changed, 642 insertions(+), 202 deletions(-) create mode 100644 backend/data/llm_config.json create mode 100644 frontend/src/pages/ModelConfigs.tsx diff --git a/backend/app/api/llm.py b/backend/app/api/llm.py index 2a62f9a..28e0313 100644 --- a/backend/app/api/llm.py +++ b/backend/app/api/llm.py @@ -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] diff --git a/backend/data/llm_config.json b/backend/data/llm_config.json new file mode 100644 index 0000000..e91756d --- /dev/null +++ b/backend/data/llm_config.json @@ -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 + } +] \ No newline at end of file diff --git a/backend/dataclaw.db b/backend/dataclaw.db index a421a5e705dc756ecd4d4bcf5ea5f3e0739e7067..2b488f08be917975f32f068ddb78e636f8f94862 100644 GIT binary patch delta 162 zcmZozz}T>Wae_1>_e2?IM(&LX269eWrf!uk=^p9EUd18hS?PJEepTjaMg{hTbmjL0N%0MUF{<*^@8Hxi}RYMtNrjd1U7K z_=o!zN0}s<85m}yt5g{!r59CZr}Wae_1>$3z)tMvjdM269fRzIvu^240?pVWHlU!R2n{PU$Y?jw&VP-WjO| zUPUg6`a#J>`Hsb&e#N2AQI(b!#U?rWsYyNomKK$+>B%L*zLPJ>xj4C{dL_yCYxlY`x=`%7iSilMpowfrAJvLM-*gb Ox`iYgY>tp$VE_P5m^7CF diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92660c0..503a195 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + + + + + + } /> ); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e4b4b69..74df860 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 + 个人设置 - + {user?.is_admin && ( - + <> + + + + )}
@@ -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 + 退出登录
)} diff --git a/frontend/src/pages/ModelConfigs.tsx b/frontend/src/pages/ModelConfigs.tsx new file mode 100644 index 0000000..267e92b --- /dev/null +++ b/frontend/src/pages/ModelConfigs.tsx @@ -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; + is_active: boolean; +} + +const defaultForm: Omit = { + 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([]); + 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(null); + const [error, setError] = useState(""); + const [extraConfigText, setExtraConfigText] = useState("{}"); + const [form, setForm] = useState>(defaultForm); + + const fetchConfigs = async () => { + setIsLoading(true); + try { + const data = await api.get("/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 = {}; + 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 ( +
+
无权限访问此页面,请使用管理员账号登录。
+
+ ); + } + + return ( +
+
+
+ + 模型配置 +
+
+
+ + setKeyword(e.target.value)} placeholder="搜索模型..." className="w-[200px] pl-9 h-8 text-sm" /> +
+ + +
+
+ +
+
+ {isLoading ? ( +
+ +
+ ) : ( + + + + 模型名称 + 供应商 + 模型标识 + 模型类型 + 状态 + 操作 + + + + {filteredConfigs.length === 0 ? ( + + + 暂无模型数据 + + + ) : ( + filteredConfigs.map((item) => ( + + + {item.name || item.model} + + {item.provider} + {item.model} + {item.model_type || "大语言模型"} + + 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 ? '默认' : '设为默认'} + + + + + + + + )) + )} + +
+ )} +
+
+ + + +
+ + {editingId ? "编辑模型" : "添加模型"} + +
+ {error &&
{error}
} + +
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} placeholder="如:GPT-4" /> +
+
+ + +
+
+ +
+
+ + setForm((p) => ({ ...p, model: e.target.value }))} placeholder="如:gpt-4-turbo" required /> +
+
+ + setForm((p) => ({ ...p, base_model: e.target.value }))} placeholder="可选" /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + setForm((p) => ({ ...p, api_base: e.target.value }))} placeholder="如:https://api.openai.com/v1" required /> +
+ +
+ +
+ setForm((p) => ({ ...p, api_key: e.target.value }))} + className="pr-10" + placeholder="不修改请留空" + /> + +
+
+ +
+ +