feat: 补全模型能力选项(11种) + 按能力指定默认模型 + Auto自动模式
This commit is contained in:
1 parent
176cb35b54
commit
4c372746d5
6 files changed
+523
-81
No files matched your search
@@ -7,9 +7,13 @@ 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 ...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
|
||||
@@ -18,6 +22,36 @@ 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
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协议预设列表
|
||||
# ============================================================
|
||||
@@ -103,6 +137,17 @@ async def get_protocols(
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模型供应商管理
|
||||
# ============================================================
|
||||
@@ -404,37 +449,28 @@ async def get_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,
|
||||
data: ModelCreateRequest,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""添加模型"""
|
||||
# 验证供应商存在
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first()
|
||||
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=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,
|
||||
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()
|
||||
@@ -454,16 +490,7 @@ async def create_model(
|
||||
@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,
|
||||
data: ModelUpdateRequest,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -472,29 +499,29 @@ async def update_model(
|
||||
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 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 = 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.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()
|
||||
|
||||
@@ -610,24 +637,100 @@ async def get_model_stats(
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 按能力指定默认模型(供技能 / 应用 / 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 判断模型类型,返回测试类型"""
|
||||
"""根据 capabilities 判断模型类型,返回测试类型。
|
||||
优先级(从高到低):image > video > audio > asr > ocr > embedding > rerank > vision > thinking > tool > text
|
||||
"""
|
||||
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 "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"
|
||||
|
||||
|
||||
@@ -701,25 +804,28 @@ async def test_model_connection(
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
|
||||
# ============================================================
|
||||
# 文本模型 / 视觉模型 / 思考模型 → chat/completions
|
||||
# 文本模型 / 视觉模型 / OCR模型 / 思考模型 → chat/completions
|
||||
# ============================================================
|
||||
if model_type in ("text", "vision", "thinking", "tool"):
|
||||
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,
|
||||
}
|
||||
# 视觉模型附带图片URL测试
|
||||
if model_type == "vision":
|
||||
# 视觉 / 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 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"}},
|
||||
{"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"] = 10
|
||||
payload["max_tokens"] = 64
|
||||
|
||||
resp = await client.post(chat_url, json=payload, headers=headers)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
@@ -788,6 +894,138 @@ async def test_model_connection(
|
||||
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 (可能不支持,回退)
|
||||
# ============================================================
|
||||
@@ -947,3 +1185,4 @@ class UpdateModelRequest(BaseModel):
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
@@ -315,6 +315,11 @@ export const adminAPI = {
|
||||
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
|
||||
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||
getModelStats: () => get<any>('/admin/models/stats'),
|
||||
// 能力类型 & 按能力默认模型
|
||||
getCapabilities: () => get<any[]>('/admin/models/capabilities'),
|
||||
getCapabilityDefaults: () => get<any>('/admin/models/capability-defaults'),
|
||||
setCapabilityDefault: (capability: string, modelId: string | null) =>
|
||||
put<any>('/admin/models/capability-defaults', { capability, model_id: modelId }),
|
||||
// 网站设置
|
||||
getSiteSettings: () => get<any>('/admin/settings/site'),
|
||||
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
|
||||
|
||||
@@ -15,13 +15,15 @@ const formatNumber = (n: number) => {
|
||||
};
|
||||
|
||||
const capabilityColors: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<EditingModel | null>(null);
|
||||
const [modelSaving, setModelSaving] = useState(false);
|
||||
// 按能力默认模型
|
||||
const [capDefaults, setCapDefaults] = useState<Record<string, string>>({});
|
||||
const [capList, setCapList] = useState<any[]>([]);
|
||||
const [capModels, setCapModels] = useState<any[]>([]);
|
||||
const [savingCap, setSavingCap] = useState<string | null>(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<string, { label: string; color: string }> = {
|
||||
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 */}
|
||||
<ModelStatsPanel models={models} providers={providers} />
|
||||
|
||||
{/* 按能力指定默认模型 */}
|
||||
<div style={{ background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)', padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 18 }}>🎯</span>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>按能力指定默认模型</h3>
|
||||
</div>
|
||||
<p style={{ margin: '4px 0 16px', color: '#6B7280', fontSize: 13 }}>
|
||||
Auto 模式下,技能 / 应用 / IM Bot 会根据所需能力自动匹配下方指定的默认模型;文本、推理、视觉、工具、OCR 等文本类能力若未单独指定,将回退到全局默认模型。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{capList.map((cap) => (
|
||||
<div key={cap.id} style={{ border: '1px solid #E5E7EB', borderRadius: 8, padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: cap.color }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{cap.label}</span>
|
||||
<span style={{ fontSize: 11, color: '#9CA3AF' }}>{cap.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#9CA3AF', minHeight: 16 }}>{cap.desc}</div>
|
||||
<select
|
||||
value={capDefaults[cap.id] || ''}
|
||||
disabled={savingCap === cap.id}
|
||||
onChange={(e) => handleSetCapDefault(cap.id, e.target.value)}
|
||||
style={{ padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' }}
|
||||
>
|
||||
<option value=''>(未指定 · 文本类回退全局默认)</option>
|
||||
{capModels
|
||||
.filter((m) => (m.capabilities || []).includes(cap.id))
|
||||
.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.display_name || m.name}({m.provider_name})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
|
||||
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Auto 自动匹配(默认) */}
|
||||
<button
|
||||
key="auto"
|
||||
className={`dropdown-item ${selectedModelId === 'auto' ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedModelId('auto')}
|
||||
>
|
||||
<div className="model-dropdown-item">
|
||||
<span>⚡ Auto 自动</span>
|
||||
<span className="model-provider-tag">按能力匹配默认模型</span>
|
||||
<span className="model-cap-badge">默认</span>
|
||||
</div>
|
||||
</button>
|
||||
{chatModels.filter(m => m.is_enabled !== false).map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
|
||||
Reference in new issue
Block a user