From 4c372746d57d084d7e7983d8e0329be3306f927a Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 10 Jul 2026 20:04:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=80=89=E9=A1=B9(11=E7=A7=8D)=20+=20?= =?UTF-8?q?=E6=8C=89=E8=83=BD=E5=8A=9B=E6=8C=87=E5=AE=9A=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20+=20Auto=E8=87=AA=E5=8A=A8=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/admin/models.py | 2137 ++++++++++++--------- backend/python/app/api/chat.py | 17 +- backend/python/app/capability_registry.py | 97 + frontend/src/api/index.ts | 5 + frontend/src/pages/admin/Models.tsx | 94 +- frontend/src/pages/front/ChatPage.tsx | 24 +- 6 files changed, 1408 insertions(+), 966 deletions(-) create mode 100644 backend/python/app/capability_registry.py diff --git a/backend/python/app/api/admin/models.py b/backend/python/app/api/admin/models.py index 5f36f7f..bf5d039 100644 --- a/backend/python/app/api/admin/models.py +++ b/backend/python/app/api/admin/models.py @@ -1,949 +1,1188 @@ -""" -大模型库管理 API(管理员) -""" -from fastapi import APIRouter, Depends, HTTPException, Query, Body -from sqlalchemy.orm import Session -from sqlalchemy import func -from typing import List, Optional -from pydantic import BaseModel -from ...database import get_db, SessionLocal -from ...models import AIModel, ModelProvider -from ...auth import get_current_admin -from ...middleware.log_service import LogService -import uuid -import json -import logging -import httpx -from datetime import datetime - -router = APIRouter() - -# ============================================================ -# 协议预设列表 -# ============================================================ - -PROTOCOLS = [ - {"id": "openai_compatible", "name": "OpenAI兼容", "base_path": "/v1", "description": "兼容OpenAI API格式的模型服务"}, - {"id": "new_api", "name": "New API", "base_path": "/v1", "description": "新一代API聚合平台"}, - {"id": "volcengine", "name": "火山引擎", "base_path": "/v1", "description": "字节跳动火山引擎模型服务"}, - {"id": "aliyun_bailian", "name": "阿里云百炼", "base_path": "/v1", "description": "阿里云百炼大模型平台"}, - {"id": "ollama", "name": "Ollama", "base_path": "/v1", "description": "本地Ollama模型服务"}, - {"id": "xiaoma", "name": "小马算力", "base_path": "/v1", "description": "小马算力模型服务"}, -] - -# ============================================================ -# 模型列表(用于 fetch-models 的回退数据) -# ============================================================ - -FETCHABLE_MODELS = [ - {"id": "gpt-4o", "name": "GPT-4o", "capabilities": ["text", "vision", "tool"], "pricing": "paid"}, - {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "capabilities": ["text", "vision"], "pricing": "paid"}, - {"id": "deepseek-v3", "name": "DeepSeek-V3", "capabilities": ["text", "thinking"], "pricing": "free"}, - {"id": "deepseek-r1", "name": "DeepSeek-R1", "capabilities": ["text", "thinking"], "pricing": "paid"}, - {"id": "qwen-max", "name": "Qwen-Max", "capabilities": ["text", "tool"], "pricing": "free"}, - {"id": "qwen-vl", "name": "Qwen-VL", "capabilities": ["text", "vision"], "pricing": "paid"}, - {"id": "whisper", "name": "Whisper", "capabilities": ["audio"], "pricing": "paid"}, - {"id": "dalle", "name": "DALL-E", "capabilities": ["image"], "pricing": "paid"}, -] - -# 支持通过 API 获取模型列表的协议类型 -FETCHABLE_PROTOCOLS = {"openai_compatible", "new_api", "volcengine", "aliyun_bailian", "ollama", "xiaoma"} - - -# ============================================================ -# 辅助函数 -# ============================================================ - -def provider_to_dict(p: ModelProvider) -> dict: - return { - "id": p.id, - "name": p.name, - "protocol": p.protocol, - "api_key": p.api_key, - "api_base_url": p.api_base_url, - "apiKey": p.api_key, - "apiBaseUrl": p.api_base_url, - "created_at": p.created_at.isoformat() if p.created_at else None, - "updated_at": p.updated_at.isoformat() if p.updated_at else None, - "createdAt": p.created_at.isoformat() if p.created_at else None, - "updatedAt": p.updated_at.isoformat() if p.updated_at else None, - } - - -def model_to_dict(m: AIModel) -> dict: - return { - "id": m.id, - "provider_id": m.provider_id, - "name": m.name, - "display_name": getattr(m, "display_name", None) or m.name, - "api_key": m.api_key, - "endpoint": m.endpoint, - "capabilities": m.capabilities, - "timeout": m.timeout, - "status": m.status, - "price_per_million_tokens": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, - "is_enabled": m.is_enabled, - "is_default": bool(m.is_default), - "total_calls": m.total_calls or 0, - "total_tokens": m.total_tokens or 0, - "created_at": m.created_at.isoformat() if m.created_at else None, - "updated_at": m.updated_at.isoformat() if m.updated_at else None, - } - - -# ============================================================ -# 协议列表 -# ============================================================ - -@router.get("/protocols") -async def get_protocols( - admin_user=Depends(get_current_admin), -): - """获取支持的协议列表""" - return PROTOCOLS - - -# ============================================================ -# 模型供应商管理 -# ============================================================ - -@router.get("/providers") -async def get_providers( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取模型供应商列表""" - providers = db.query(ModelProvider).order_by( - ModelProvider.created_at.desc() - ).all() - return [provider_to_dict(p) for p in providers] - - -@router.post("/providers") -async def create_provider( - name: str = Body(...), - protocol: str = Body(...), - api_key: str = Body(default=""), - api_base_url: str = Body(default=None), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """添加模型供应商""" - # 验证协议 - valid_protocols = [p["id"] for p in PROTOCOLS] - if protocol not in valid_protocols: - raise HTTPException(status_code=400, detail=f"无效的协议类型,支持: {', '.join(valid_protocols)}") - - provider = ModelProvider( - id=str(uuid.uuid4()), - name=name, - protocol=protocol, - api_key=api_key, - api_base_url=api_base_url, - ) - db.add(provider) - db.commit() - db.refresh(provider) - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="create_provider", - target_type="model_provider", - target_id=provider.id, - detail=f"添加模型供应商 {name} (协议: {protocol})", - ) - - return {"message": "供应商已创建", "id": provider.id, "provider": provider_to_dict(provider)} - - -@router.put("/providers/{id}") -async def update_provider( - id: str, - name: str = Body(default=None), - protocol: str = Body(default=None), - api_key: str = Body(default=None), - api_base_url: str = Body(default=None), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新模型供应商""" - provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - - if name is not None: - provider.name = name - if protocol is not None: - valid_protocols = [p["id"] for p in PROTOCOLS] - if protocol not in valid_protocols: - raise HTTPException(status_code=400, detail=f"无效的协议类型") - provider.protocol = protocol - if api_key is not None: - provider.api_key = api_key - if api_base_url is not None: - provider.api_base_url = api_base_url - provider.updated_at = datetime.now() - db.commit() - db.refresh(provider) - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_provider", - target_type="model_provider", - target_id=id, - detail=f"更新模型供应商 {provider.name}", - ) - - return {"message": "供应商已更新", "provider": provider_to_dict(provider)} - - -@router.delete("/providers/{id}") -async def delete_provider( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """删除模型供应商""" - provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - - # 检查是否有模型关联 - model_count = db.query(func.count(AIModel.id)).filter( - AIModel.provider_id == id - ).scalar() - if model_count > 0: - raise HTTPException(status_code=400, detail=f"该供应商下有 {model_count} 个模型,请先删除模型") - - db.delete(provider) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="delete_provider", - target_type="model_provider", - target_id=id, - detail=f"删除模型供应商 {provider.name}", - ) - - return {"message": "供应商已删除"} - - -@router.post("/providers/{id}/fetch-models") -async def fetch_models( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取供应商可用模型列表(优先从真实API获取)""" - provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - - logger = logging.getLogger(__name__) - logger.info(f"Fetching models for provider {provider.name} (protocol: {provider.protocol})") - - api_base = (provider.api_base_url or "").rstrip("/") - api_key = provider.api_key or "" - - # 尝试从真实API获取模型列表(只要有 api_base 就尝试调用,api_key 可选) - if api_base and provider.protocol in FETCHABLE_PROTOCOLS: - try: - async with httpx.AsyncClient(timeout=15) as client: - headers = {} - if api_key: - headers["Authorization"] = f"Bearer {api_key}" - resp = await client.get(f"{api_base}/v1/models", headers=headers) - if resp.status_code == 200: - data = resp.json() - models_list = data.get("data", []) - if models_list: - result = [] - for m in models_list: - mid = m.get("id", "") - if not mid: - continue - result.append({ - "id": mid, - "name": mid, - "capabilities": ["text"], - "pricing": "paid", - }) - logger.info(f"Successfully fetched {len(result)} models from provider API") - return {"models": result, "source": "api"} - else: - logger.warning(f"Provider API returned empty model list") - return {"models": [], "source": "api", "message": "API返回的模型列表为空"} - elif resp.status_code == 401 or resp.status_code == 403: - logger.warning(f"Provider API returned {resp.status_code}: auth error") - return {"models": [], "source": "error", "message": "API认证失败,请检查API Key是否正确"} - else: - logger.warning(f"Provider API returned status {resp.status_code}: {resp.text[:200]}") - return {"models": [], "source": "error", "message": f"API请求失败(HTTP {resp.status_code}),请检查API地址和密钥"} - except httpx.TimeoutException: - logger.warning(f"Timeout fetching models from provider API: {api_base}") - return {"models": [], "source": "error", "message": "请求API超时,请检查API地址是否正确"} - except Exception as e: - logger.warning(f"Failed to fetch models from provider API: {e}") - return {"models": [], "source": "error", "message": f"请求API失败: {str(e)}"} - - # 回退:返回固定模型列表(供无 API 的离线环境使用) - logger.info(f"Using fallback model list for provider {provider.name}") - return {"models": FETCHABLE_MODELS, "source": "fallback"} - - -class AddModelsRequest(BaseModel): - model_ids: List[str] - - -@router.post("/providers/{id}/add-models") -async def add_models( - id: str, - req: AddModelsRequest, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """批量添加模型到供应商""" - provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - - # 找到协议对应的 base_path - protocol_info = next((p for p in PROTOCOLS if p["id"] == provider.protocol), None) - base_path = protocol_info["base_path"] if protocol_info else "/v1" - - added = [] - for model_id in req.model_ids: - # 在 FETCHABLE_MODELS 中查找额外信息 - model_info = next((m for m in FETCHABLE_MODELS if m["id"] == model_id), None) - - # 使用 model_id 作为模型名称 - model_name = model_id # API ID - display_name_val = model_info["name"] if model_info else model_id - - # 检查是否已存在同名模型 - existing = db.query(AIModel).filter( - AIModel.provider_id == id, - AIModel.name == model_name - ).first() - if existing: - continue - - # 自动生成 endpoint - if provider.api_base_url: - endpoint = f"{provider.api_base_url.rstrip('/')}{base_path}/chat/completions" - else: - endpoint = "" - - model = AIModel( - id=str(uuid.uuid4()), - provider_id=id, - name=model_name, - display_name=display_name_val, - api_key=provider.api_key or "", - endpoint=endpoint, - capabilities=model_info.get("capabilities") if model_info else ["text"], - timeout=60, - status="free" if (model_info and model_info.get("pricing") == "free") else "paid", - price_per_million_tokens=0, - is_enabled=True, - ) - db.add(model) - added.append(model_name) - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="add_models", - target_type="model_provider", - target_id=id, - detail=f"为供应商 {provider.name} 批量添加 {len(added)} 个模型", - ) - - return {"message": f"成功添加 {len(added)} 个模型", "added": added} - - -# ============================================================ -# 模型管理 -# ============================================================ - -@router.get("/") -async def get_models( - provider_id: str = Query(None), - status: str = Query(None, description="模型状态筛选: free/paid"), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取模型列表(管理员)""" - query = db.query(AIModel) - - if provider_id: - query = query.filter(AIModel.provider_id == provider_id) - if status: - query = query.filter(AIModel.status == status) - - total = query.count() - models = query.order_by(AIModel.created_at.desc()).offset( - (page - 1) * page_size - ).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "items": [model_to_dict(m) for m in models], - } - - -@router.post("/") -async def create_model( - provider_id: str, - name: str, - display_name: str = None, - api_key: str = None, - endpoint: str = None, - capabilities: List[str] = None, - timeout: int = 60, - status: str = "paid", - price_per_million_tokens: float = 0, - is_enabled: bool = True, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """添加模型""" - # 验证供应商存在 - provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - - model = AIModel( - id=str(uuid.uuid4()), - provider_id=provider_id, - name=name, - display_name=display_name, - api_key=api_key, - endpoint=endpoint, - capabilities=capabilities, - timeout=timeout, - status=status, - price_per_million_tokens=price_per_million_tokens, - is_enabled=is_enabled, - ) - db.add(model) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="create_model", - target_type="model", - target_id=model.id, - detail=f"添加模型 {name}", - ) - - return {"message": "模型已创建", "id": model.id} - - -@router.put("/{id}") -async def update_model( - id: str, - provider_id: str = None, - name: str = None, - display_name: str = None, - api_key: str = None, - endpoint: str = None, - capabilities: List[str] = None, - timeout: int = None, - status: str = None, - price_per_million_tokens: float = None, - is_enabled: bool = None, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新模型""" - model = db.query(AIModel).filter(AIModel.id == id).first() - if not model: - raise HTTPException(status_code=404, detail="模型不存在") - - if provider_id is not None: - provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first() - if not provider: - raise HTTPException(status_code=404, detail="供应商不存在") - model.provider_id = provider_id - if name is not None: - model.name = name - if display_name is not None: - model.display_name = display_name - if api_key is not None: - model.api_key = api_key - if endpoint is not None: - model.endpoint = endpoint - if capabilities is not None: - model.capabilities = capabilities - if timeout is not None: - model.timeout = timeout - if status is not None: - model.status = status - if price_per_million_tokens is not None: - model.price_per_million_tokens = price_per_million_tokens - if is_enabled is not None: - model.is_enabled = is_enabled - model.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_model", - target_type="model", - target_id=id, - detail=f"更新模型 {model.name}", - ) - - return {"message": "模型已更新"} - - -@router.delete("/{id}") -async def delete_model( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """删除模型""" - model = db.query(AIModel).filter(AIModel.id == id).first() - if not model: - raise HTTPException(status_code=404, detail="模型不存在") - - model_name = model.name - db.delete(model) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="delete_model", - target_type="model", - target_id=id, - detail=f"删除模型 {model_name}", - ) - - return {"message": "模型已删除"} - - -@router.put("/{id}/toggle") -async def toggle_model( - id: str, - is_enabled: bool = Query(...), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """开关模型状态""" - model = db.query(AIModel).filter(AIModel.id == id).first() - if not model: - raise HTTPException(status_code=404, detail="模型不存在") - - model.is_enabled = is_enabled - model.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="toggle_model", - target_type="model", - target_id=id, - detail=f"将模型 {model.name} {'启用' if is_enabled else '禁用'}", - ) - - return {"message": f"模型已{'启用' if is_enabled else '禁用'}"} - - -@router.put("/{id}/set-default") -async def set_default_model( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """设为全局默认模型(取消其他模型的默认状态)""" - model = db.query(AIModel).filter(AIModel.id == id).first() - if not model: - raise HTTPException(status_code=404, detail="模型不存在") - - # 取消所有模型的默认状态 - db.query(AIModel).filter(AIModel.is_default == True).update({"is_default": False}) - # 设置当前模型为默认 - model.is_default = True - model.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="set_default_model", - target_type="model", - target_id=id, - detail=f"设置模型 {model.name} 为全局默认", - ) - - return {"message": f"已将 {model.name} 设为全局默认模型"} - - -@router.get("/stats") -async def get_model_stats( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取模型调用统计""" - total_calls = db.query(func.sum(AIModel.total_calls)).scalar() or 0 - total_tokens = db.query(func.sum(AIModel.total_tokens)).scalar() or 0 - - return { - "total_calls": int(total_calls), - "total_tokens": int(total_tokens), - } - - -# ============================================================ -# 模型连接测试 API (Phase 2) -# ============================================================ - -def _detect_model_type(capabilities): - """根据 capabilities 判断模型类型,返回测试类型""" - if not capabilities: - return "text" - caps = [c.lower() for c in capabilities] - # 优先级: image > audio > video > vision > text - if "image" in caps: - return "image" - if "audio" in caps: - return "audio" - if "video" in caps: - return "video" - if "vision" in caps: - return "vision" - return "text" - - -def _build_api_base(provider, ai_model): - """构造 API base URL 和 headers""" - api_key = ai_model.api_key or (provider.api_key if provider else None) - base_url = "" - if provider and provider.api_base_url: - base_url = provider.api_base_url.rstrip("/") - elif ai_model.endpoint: - # 从 endpoint 反推 base - endpoint = ai_model.endpoint - for suffix in ["/v1/chat/completions", "/v1/images/generations", "/v1/audio/speech", "/v1/audio/transcriptions"]: - if endpoint.endswith(suffix): - base_url = endpoint[:-len(suffix)] - break - if not base_url: - base_url = endpoint.rsplit("/v1", 1)[0] if "/v1" in endpoint else "" - return api_key, base_url - - -@router.post("/{model_id}/test-connection") -async def test_model_connection( - model_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """ - 测试指定模型的连接是否正常 - 支持多模态: text/image/audio/vision 自动检测模型类型并使用对应API测试 - """ - import time - - logger = logging.getLogger(__name__) - logger.info(f"Testing connection for model_id: {model_id}") - - ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() - if not ai_model: - raise HTTPException(status_code=404, detail="模型不存在") - - provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() - if not provider: - raise HTTPException(status_code=404, detail="模型供应商不存在") - - api_key, base_url = _build_api_base(provider, ai_model) - - if not api_key: - return { - "success": False, - "message": "未配置 API Key,请在模型或供应商设置中添加", - "latency_ms": None, - "model_type": "unknown", - } - if not base_url: - return { - "success": False, - "message": "未配置 API 地址,请在模型或供应商设置中添加", - "latency_ms": None, - "model_type": "unknown", - } - - model_type = _detect_model_type(ai_model.capabilities) - timeout = ai_model.timeout or 60 - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - start = time.time() - - try: - async with httpx.AsyncClient(timeout=timeout) as client: - - # ============================================================ - # 文本模型 / 视觉模型 / 思考模型 → chat/completions - # ============================================================ - if model_type in ("text", "vision", "thinking", "tool"): - chat_url = f"{base_url}/v1/chat/completions" - payload = { - "model": ai_model.name, - "messages": [{"role": "user", "content": "hi"}], - "max_tokens": 5, - } - # 视觉模型附带图片URL测试 - if model_type == "vision": - payload["messages"] = [{ - "role": "user", - "content": [ - {"type": "text", "text": "What is this?"}, - {"type": "image_url", "image_url": {"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png"}}, - ], - }] - payload["max_tokens"] = 10 - - resp = await client.post(chat_url, json=payload, headers=headers) - latency = int((time.time() - start) * 1000) - - if resp.status_code == 200: - data = resp.json() - model_used = data.get("model", ai_model.name) - return { - "success": True, - "message": f"连接成功 ({model_type}),模型: {model_used}", - "latency_ms": latency, - "model_type": model_type, - "model_in_response": model_used, - } - else: - return _build_error_response(resp, latency, model_type) - - # ============================================================ - # 图像生成模型 → images/generations - # ============================================================ - elif model_type == "image": - image_url = f"{base_url}/v1/images/generations" - payload = { - "model": ai_model.name, - "prompt": "a red circle", - "n": 1, - "size": "256x256", - } - resp = await client.post(image_url, json=payload, headers=headers) - latency = int((time.time() - start) * 1000) - - if resp.status_code == 200: - data = resp.json() - img_count = len(data.get("data", [])) - return { - "success": True, - "message": f"图像生成成功 ({model_type}),返回 {img_count} 张图片", - "latency_ms": latency, - "model_type": model_type, - } - else: - return _build_error_response(resp, latency, model_type) - - # ============================================================ - # 音频模型 → audio/speech (TTS) - # ============================================================ - elif model_type == "audio": - audio_url = f"{base_url}/v1/audio/speech" - payload = { - "model": ai_model.name, - "input": "hello", - "voice": "alloy", - } - resp = await client.post(audio_url, json=payload, headers=headers) - latency = int((time.time() - start) * 1000) - - if resp.status_code == 200: - content_type = resp.headers.get("content-type", "") - has_audio = "audio" in content_type or len(resp.content) > 100 - return { - "success": True, - "message": f"音频服务连接成功 ({model_type}),响应类型: {content_type}", - "latency_ms": latency, - "model_type": model_type, - } - else: - return _build_error_response(resp, latency, model_type) - - # ============================================================ - # 视频模型 → 尝试 chat/completions (可能不支持,回退) - # ============================================================ - elif model_type == "video": - chat_url = f"{base_url}/v1/chat/completions" - payload = { - "model": ai_model.name, - "messages": [{"role": "user", "content": "hi"}], - "max_tokens": 5, - } - resp = await client.post(chat_url, json=payload, headers=headers) - latency = int((time.time() - start) * 1000) - - if resp.status_code == 200: - data = resp.json() - model_used = data.get("model", ai_model.name) - return { - "success": True, - "message": f"连接成功 ({model_type}),模型: {model_used}", - "latency_ms": latency, - "model_type": model_type, - "model_in_response": model_used, - } - else: - return _build_error_response(resp, latency, model_type) - - else: - return { - "success": False, - "message": f"未知模型类型: {model_type}", - "latency_ms": int((time.time() - start) * 1000), - "model_type": model_type, - } - - except httpx.TimeoutException: - latency = int((time.time() - start) * 1000) - return { - "success": False, - "message": f"连接超时({timeout}s):请检查 API 地址是否正确,或服务是否可访问", - "latency_ms": latency, - "model_type": model_type, - } - except Exception as e: - latency = int((time.time() - start) * 1000) - logger.error(f"Model connection test failed: {e}") - return { - "success": False, - "message": f"连接失败: {str(e)}", - "latency_ms": latency, - "model_type": model_type, - } - - -def _build_error_response(resp, latency, model_type): - """构造错误响应""" - if resp.status_code == 401: - msg = "认证失败:API Key 无效或已过期" - elif resp.status_code == 403: - msg = "访问被拒绝:API Key 权限不足" - elif resp.status_code == 429: - msg = "触发限流:请稍后重试" - elif resp.status_code == 404: - msg = f"API 地址不存在 (404):请检查 endpoint 配置是否正确" - else: - try: - err_data = resp.json() - err_msg = err_data.get("error", {}).get("message", resp.text[:200]) - except Exception: - err_msg = resp.text[:200] - msg = f"API 错误 (HTTP {resp.status_code}): {err_msg}" - return { - "success": False, - "message": msg, - "latency_ms": latency, - "model_type": model_type, - } - - -@router.post("/test-all") -async def test_all_models( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """批量测试所有已启用模型的连接状态""" - import asyncio - - models = db.query(AIModel).filter(AIModel.is_enabled == True).all() - results = [] - - for m in models: - provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() - if not provider: - results.append({ - "model_id": m.id, - "model_name": m.name, - "display_name": m.display_name or m.name, - "success": False, - "message": "供应商不存在", - "latency_ms": None, - "model_type": "unknown", - }) - continue - - # 复用 test_model_connection 逻辑 - try: - result = await test_model_connection(m.id, admin_user, db) - if isinstance(result, dict): - result["model_id"] = m.id - result["model_name"] = m.name - result["display_name"] = m.display_name or m.name - results.append(result) - else: - results.append({ - "model_id": m.id, - "model_name": m.name, - "display_name": m.display_name or m.name, - "success": False, - "message": "测试返回异常", - "latency_ms": None, - "model_type": "unknown", - }) - except Exception as e: - results.append({ - "model_id": m.id, - "model_name": m.name, - "display_name": m.display_name or m.name, - "success": False, - "message": f"测试异常: {str(e)}", - "latency_ms": None, - "model_type": "unknown", - }) - - success_count = sum(1 for r in results if r.get("success")) - return { - "total": len(results), - "success": success_count, - "failed": len(results) - success_count, - "results": results, - } - - - timeout: int = 60 - status: str = "paid" - price_per_million_tokens: float = 0.0 - is_enabled: bool = True - - -class UpdateModelRequest(BaseModel): - name: Optional[str] = None - api_key: Optional[str] = None - endpoint: Optional[str] = None - capabilities: Optional[List[str]] = None - timeout: Optional[int] = None - status: Optional[str] = None - price_per_million_tokens: Optional[float] = None - is_enabled: Optional[bool] = None - - -from typing import Optional +""" +大模型库管理 API(管理员) +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import func +from typing import List, Optional +from pydantic import BaseModel +from ...database import get_db, SessionLocal +from ...models import AIModel, ModelProvider, SiteSetting +from ...auth import get_current_admin +from ...middleware.log_service import LogService +from ...capability_registry import ( + CAPABILITY_TYPES, CAPABILITY_COLORS, TEXT_FAMILY, + get_capability_defaults, set_capability_default, resolve_auto_model, +) +import uuid +import json +import logging +import httpx +from datetime import datetime + +router = APIRouter() + + +# ============================================================ +# 请求体模型(前端以 JSON body 提交,而非 query 参数) +# ============================================================ +class ModelCreateRequest(BaseModel): + provider_id: str + name: str + display_name: Optional[str] = None + api_key: Optional[str] = None + endpoint: Optional[str] = None + capabilities: Optional[List[str]] = None + timeout: int = 60 + status: str = "paid" + price_per_million_tokens: float = 0 + is_enabled: bool = True + + +class ModelUpdateRequest(BaseModel): + provider_id: Optional[str] = None + name: Optional[str] = None + display_name: Optional[str] = None + api_key: Optional[str] = None + endpoint: Optional[str] = None + capabilities: Optional[List[str]] = None + timeout: Optional[int] = None + status: Optional[str] = None + price_per_million_tokens: Optional[float] = None + is_enabled: Optional[bool] = None + + +# ============================================================ +# 协议预设列表 +# ============================================================ + +PROTOCOLS = [ + {"id": "openai_compatible", "name": "OpenAI兼容", "base_path": "/v1", "description": "兼容OpenAI API格式的模型服务"}, + {"id": "new_api", "name": "New API", "base_path": "/v1", "description": "新一代API聚合平台"}, + {"id": "volcengine", "name": "火山引擎", "base_path": "/v1", "description": "字节跳动火山引擎模型服务"}, + {"id": "aliyun_bailian", "name": "阿里云百炼", "base_path": "/v1", "description": "阿里云百炼大模型平台"}, + {"id": "ollama", "name": "Ollama", "base_path": "/v1", "description": "本地Ollama模型服务"}, + {"id": "xiaoma", "name": "小马算力", "base_path": "/v1", "description": "小马算力模型服务"}, +] + +# ============================================================ +# 模型列表(用于 fetch-models 的回退数据) +# ============================================================ + +FETCHABLE_MODELS = [ + {"id": "gpt-4o", "name": "GPT-4o", "capabilities": ["text", "vision", "tool"], "pricing": "paid"}, + {"id": "gpt-4o-mini", "name": "GPT-4o Mini", "capabilities": ["text", "vision"], "pricing": "paid"}, + {"id": "deepseek-v3", "name": "DeepSeek-V3", "capabilities": ["text", "thinking"], "pricing": "free"}, + {"id": "deepseek-r1", "name": "DeepSeek-R1", "capabilities": ["text", "thinking"], "pricing": "paid"}, + {"id": "qwen-max", "name": "Qwen-Max", "capabilities": ["text", "tool"], "pricing": "free"}, + {"id": "qwen-vl", "name": "Qwen-VL", "capabilities": ["text", "vision"], "pricing": "paid"}, + {"id": "whisper", "name": "Whisper", "capabilities": ["audio"], "pricing": "paid"}, + {"id": "dalle", "name": "DALL-E", "capabilities": ["image"], "pricing": "paid"}, +] + +# 支持通过 API 获取模型列表的协议类型 +FETCHABLE_PROTOCOLS = {"openai_compatible", "new_api", "volcengine", "aliyun_bailian", "ollama", "xiaoma"} + + +# ============================================================ +# 辅助函数 +# ============================================================ + +def provider_to_dict(p: ModelProvider) -> dict: + return { + "id": p.id, + "name": p.name, + "protocol": p.protocol, + "api_key": p.api_key, + "api_base_url": p.api_base_url, + "apiKey": p.api_key, + "apiBaseUrl": p.api_base_url, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + "createdAt": p.created_at.isoformat() if p.created_at else None, + "updatedAt": p.updated_at.isoformat() if p.updated_at else None, + } + + +def model_to_dict(m: AIModel) -> dict: + return { + "id": m.id, + "provider_id": m.provider_id, + "name": m.name, + "display_name": getattr(m, "display_name", None) or m.name, + "api_key": m.api_key, + "endpoint": m.endpoint, + "capabilities": m.capabilities, + "timeout": m.timeout, + "status": m.status, + "price_per_million_tokens": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, + "is_enabled": m.is_enabled, + "is_default": bool(m.is_default), + "total_calls": m.total_calls or 0, + "total_tokens": m.total_tokens or 0, + "created_at": m.created_at.isoformat() if m.created_at else None, + "updated_at": m.updated_at.isoformat() if m.updated_at else None, + } + + +# ============================================================ +# 协议列表 +# ============================================================ + +@router.get("/protocols") +async def get_protocols( + admin_user=Depends(get_current_admin), +): + """获取支持的协议列表""" + return PROTOCOLS + + +@router.get("/capabilities") +async def get_capabilities( + admin_user=Depends(get_current_admin), +): + """获取平台支持的全部模型能力类型(含标签、测试类型、配色、描述)""" + return [ + {**c, "color": CAPABILITY_COLORS.get(c["id"], "#6B7280")} + for c in CAPABILITY_TYPES + ] + + +# ============================================================ +# 模型供应商管理 +# ============================================================ + +@router.get("/providers") +async def get_providers( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取模型供应商列表""" + providers = db.query(ModelProvider).order_by( + ModelProvider.created_at.desc() + ).all() + return [provider_to_dict(p) for p in providers] + + +@router.post("/providers") +async def create_provider( + name: str = Body(...), + protocol: str = Body(...), + api_key: str = Body(default=""), + api_base_url: str = Body(default=None), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """添加模型供应商""" + # 验证协议 + valid_protocols = [p["id"] for p in PROTOCOLS] + if protocol not in valid_protocols: + raise HTTPException(status_code=400, detail=f"无效的协议类型,支持: {', '.join(valid_protocols)}") + + provider = ModelProvider( + id=str(uuid.uuid4()), + name=name, + protocol=protocol, + api_key=api_key, + api_base_url=api_base_url, + ) + db.add(provider) + db.commit() + db.refresh(provider) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_provider", + target_type="model_provider", + target_id=provider.id, + detail=f"添加模型供应商 {name} (协议: {protocol})", + ) + + return {"message": "供应商已创建", "id": provider.id, "provider": provider_to_dict(provider)} + + +@router.put("/providers/{id}") +async def update_provider( + id: str, + name: str = Body(default=None), + protocol: str = Body(default=None), + api_key: str = Body(default=None), + api_base_url: str = Body(default=None), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新模型供应商""" + provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + + if name is not None: + provider.name = name + if protocol is not None: + valid_protocols = [p["id"] for p in PROTOCOLS] + if protocol not in valid_protocols: + raise HTTPException(status_code=400, detail=f"无效的协议类型") + provider.protocol = protocol + if api_key is not None: + provider.api_key = api_key + if api_base_url is not None: + provider.api_base_url = api_base_url + provider.updated_at = datetime.now() + db.commit() + db.refresh(provider) + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_provider", + target_type="model_provider", + target_id=id, + detail=f"更新模型供应商 {provider.name}", + ) + + return {"message": "供应商已更新", "provider": provider_to_dict(provider)} + + +@router.delete("/providers/{id}") +async def delete_provider( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除模型供应商""" + provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + + # 检查是否有模型关联 + model_count = db.query(func.count(AIModel.id)).filter( + AIModel.provider_id == id + ).scalar() + if model_count > 0: + raise HTTPException(status_code=400, detail=f"该供应商下有 {model_count} 个模型,请先删除模型") + + db.delete(provider) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_provider", + target_type="model_provider", + target_id=id, + detail=f"删除模型供应商 {provider.name}", + ) + + return {"message": "供应商已删除"} + + +@router.post("/providers/{id}/fetch-models") +async def fetch_models( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取供应商可用模型列表(优先从真实API获取)""" + provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + + logger = logging.getLogger(__name__) + logger.info(f"Fetching models for provider {provider.name} (protocol: {provider.protocol})") + + api_base = (provider.api_base_url or "").rstrip("/") + api_key = provider.api_key or "" + + # 尝试从真实API获取模型列表(只要有 api_base 就尝试调用,api_key 可选) + if api_base and provider.protocol in FETCHABLE_PROTOCOLS: + try: + async with httpx.AsyncClient(timeout=15) as client: + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + resp = await client.get(f"{api_base}/v1/models", headers=headers) + if resp.status_code == 200: + data = resp.json() + models_list = data.get("data", []) + if models_list: + result = [] + for m in models_list: + mid = m.get("id", "") + if not mid: + continue + result.append({ + "id": mid, + "name": mid, + "capabilities": ["text"], + "pricing": "paid", + }) + logger.info(f"Successfully fetched {len(result)} models from provider API") + return {"models": result, "source": "api"} + else: + logger.warning(f"Provider API returned empty model list") + return {"models": [], "source": "api", "message": "API返回的模型列表为空"} + elif resp.status_code == 401 or resp.status_code == 403: + logger.warning(f"Provider API returned {resp.status_code}: auth error") + return {"models": [], "source": "error", "message": "API认证失败,请检查API Key是否正确"} + else: + logger.warning(f"Provider API returned status {resp.status_code}: {resp.text[:200]}") + return {"models": [], "source": "error", "message": f"API请求失败(HTTP {resp.status_code}),请检查API地址和密钥"} + except httpx.TimeoutException: + logger.warning(f"Timeout fetching models from provider API: {api_base}") + return {"models": [], "source": "error", "message": "请求API超时,请检查API地址是否正确"} + except Exception as e: + logger.warning(f"Failed to fetch models from provider API: {e}") + return {"models": [], "source": "error", "message": f"请求API失败: {str(e)}"} + + # 回退:返回固定模型列表(供无 API 的离线环境使用) + logger.info(f"Using fallback model list for provider {provider.name}") + return {"models": FETCHABLE_MODELS, "source": "fallback"} + + +class AddModelsRequest(BaseModel): + model_ids: List[str] + + +@router.post("/providers/{id}/add-models") +async def add_models( + id: str, + req: AddModelsRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """批量添加模型到供应商""" + provider = db.query(ModelProvider).filter(ModelProvider.id == id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + + # 找到协议对应的 base_path + protocol_info = next((p for p in PROTOCOLS if p["id"] == provider.protocol), None) + base_path = protocol_info["base_path"] if protocol_info else "/v1" + + added = [] + for model_id in req.model_ids: + # 在 FETCHABLE_MODELS 中查找额外信息 + model_info = next((m for m in FETCHABLE_MODELS if m["id"] == model_id), None) + + # 使用 model_id 作为模型名称 + model_name = model_id # API ID + display_name_val = model_info["name"] if model_info else model_id + + # 检查是否已存在同名模型 + existing = db.query(AIModel).filter( + AIModel.provider_id == id, + AIModel.name == model_name + ).first() + if existing: + continue + + # 自动生成 endpoint + if provider.api_base_url: + endpoint = f"{provider.api_base_url.rstrip('/')}{base_path}/chat/completions" + else: + endpoint = "" + + model = AIModel( + id=str(uuid.uuid4()), + provider_id=id, + name=model_name, + display_name=display_name_val, + api_key=provider.api_key or "", + endpoint=endpoint, + capabilities=model_info.get("capabilities") if model_info else ["text"], + timeout=60, + status="free" if (model_info and model_info.get("pricing") == "free") else "paid", + price_per_million_tokens=0, + is_enabled=True, + ) + db.add(model) + added.append(model_name) + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="add_models", + target_type="model_provider", + target_id=id, + detail=f"为供应商 {provider.name} 批量添加 {len(added)} 个模型", + ) + + return {"message": f"成功添加 {len(added)} 个模型", "added": added} + + +# ============================================================ +# 模型管理 +# ============================================================ + +@router.get("/") +async def get_models( + provider_id: str = Query(None), + status: str = Query(None, description="模型状态筛选: free/paid"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取模型列表(管理员)""" + query = db.query(AIModel) + + if provider_id: + query = query.filter(AIModel.provider_id == provider_id) + if status: + query = query.filter(AIModel.status == status) + + total = query.count() + models = query.order_by(AIModel.created_at.desc()).offset( + (page - 1) * page_size + ).limit(page_size).all() + + return { + "total": total, + "page": page, + "page_size": page_size, + "items": [model_to_dict(m) for m in models], + } + + +@router.post("/") +async def create_model( + data: ModelCreateRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """添加模型""" + # 验证供应商存在 + provider = db.query(ModelProvider).filter(ModelProvider.id == data.provider_id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + + model = AIModel( + id=str(uuid.uuid4()), + provider_id=data.provider_id, + name=data.name, + display_name=data.display_name, + api_key=data.api_key, + endpoint=data.endpoint, + capabilities=data.capabilities, + timeout=data.timeout, + status=data.status, + price_per_million_tokens=data.price_per_million_tokens, + is_enabled=data.is_enabled, + ) + db.add(model) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_model", + target_type="model", + target_id=model.id, + detail=f"添加模型 {name}", + ) + + return {"message": "模型已创建", "id": model.id} + + +@router.put("/{id}") +async def update_model( + id: str, + data: ModelUpdateRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新模型""" + model = db.query(AIModel).filter(AIModel.id == id).first() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + + if data.provider_id is not None: + provider = db.query(ModelProvider).filter(ModelProvider.id == data.provider_id).first() + if not provider: + raise HTTPException(status_code=404, detail="供应商不存在") + model.provider_id = data.provider_id + if data.name is not None: + model.name = data.name + if data.display_name is not None: + model.display_name = data.display_name + if data.api_key is not None: + model.api_key = data.api_key + if data.endpoint is not None: + model.endpoint = data.endpoint + if data.capabilities is not None: + model.capabilities = data.capabilities + if data.timeout is not None: + model.timeout = data.timeout + if data.status is not None: + model.status = data.status + if data.price_per_million_tokens is not None: + model.price_per_million_tokens = data.price_per_million_tokens + if data.is_enabled is not None: + model.is_enabled = data.is_enabled + model.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_model", + target_type="model", + target_id=id, + detail=f"更新模型 {model.name}", + ) + + return {"message": "模型已更新"} + + +@router.delete("/{id}") +async def delete_model( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除模型""" + model = db.query(AIModel).filter(AIModel.id == id).first() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + + model_name = model.name + db.delete(model) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_model", + target_type="model", + target_id=id, + detail=f"删除模型 {model_name}", + ) + + return {"message": "模型已删除"} + + +@router.put("/{id}/toggle") +async def toggle_model( + id: str, + is_enabled: bool = Query(...), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """开关模型状态""" + model = db.query(AIModel).filter(AIModel.id == id).first() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + + model.is_enabled = is_enabled + model.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="toggle_model", + target_type="model", + target_id=id, + detail=f"将模型 {model.name} {'启用' if is_enabled else '禁用'}", + ) + + return {"message": f"模型已{'启用' if is_enabled else '禁用'}"} + + +@router.put("/{id}/set-default") +async def set_default_model( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """设为全局默认模型(取消其他模型的默认状态)""" + model = db.query(AIModel).filter(AIModel.id == id).first() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + + # 取消所有模型的默认状态 + db.query(AIModel).filter(AIModel.is_default == True).update({"is_default": False}) + # 设置当前模型为默认 + model.is_default = True + model.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="set_default_model", + target_type="model", + target_id=id, + detail=f"设置模型 {model.name} 为全局默认", + ) + + return {"message": f"已将 {model.name} 设为全局默认模型"} + + +@router.get("/stats") +async def get_model_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取模型调用统计""" + total_calls = db.query(func.sum(AIModel.total_calls)).scalar() or 0 + total_tokens = db.query(func.sum(AIModel.total_tokens)).scalar() or 0 + + return { + "total_calls": int(total_calls), + "total_tokens": int(total_tokens), + } + + +# ============================================================ +# 按能力指定默认模型(供技能 / 应用 / IM Bot 自动匹配) +# ============================================================ + +@router.get("/capability-defaults") +async def get_capability_defaults_api( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取每个能力当前配置的默认模型,以及可选模型列表""" + defaults = get_capability_defaults(db) + models = db.query(AIModel).filter(AIModel.is_enabled == True).all() + model_list = [{ + "id": m.id, + "name": m.name, + "display_name": getattr(m, "display_name", None) or m.name, + "provider_name": ( + db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first().name + if m.provider_id else "" + ), + "capabilities": m.capabilities or [], + } for m in models] + return { + "capabilities": [ + {**c, "color": CAPABILITY_COLORS.get(c["id"], "#6B7280")} + for c in CAPABILITY_TYPES + ], + "models": model_list, + "defaults": defaults, + } + + +class CapabilityDefaultRequest(BaseModel): + capability: str + model_id: Optional[str] = None + + +@router.put("/capability-defaults") +async def set_capability_default_api( + data: CapabilityDefaultRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """为某个能力设置 / 清除默认模型""" + valid = {c["id"] for c in CAPABILITY_TYPES} + if data.capability not in valid: + raise HTTPException(status_code=400, detail=f"无效的能力类型: {data.capability}") + if data.model_id: + model = db.query(AIModel).filter(AIModel.id == data.model_id).first() + if not model: + raise HTTPException(status_code=404, detail="模型不存在") + updated = set_capability_default(db, data.capability, data.model_id) + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="set_capability_default", + target_type="model_capability", + target_id=data.capability, + detail=f"设置能力 {data.capability} 的默认模型为 {data.model_id or '(清除)'}", + ) + return {"message": "已更新能力默认模型", "defaults": updated} + + +# ============================================================ +# 模型连接测试 API (Phase 2) +# ============================================================ + +def _detect_model_type(capabilities): + """根据 capabilities 判断模型类型,返回测试类型。 + 优先级(从高到低):image > video > audio > asr > ocr > embedding > rerank > vision > thinking > tool > text + """ + if not capabilities: + return "text" + caps = [c.lower() for c in capabilities] + if "image" in caps: + return "image" + if "video" in caps: + return "video" + if "audio" in caps: + return "audio" + if "asr" in caps or "speech" in caps: + return "asr" + if "ocr" in caps: + return "ocr" + if "embedding" in caps: + return "embedding" + if "rerank" in caps: + return "rerank" + if "vision" in caps: + return "vision" + if "thinking" in caps: + return "thinking" + if "tool" in caps: + return "tool" + return "text" + + +def _build_api_base(provider, ai_model): + """构造 API base URL 和 headers""" + api_key = ai_model.api_key or (provider.api_key if provider else None) + base_url = "" + if provider and provider.api_base_url: + base_url = provider.api_base_url.rstrip("/") + elif ai_model.endpoint: + # 从 endpoint 反推 base + endpoint = ai_model.endpoint + for suffix in ["/v1/chat/completions", "/v1/images/generations", "/v1/audio/speech", "/v1/audio/transcriptions"]: + if endpoint.endswith(suffix): + base_url = endpoint[:-len(suffix)] + break + if not base_url: + base_url = endpoint.rsplit("/v1", 1)[0] if "/v1" in endpoint else "" + return api_key, base_url + + +@router.post("/{model_id}/test-connection") +async def test_model_connection( + model_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """ + 测试指定模型的连接是否正常 + 支持多模态: text/image/audio/vision 自动检测模型类型并使用对应API测试 + """ + import time + + logger = logging.getLogger(__name__) + logger.info(f"Testing connection for model_id: {model_id}") + + ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() + if not ai_model: + raise HTTPException(status_code=404, detail="模型不存在") + + provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() + if not provider: + raise HTTPException(status_code=404, detail="模型供应商不存在") + + api_key, base_url = _build_api_base(provider, ai_model) + + if not api_key: + return { + "success": False, + "message": "未配置 API Key,请在模型或供应商设置中添加", + "latency_ms": None, + "model_type": "unknown", + } + if not base_url: + return { + "success": False, + "message": "未配置 API 地址,请在模型或供应商设置中添加", + "latency_ms": None, + "model_type": "unknown", + } + + model_type = _detect_model_type(ai_model.capabilities) + timeout = ai_model.timeout or 60 + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + start = time.time() + + try: + async with httpx.AsyncClient(timeout=timeout) as client: + + # ============================================================ + # 文本模型 / 视觉模型 / OCR模型 / 思考模型 → chat/completions + # ============================================================ + if model_type in ("text", "vision", "ocr", "thinking", "tool"): + chat_url = f"{base_url}/v1/chat/completions" + payload = { + "model": ai_model.name, + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 5, + } + # 视觉 / OCR 模型附带图片测试(用 base64 data-URI,避免远端 URL 不可达) + if model_type in ("vision", "ocr"): + _test_png_b64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAjUlEQVR4nO3PoRHAMADDwMzS/XdsJzAoUnQnYwH/eceesdv6c9uhAPShAPShAPSh3wDL0dUHoPsAdB+A7v0Ay9HVB6D7AHQfgO79AMvR1Qeg+wB0H4Du/QDL0dUHoPsAdB+A7v0Ay9HVB6D7AHQfgO79AMvR1Qeg+wB0H4Du/QDL0dUHoPsAdB+A7vWAD38HGZb2HO9/AAAAAElFTkSuQmCC" + ) + payload["messages"] = [{ + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_test_png_b64}"}}, + ], + }] + payload["max_tokens"] = 64 + + resp = await client.post(chat_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + data = resp.json() + model_used = data.get("model", ai_model.name) + return { + "success": True, + "message": f"连接成功 ({model_type}),模型: {model_used}", + "latency_ms": latency, + "model_type": model_type, + "model_in_response": model_used, + } + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 图像生成模型 → images/generations + # ============================================================ + elif model_type == "image": + image_url = f"{base_url}/v1/images/generations" + payload = { + "model": ai_model.name, + "prompt": "a red circle", + "n": 1, + "size": "256x256", + } + resp = await client.post(image_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + data = resp.json() + img_count = len(data.get("data", [])) + return { + "success": True, + "message": f"图像生成成功 ({model_type}),返回 {img_count} 张图片", + "latency_ms": latency, + "model_type": model_type, + } + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 音频模型 → audio/speech (TTS) + # ============================================================ + elif model_type == "audio": + audio_url = f"{base_url}/v1/audio/speech" + payload = { + "model": ai_model.name, + "input": "hello", + "voice": "alloy", + } + resp = await client.post(audio_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + content_type = resp.headers.get("content-type", "") + has_audio = "audio" in content_type or len(resp.content) > 100 + return { + "success": True, + "message": f"音频服务连接成功 ({model_type}),响应类型: {content_type}", + "latency_ms": latency, + "model_type": model_type, + } + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 嵌入向量模型 → embeddings + # ============================================================ + elif model_type == "embedding": + embed_url = f"{base_url}/v1/embeddings" + payload = { + "model": ai_model.name, + "input": "hello world", + } + resp = await client.post(embed_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + data = resp.json() + emb = data.get("data") or [] + dim = len(emb[0].get("embedding", [])) if emb else 0 + return { + "success": True, + "message": f"嵌入服务连接成功 ({model_type}),向量维度: {dim}", + "latency_ms": latency, + "model_type": model_type, + } + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 重排序模型 → rerank + # ============================================================ + elif model_type == "rerank": + rerank_url = f"{base_url}/v1/rerank" + payload = { + "model": ai_model.name, + "query": "什么是人工智能", + "documents": ["人工智能是计算机科学的一个分支", "今天天气晴朗"], + } + resp = await client.post(rerank_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + data = resp.json() + results = data.get("results") or data.get("data") or [] + return { + "success": True, + "message": f"重排序服务连接成功 ({model_type}),返回 {len(results)} 条结果", + "latency_ms": latency, + "model_type": model_type, + } + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 语音识别模型 → audio/transcriptions (ASR) + # ============================================================ + elif model_type == "asr": + asr_url = f"{base_url}/v1/audio/transcriptions" + # 一段极短的静音 WAV(base64),仅用于探活端点是否可达 + silent_wav_b64 = ( + "UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQAAAAA=" + ) + files = { + "file": ("test.wav", silent_wav_b64, "audio/wav"), + "model": (None, ai_model.name), + } + asr_headers = {"Authorization": headers["Authorization"]} + try: + resp = await client.post(asr_url, files=files, headers=asr_headers) + except Exception: + # 某些实现要求 JSON 而非表单,回退尝试 JSON + resp = await client.post( + asr_url, + json={"model": ai_model.name, "audio": silent_wav_b64}, + headers=headers, + ) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + return { + "success": True, + "message": f"语音识别服务连接成功 ({model_type})", + "latency_ms": latency, + "model_type": model_type, + } + elif resp.status_code in (400, 422): + # 端点可达但音频数据无效(探活目的达成) + return { + "success": True, + "message": f"语音识别端点可达 ({model_type}),返回 {resp.status_code}(需真实音频)", + "latency_ms": latency, + "model_type": model_type, + } + elif resp.status_code in (404, 405): + # OpenAI transcriptions 路径不存在 → 可能是 MiMo 风格 + # 的 /v1/chat/completions + input_audio(如 mimo-v2.5-asr) + chat_url = f"{base_url}/v1/chat/completions" + asr_payload = { + "model": ai_model.name, + "messages": [{ + "role": "user", + "content": [{ + "type": "input_audio", + "input_audio": { + "data": f"data:audio/wav;base64,{silent_wav_b64}" + }, + }], + }], + "asr_options": {"language": "auto"}, + "max_tokens": 5, + } + try: + resp2 = await client.post(chat_url, json=asr_payload, headers=headers) + except Exception: + resp2 = resp + latency2 = int((time.time() - start) * 1000) + if resp2.status_code == 200: + return { + "success": True, + "message": f"语音识别服务连接成功 ({model_type})", + "latency_ms": latency2, + "model_type": model_type, + } + elif resp2.status_code in (400, 422): + return { + "success": True, + "message": f"语音识别端点可达 ({model_type}),返回 {resp2.status_code}(需真实音频)", + "latency_ms": latency2, + "model_type": model_type, + } + else: + return _build_error_response(resp, latency, model_type) + else: + return _build_error_response(resp, latency, model_type) + + # ============================================================ + # 视频模型 → 尝试 chat/completions (可能不支持,回退) + # ============================================================ + elif model_type == "video": + chat_url = f"{base_url}/v1/chat/completions" + payload = { + "model": ai_model.name, + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 5, + } + resp = await client.post(chat_url, json=payload, headers=headers) + latency = int((time.time() - start) * 1000) + + if resp.status_code == 200: + data = resp.json() + model_used = data.get("model", ai_model.name) + return { + "success": True, + "message": f"连接成功 ({model_type}),模型: {model_used}", + "latency_ms": latency, + "model_type": model_type, + "model_in_response": model_used, + } + else: + return _build_error_response(resp, latency, model_type) + + else: + return { + "success": False, + "message": f"未知模型类型: {model_type}", + "latency_ms": int((time.time() - start) * 1000), + "model_type": model_type, + } + + except httpx.TimeoutException: + latency = int((time.time() - start) * 1000) + return { + "success": False, + "message": f"连接超时({timeout}s):请检查 API 地址是否正确,或服务是否可访问", + "latency_ms": latency, + "model_type": model_type, + } + except Exception as e: + latency = int((time.time() - start) * 1000) + logger.error(f"Model connection test failed: {e}") + return { + "success": False, + "message": f"连接失败: {str(e)}", + "latency_ms": latency, + "model_type": model_type, + } + + +def _build_error_response(resp, latency, model_type): + """构造错误响应""" + if resp.status_code == 401: + msg = "认证失败:API Key 无效或已过期" + elif resp.status_code == 403: + msg = "访问被拒绝:API Key 权限不足" + elif resp.status_code == 429: + msg = "触发限流:请稍后重试" + elif resp.status_code == 404: + msg = f"API 地址不存在 (404):请检查 endpoint 配置是否正确" + else: + try: + err_data = resp.json() + err_msg = err_data.get("error", {}).get("message", resp.text[:200]) + except Exception: + err_msg = resp.text[:200] + msg = f"API 错误 (HTTP {resp.status_code}): {err_msg}" + return { + "success": False, + "message": msg, + "latency_ms": latency, + "model_type": model_type, + } + + +@router.post("/test-all") +async def test_all_models( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """批量测试所有已启用模型的连接状态""" + import asyncio + + models = db.query(AIModel).filter(AIModel.is_enabled == True).all() + results = [] + + for m in models: + provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() + if not provider: + results.append({ + "model_id": m.id, + "model_name": m.name, + "display_name": m.display_name or m.name, + "success": False, + "message": "供应商不存在", + "latency_ms": None, + "model_type": "unknown", + }) + continue + + # 复用 test_model_connection 逻辑 + try: + result = await test_model_connection(m.id, admin_user, db) + if isinstance(result, dict): + result["model_id"] = m.id + result["model_name"] = m.name + result["display_name"] = m.display_name or m.name + results.append(result) + else: + results.append({ + "model_id": m.id, + "model_name": m.name, + "display_name": m.display_name or m.name, + "success": False, + "message": "测试返回异常", + "latency_ms": None, + "model_type": "unknown", + }) + except Exception as e: + results.append({ + "model_id": m.id, + "model_name": m.name, + "display_name": m.display_name or m.name, + "success": False, + "message": f"测试异常: {str(e)}", + "latency_ms": None, + "model_type": "unknown", + }) + + success_count = sum(1 for r in results if r.get("success")) + return { + "total": len(results), + "success": success_count, + "failed": len(results) - success_count, + "results": results, + } + + + timeout: int = 60 + status: str = "paid" + price_per_million_tokens: float = 0.0 + is_enabled: bool = True + + +class UpdateModelRequest(BaseModel): + name: Optional[str] = None + api_key: Optional[str] = None + endpoint: Optional[str] = None + capabilities: Optional[List[str]] = None + timeout: Optional[int] = None + status: Optional[str] = None + price_per_million_tokens: Optional[float] = None + is_enabled: Optional[bool] = None + + +from typing import Optional + diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 236217d..5977c40 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -27,6 +27,7 @@ from ..models import ( ) from ..auth import get_current_user from ..middleware.log_service import LogService +from ..capability_registry import resolve_auto_model router = APIRouter() @@ -981,9 +982,14 @@ async def send_message( ai_model = None skills = [] # 初始化避免UnboundLocalError agents = [] # 初始化避免UnboundLocalError - if model_id: + # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) + if model_id and model_id != "auto": ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() + if not ai_model: + # Auto / 未指定 → 使用平台默认对话模型(文本能力默认) + ai_model = resolve_auto_model("text", db) + if ai_model: # ====== 图像/视频生成模型分支 ====== caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] if ai_model and ("image" in caps or "video" in caps): @@ -1137,7 +1143,7 @@ async def send_message( else: ai_content = "请选择一个已配置的AI模型进行对话。" else: - ai_content = "请先在聊天设置中选择一个AI模型。" + ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" ai_token_count = len(ai_content) // 2 ai_msg = Message( @@ -1146,7 +1152,7 @@ async def send_message( role="assistant", content=ai_content, token_used=ai_token_count, - model_id=req.model_id or conv.model_id, + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), skill_ids=req.skill_ids if req.skill_ids else None, agent_ids=req.agent_ids if req.agent_ids else None, knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, @@ -1168,8 +1174,8 @@ async def send_message( token_usage = TokenUsageLog( id=gen_uuid(), user_id=current_user.id, - model_id=req.model_id or conv.model_id, - model_name=ai_model.name if model_id and ai_model else None, + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + model_name=ai_model.name if ai_model else None, conversation_id=conv_id, prompt_tokens=prompt_tokens or len(req.content) // 2, completion_tokens=completion_tokens or ai_token_count, @@ -1454,3 +1460,4 @@ def get_models(db: Session = Depends(get_db)): }) result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"])) return {"code": 0, "data": result, "message": "ok"} + diff --git a/backend/python/app/capability_registry.py b/backend/python/app/capability_registry.py new file mode 100644 index 0000000..a0ead83 --- /dev/null +++ b/backend/python/app/capability_registry.py @@ -0,0 +1,97 @@ +""" +能力(capability)注册中心 + +集中定义平台支持的全部模型能力,以及"每个能力对应的默认模型"的读写与解析。 +- CAPABILITY_TYPES: 全部能力(id / 中文标签 / 测试类型 / 描述) +- get_capability_defaults / set_capability_default: 读写 site_settings 中的 model_capability_defaults +- resolve_auto_model: 根据能力解析平台默认模型(供聊天 / 技能 / 应用 / IM Bot 自动匹配) +""" +from typing import Optional +from sqlalchemy.orm import Session +from .models import AIModel, SiteSetting +import json + + +# ============================================================ +# 平台支持的全部能力类型 +# test_type 用于模型连接测试时选择对应的 API 路径 +# ============================================================ +CAPABILITY_TYPES = [ + {"id": "text", "label": "文本对话", "test_type": "text", "desc": "文本生成与多轮对话"}, + {"id": "thinking", "label": "推理思考", "test_type": "text", "desc": "深度推理 / 思维链"}, + {"id": "vision", "label": "视觉理解", "test_type": "vision", "desc": "图片 / 多模态理解"}, + {"id": "tool", "label": "工具调用", "test_type": "text", "desc": "Function Calling 函数调用"}, + {"id": "image", "label": "图像生成", "test_type": "image", "desc": "文生图 / 图像生成"}, + {"id": "audio", "label": "语音合成", "test_type": "audio", "desc": "TTS 文字转语音"}, + {"id": "asr", "label": "语音识别", "test_type": "asr", "desc": "ASR 语音转文字"}, + {"id": "ocr", "label": "OCR识别", "test_type": "ocr", "desc": "图片文字识别提取"}, + {"id": "embedding", "label": "嵌入向量", "test_type": "embedding", "desc": "文本向量化 / 嵌入"}, + {"id": "rerank", "label": "重排序", "test_type": "rerank", "desc": "语义重排 / Rerank"}, + {"id": "video", "label": "视频生成", "test_type": "video", "desc": "文生视频 / 视频生成"}, +] + +# 文本类能力(对话 / 视觉 / 推理 / 工具 / OCR)在缺少专属默认模型时,回退到全局默认模型 +TEXT_FAMILY = {"text", "thinking", "vision", "tool", "ocr"} + +# 仅用于前端展示的固定配色 +CAPABILITY_COLORS = { + "text": "#5B5FED", "thinking": "#8B5CF6", "vision": "#219B5E", "tool": "#3B82F6", + "image": "#D9383A", "audio": "#E8922C", "asr": "#0EA5E9", "ocr": "#F59E0B", + "embedding": "#10B981", "rerank": "#EC4899", "video": "#EF4444", +} + +SETTING_KEY = "model_capability_defaults" + + +def get_capability_defaults(db: Session) -> dict: + """读取 {capability: model_id} 映射""" + row = db.query(SiteSetting).filter(SiteSetting.key == SETTING_KEY).first() + if not row or not row.value: + return {} + try: + data = json.loads(row.value) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def set_capability_default(db: Session, capability: str, model_id: Optional[str]) -> dict: + """设置某个能力的默认模型(model_id 为空则清除)""" + data = get_capability_defaults(db) + if model_id: + data[capability] = model_id + else: + data.pop(capability, None) + row = db.query(SiteSetting).filter(SiteSetting.key == SETTING_KEY).first() + if not row: + from uuid import uuid4 + row = SiteSetting(id=str(uuid4()), key=SETTING_KEY, value="{}") + db.add(row) + row.value = json.dumps(data, ensure_ascii=False) + db.commit() + return data + + +def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]: + """ + 根据能力解析平台默认模型: + 1. 优先使用后台"按能力默认模型"中配置的值 + 2. 文本类能力若未单独配置,回退到全局默认模型(is_default) + 3. 其余能力(embedding/rerank/asr 等)若未配置则返回 None + """ + data = get_capability_defaults(db) + mid = data.get(capability) + if mid: + m = db.query(AIModel).filter( + AIModel.id == mid, AIModel.is_enabled == True + ).first() + if m: + return m + # 文本类能力回退到全局默认模型 + if capability in TEXT_FAMILY: + m = db.query(AIModel).filter( + AIModel.is_default == True, AIModel.is_enabled == True + ).first() + if m: + return m + return None diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e104304..b46b649 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -315,6 +315,11 @@ export const adminAPI = { updateAdminModel: (id: string, data: any) => put(`/admin/models/${id}`, data), deleteAdminModel: (id: string) => del(`/admin/models/${id}`), getModelStats: () => get('/admin/models/stats'), + // 能力类型 & 按能力默认模型 + getCapabilities: () => get('/admin/models/capabilities'), + getCapabilityDefaults: () => get('/admin/models/capability-defaults'), + setCapabilityDefault: (capability: string, modelId: string | null) => + put('/admin/models/capability-defaults', { capability, model_id: modelId }), // 网站设置 getSiteSettings: () => get('/admin/settings/site'), updateSiteSettings: (data: any) => put('/admin/settings/site', data), diff --git a/frontend/src/pages/admin/Models.tsx b/frontend/src/pages/admin/Models.tsx index 2d23542..cb6cd71 100644 --- a/frontend/src/pages/admin/Models.tsx +++ b/frontend/src/pages/admin/Models.tsx @@ -15,13 +15,15 @@ const formatNumber = (n: number) => { }; const capabilityColors: Record = { - text: '#5B5FED', vision: '#219B5E', audio: '#E8922C', - video: '#D9383A', image: '#D9383A', thinking: '#8B5CF6', tool: '#3B82F6', + text: '#5B5FED', thinking: '#8B5CF6', vision: '#219B5E', tool: '#3B82F6', + image: '#D9383A', audio: '#E8922C', asr: '#0EA5E9', ocr: '#F59E0B', + embedding: '#10B981', rerank: '#EC4899', video: '#EF4444', }; const capabilityLabels: Record = { - text: '文本', vision: '视觉', audio: '音频', - video: '视频', image: '图像', thinking: '思考', tool: '工具', + text: '文本对话', thinking: '推理思考', vision: '视觉理解', tool: '工具调用', + image: '图像生成', audio: '语音合成', asr: '语音识别', ocr: 'OCR识别', + embedding: '嵌入向量', rerank: '重排序', video: '视频生成', }; interface TestResult { @@ -101,6 +103,11 @@ const Models: React.FC = () => { const [showModelEditor, setShowModelEditor] = useState(false); const [editingModel, setEditingModel] = useState(null); const [modelSaving, setModelSaving] = useState(false); + // 按能力默认模型 + const [capDefaults, setCapDefaults] = useState>({}); + const [capList, setCapList] = useState([]); + const [capModels, setCapModels] = useState([]); + const [savingCap, setSavingCap] = useState(null); const [providerForm, setProviderForm] = useState({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '', @@ -109,10 +116,11 @@ const Models: React.FC = () => { const loadData = useCallback(async () => { try { setLoading(true); - const [provRes, modelsRes, protoRes] = await Promise.all([ + const [provRes, modelsRes, protoRes, capRes] = await Promise.all([ adminAPI.getProviders(), adminAPI.getAdminModels({ page_size: 100 }), adminAPI.getProtocols(), + adminAPI.getCapabilityDefaults().catch(() => null), ]); setProviders(Array.isArray(provRes) ? provRes : (provRes.data || [])); setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || [])); @@ -130,6 +138,12 @@ const Models: React.FC = () => { totalTokens: m.totalTokens ?? m.total_tokens ?? 0, })); setModels(mappedModels); + if (capRes) { + const cd = capRes.data || capRes; + setCapList(cd.capabilities || []); + setCapModels(cd.models || []); + setCapDefaults(cd.defaults || {}); + } } catch (err: any) { setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err))); } finally { @@ -137,6 +151,24 @@ const Models: React.FC = () => { } }, []); + // 设置某个能力的默认模型 + const handleSetCapDefault = async (capability: string, modelId: string) => { + try { + setSavingCap(capability); + await adminAPI.setCapabilityDefault(capability, modelId || null); + setCapDefaults((prev) => { + const next = { ...prev }; + if (modelId) next[capability] = modelId; + else delete next[capability]; + return next; + }); + } catch (err: any) { + setError('保存失败:' + (err.message || err.detail || JSON.stringify(err))); + } finally { + setSavingCap(null); + } + }; + useEffect(() => { loadData(); }, [loadData]); // Filtered models @@ -193,17 +225,27 @@ const Models: React.FC = () => { if (!caps || caps.length === 0) return 'text'; const lower = caps.map(c => c.toLowerCase()); if (lower.includes('image')) return 'image'; - if (lower.includes('audio')) return 'audio'; if (lower.includes('video')) return 'video'; + if (lower.includes('audio')) return 'audio'; + if (lower.includes('asr') || lower.includes('speech')) return 'asr'; + if (lower.includes('ocr')) return 'ocr'; + if (lower.includes('embedding')) return 'embedding'; + if (lower.includes('rerank')) return 'rerank'; if (lower.includes('vision')) return 'vision'; + if (lower.includes('thinking') || lower.includes('tool')) return 'thinking'; return 'text'; }; const modelTypeLabels: Record = { text: { label: '文本', color: '#5B5FED' }, + thinking: { label: '推理/工具', color: '#8B5CF6' }, image: { label: '图像生成', color: '#D9383A' }, - audio: { label: '音频', color: '#E8922C' }, - video: { label: '视频', color: '#EC4899' }, + audio: { label: '语音合成', color: '#E8922C' }, + asr: { label: '语音识别', color: '#0EA5E9' }, + ocr: { label: 'OCR识别', color: '#F59E0B' }, + embedding: { label: '嵌入向量', color: '#10B981' }, + rerank: { label: '重排序', color: '#EC4899' }, + video: { label: '视频生成', color: '#EF4444' }, vision: { label: '视觉理解', color: '#219B5E' }, }; @@ -379,6 +421,42 @@ const Models: React.FC = () => { {/* Stats */} + {/* 按能力指定默认模型 */} +
+
+ 🎯 +

