diff --git a/api/app/routers/llm.py b/api/app/routers/llm.py index 6292eed..80783b0 100644 --- a/api/app/routers/llm.py +++ b/api/app/routers/llm.py @@ -10,7 +10,7 @@ from ..db import get_db from ..models import LLMModel from ..schemas import ( LLMModelCreate, LLMModelUpdate, LLMModelOut, - LLMModelTestResponse + LLMModelTestResponse, LLMPreviewRequest, LLMPreviewResponse ) router = APIRouter(prefix="/llm", tags=["LLM Models"]) @@ -204,3 +204,66 @@ def chat_with_llm( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{id}/preview", response_model=LLMPreviewResponse) +def preview_llm_model( + id: str, + request: LLMPreviewRequest, + db: Session = Depends(get_db) +): + """预览 LLM 输出,基于 OpenAI-compatible /chat/completions。""" + model = db.query(LLMModel).filter(LLMModel.id == id).first() + if not model: + raise HTTPException(status_code=404, detail="LLM Model not found") + + user_message = (request.message or "").strip() + if not user_message: + raise HTTPException(status_code=400, detail="Preview message cannot be empty") + + messages = [] + if request.system_prompt and request.system_prompt.strip(): + messages.append({"role": "system", "content": request.system_prompt.strip()}) + messages.append({"role": "user", "content": user_message}) + + payload = { + "model": model.model_name or "gpt-3.5-turbo", + "messages": messages, + "max_tokens": request.max_tokens or 512, + "temperature": request.temperature if request.temperature is not None else (model.temperature or 0.7), + } + headers = {"Authorization": f"Bearer {(request.api_key or model.api_key).strip()}"} + + start_time = time.time() + try: + with httpx.Client(timeout=60.0) as client: + response = client.post( + f"{model.base_url.rstrip('/')}/chat/completions", + json=payload, + headers=headers + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"LLM request failed: {exc}") from exc + + if response.status_code != 200: + detail = response.text + try: + detail_json = response.json() + detail = detail_json.get("error", {}).get("message") or detail_json.get("detail") or detail + except Exception: + pass + raise HTTPException(status_code=502, detail=f"LLM vendor error: {detail}") + + result = response.json() + reply = "" + choices = result.get("choices", []) + if choices: + reply = choices[0].get("message", {}).get("content", "") or "" + + return LLMPreviewResponse( + success=bool(reply), + reply=reply, + usage=result.get("usage"), + latency_ms=int((time.time() - start_time) * 1000), + error=None if reply else "No response content", + ) diff --git a/api/app/schemas.py b/api/app/schemas.py index 5f718da..e8e0f7b 100644 --- a/api/app/schemas.py +++ b/api/app/schemas.py @@ -153,6 +153,22 @@ class LLMModelTestResponse(BaseModel): message: Optional[str] = None +class LLMPreviewRequest(BaseModel): + message: str + system_prompt: Optional[str] = None + max_tokens: Optional[int] = None + temperature: Optional[float] = None + api_key: Optional[str] = None + + +class LLMPreviewResponse(BaseModel): + success: bool + reply: Optional[str] = None + usage: Optional[dict] = None + latency_ms: Optional[int] = None + error: Optional[str] = None + + # ============ ASR Model ============ class ASRModelBase(BaseModel): name: str diff --git a/api/tests/test_llm.py b/api/tests/test_llm.py index d626928..42daf34 100644 --- a/api/tests/test_llm.py +++ b/api/tests/test_llm.py @@ -244,3 +244,55 @@ class TestLLMModelAPI: response = client.post("/api/llm", json=data) assert response.status_code == 200 assert response.json()["type"] == "embedding" + + def test_preview_llm_model_success(self, client, sample_llm_model_data, monkeypatch): + """Test LLM preview endpoint returns model reply.""" + from app.routers import llm as llm_router + + create_response = client.post("/api/llm", json=sample_llm_model_data) + model_id = create_response.json()["id"] + + class DummyResponse: + status_code = 200 + + def json(self): + return { + "choices": [{"message": {"content": "Preview OK"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12} + } + + @property + def text(self): + return '{"ok":true}' + + class DummyClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def post(self, url, json=None, headers=None): + assert url.endswith("/chat/completions") + assert headers["Authorization"] == f"Bearer {sample_llm_model_data['api_key']}" + assert json["messages"][0]["role"] == "user" + return DummyResponse() + + monkeypatch.setattr(llm_router.httpx, "Client", DummyClient) + + response = client.post(f"/api/llm/{model_id}/preview", json={"message": "hello"}) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["reply"] == "Preview OK" + + def test_preview_llm_model_reject_empty_message(self, client, sample_llm_model_data): + """Test LLM preview endpoint validates message.""" + create_response = client.post("/api/llm", json=sample_llm_model_data) + model_id = create_response.json()["id"] + + response = client.post(f"/api/llm/{model_id}/preview", json={"message": " "}) + assert response.status_code == 400 diff --git a/web/pages/LLMLibrary.tsx b/web/pages/LLMLibrary.tsx index c4e92b5..e6252eb 100644 --- a/web/pages/LLMLibrary.tsx +++ b/web/pages/LLMLibrary.tsx @@ -1,100 +1,115 @@ - -import React, { useState } from 'react'; -import { Search, Filter, Plus, BrainCircuit, Trash2, Key, Settings2, Server, Thermometer } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { Search, Filter, Plus, BrainCircuit, Trash2, Key, Settings2, Server, Thermometer, Pencil, Play } from 'lucide-react'; import { Button, Input, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge } from '../components/UI'; -import { mockLLMModels } from '../services/mockData'; import { LLMModel } from '../types'; +import { createLLMModel, deleteLLMModel, fetchLLMModels, previewLLMModel, updateLLMModel } from '../services/backendApi'; + +const maskApiKey = (key?: string) => { + if (!key) return '********'; + if (key.length < 8) return '********'; + return `${key.slice(0, 3)}****${key.slice(-4)}`; +}; export const LLMLibraryPage: React.FC = () => { - const [models, setModels] = useState(mockLLMModels); + const [models, setModels] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [vendorFilter, setVendorFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [isAddModalOpen, setIsAddModalOpen] = useState(false); + const [editingModel, setEditingModel] = useState(null); + const [previewingModel, setPreviewingModel] = useState(null); + const [isLoading, setIsLoading] = useState(true); - // Form State - const [newModel, setNewModel] = useState>({ - vendor: 'OpenAI Compatible', - type: 'text', - temperature: 0.7 - }); + useEffect(() => { + const load = async () => { + setIsLoading(true); + try { + setModels(await fetchLLMModels()); + } catch (error) { + console.error(error); + setModels([]); + } finally { + setIsLoading(false); + } + }; + load(); + }, []); - const filteredModels = models.filter(m => { - const matchesSearch = m.name.toLowerCase().includes(searchTerm.toLowerCase()); + const filteredModels = models.filter((m) => { + const q = searchTerm.toLowerCase(); + const matchesSearch = + m.name.toLowerCase().includes(q) || + (m.modelName || '').toLowerCase().includes(q) || + (m.baseUrl || '').toLowerCase().includes(q); const matchesVendor = vendorFilter === 'all' || m.vendor === vendorFilter; const matchesType = typeFilter === 'all' || m.type === typeFilter; return matchesSearch && matchesVendor && matchesType; }); - const handleAddModel = () => { - if (!newModel.name || !newModel.baseUrl || !newModel.apiKey) { - alert("请填写完整信息"); - return; - } - - const model: LLMModel = { - id: `m_${Date.now()}`, - name: newModel.name, - vendor: newModel.vendor as string, - type: newModel.type as 'text' | 'embedding' | 'rerank', - baseUrl: newModel.baseUrl, - apiKey: newModel.apiKey, - temperature: newModel.type === 'text' ? newModel.temperature : undefined - }; - - setModels([model, ...models]); + const handleCreate = async (data: Partial) => { + const created = await createLLMModel(data); + setModels((prev) => [created, ...prev]); setIsAddModalOpen(false); - setNewModel({ vendor: 'OpenAI Compatible', type: 'text', temperature: 0.7, name: '', baseUrl: '', apiKey: '' }); }; - const handleDeleteModel = (id: string) => { - if (confirm('确认删除该模型配置吗?')) { - setModels(prev => prev.filter(m => m.id !== id)); - } + const handleUpdate = async (id: string, data: Partial) => { + const updated = await updateLLMModel(id, data); + setModels((prev) => prev.map((item) => (item.id === id ? updated : item))); + setEditingModel(null); }; + const handleDelete = async (id: string) => { + if (!confirm('确认删除该模型配置吗?')) return; + await deleteLLMModel(id); + setModels((prev) => prev.filter((item) => item.id !== id)); + }; + + const vendorOptions = Array.from(new Set(models.map((m) => m.vendor).filter(Boolean))); + return (

模型接入

-
- - setSearchTerm(e.target.value)} - /> -
-
- - -
-
- -
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + +
+
+ +
@@ -104,145 +119,349 @@ export const LLMLibraryPage: React.FC = () => { 模型名称 厂商 类型 + 模型标识 Base URL + API Key 操作 - {filteredModels.map(model => ( + {!isLoading && filteredModels.map((model) => ( - - {model.name} + + {model.name} - {model.vendor} + {model.vendor} - - {model.type.toUpperCase()} - - - - {model.baseUrl} + + {model.type.toUpperCase()} + + {model.modelName || '-'} + {model.baseUrl} + {maskApiKey(model.apiKey)} - + + + ))} - {filteredModels.length === 0 && ( - - 暂无模型数据 - - )} + {!isLoading && filteredModels.length === 0 && ( + + 暂无模型数据 + + )} + {isLoading && ( + + 加载中... + + )}
- setIsAddModalOpen(false)} - title="添加大模型" - footer={ - <> - - - - } - > -
-
- - -
+ setIsAddModalOpen(false)} onSubmit={handleCreate} /> -
- -
- {(['text', 'embedding', 'rerank'] as const).map(t => ( - - ))} -
-
+ setEditingModel(null)} + onSubmit={(data) => handleUpdate(editingModel!.id, data)} + initialModel={editingModel || undefined} + /> -
- - setNewModel({...newModel, name: e.target.value})} - placeholder="例如: gpt-4o, deepseek-chat" - /> -
- -
- - setNewModel({...newModel, baseUrl: e.target.value})} - placeholder="https://api.openai.com/v1" - className="font-mono text-xs" - /> -
- -
- - setNewModel({...newModel, apiKey: e.target.value})} - placeholder="sk-..." - className="font-mono text-xs" - /> -
- - {newModel.type === 'text' && ( -
-
- - {newModel.temperature} -
- setNewModel({...newModel, temperature: parseFloat(e.target.value)})} - className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - /> -
- )} -
-
+ setPreviewingModel(null)} + model={previewingModel} + />
); }; + +const LLMModelModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + onSubmit: (model: Partial) => Promise; + initialModel?: LLMModel; +}> = ({ isOpen, onClose, onSubmit, initialModel }) => { + const [name, setName] = useState(''); + const [vendor, setVendor] = useState('OpenAI Compatible'); + const [type, setType] = useState<'text' | 'embedding' | 'rerank'>('text'); + const [modelName, setModelName] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [temperature, setTemperature] = useState(0.7); + const [contextLength, setContextLength] = useState(8192); + const [enabled, setEnabled] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + if (initialModel) { + setName(initialModel.name || ''); + setVendor(initialModel.vendor || 'OpenAI Compatible'); + setType(initialModel.type || 'text'); + setModelName(initialModel.modelName || ''); + setBaseUrl(initialModel.baseUrl || ''); + setApiKey(initialModel.apiKey || ''); + setTemperature(initialModel.temperature ?? 0.7); + setContextLength(initialModel.contextLength ?? 8192); + setEnabled(initialModel.enabled ?? true); + return; + } + + setName(''); + setVendor('OpenAI Compatible'); + setType('text'); + setModelName(''); + setBaseUrl(''); + setApiKey(''); + setTemperature(0.7); + setContextLength(8192); + setEnabled(true); + }, [initialModel, isOpen]); + + const handleSubmit = async () => { + if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) { + alert('请填写完整信息'); + return; + } + + try { + setSaving(true); + await onSubmit({ + name: name.trim(), + vendor: vendor.trim(), + type, + modelName: modelName.trim() || undefined, + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim(), + temperature: type === 'text' ? temperature : undefined, + contextLength: contextLength > 0 ? contextLength : undefined, + enabled, + }); + } catch (error: any) { + alert(error?.message || '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( + + + + + } + > +
+
+ + +
+ +
+ +
+ {(['text', 'embedding', 'rerank'] as const).map((t) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} placeholder="例如: GPT4o-Prod" /> +
+ +
+ + setModelName(e.target.value)} placeholder="例如: gpt-4o-mini" /> +
+ +
+ + setBaseUrl(e.target.value)} placeholder="https://api.openai.com/v1" className="font-mono text-xs" /> +
+ +
+ + setApiKey(e.target.value)} placeholder="sk-..." className="font-mono text-xs" /> +
+ + {type === 'text' && ( +
+
+ + {temperature.toFixed(1)} +
+ setTemperature(parseFloat(e.target.value))} + className="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+ )} + +
+ + setContextLength(parseInt(e.target.value || '0', 10))} /> +
+ + +
+
+ ); +}; + +const LLMPreviewModal: React.FC<{ + isOpen: boolean; + onClose: () => void; + model: LLMModel | null; +}> = ({ isOpen, onClose, model }) => { + const [systemPrompt, setSystemPrompt] = useState('You are a concise helpful assistant.'); + const [message, setMessage] = useState('Hello, please introduce yourself in one sentence.'); + const [temperature, setTemperature] = useState(0.7); + const [maxTokens, setMaxTokens] = useState(256); + const [reply, setReply] = useState(''); + const [latency, setLatency] = useState(null); + const [usage, setUsage] = useState | null>(null); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + if (!isOpen) return; + setReply(''); + setLatency(null); + setUsage(null); + setTemperature(model?.temperature ?? 0.7); + }, [isOpen, model]); + + const runPreview = async () => { + if (!model?.id) return; + if (!message.trim()) { + alert('请输入测试消息'); + return; + } + + try { + setIsRunning(true); + const result = await previewLLMModel(model.id, { + message, + system_prompt: systemPrompt || undefined, + max_tokens: maxTokens, + temperature, + }); + setReply(result.reply || result.error || '无返回内容'); + setLatency(result.latency_ms ?? null); + setUsage(result.usage || null); + } catch (error: any) { + alert(error?.message || '预览失败'); + } finally { + setIsRunning(false); + } + }; + + return ( + + + + + } + > +
+
+ +