按能力指定默认模型

+
+

+ Auto 模式下,技能 / 应用 / IM Bot 会根据所需能力自动匹配下方指定的默认模型;文本、推理、视觉、工具、OCR 等文本类能力若未单独指定,将回退到全局默认模型。 +

+
+ {capList.map((cap) => ( +
+
+ + {cap.label} + {cap.id} +
+
{cap.desc}
+ +
+ ))} +
+
+ {error && (
{error} diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx index 4c831c8..e8cf263 100644 --- a/frontend/src/pages/front/ChatPage.tsx +++ b/frontend/src/pages/front/ChatPage.tsx @@ -338,8 +338,9 @@ const ChatPage: React.FC = () => { const data = res.data || res; const models = Array.isArray(data) ? data : (data?.items || []); setChatModels(models); - if (models.length > 0 && !selectedModelId) { - setSelectedModelId(models[0].id); + // 全平台默认使用 Auto(按能力自动匹配平台默认模型) + if (!selectedModelId) { + setSelectedModelId('auto'); } }).catch(() => { setChatModels([]); @@ -463,8 +464,11 @@ const ChatPage: React.FC = () => { return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))]; }, [conversations, searchQuery]); - // Selected model info - const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' }; + // Selected model info(Auto 为按能力自动匹配平台默认模型) + const AUTO_MODEL = { id: 'auto', name: 'Auto 自动', display_name: 'Auto 自动', status: 'free', provider_name: '按能力匹配', price_per_million_tokens: 0, capabilities: [] as string[] }; + const selectedModel = selectedModelId === 'auto' + ? AUTO_MODEL + : (chatModels.find(m => m.id === selectedModelId) || chatModels[0] || AUTO_MODEL); // Scroll to bottom when messages change useEffect(() => { @@ -792,6 +796,18 @@ const ChatPage: React.FC = () => {
} > + {/* Auto 自动匹配(默认) */} + {chatModels.filter(m => m.is_enabled !== false).map(model => (