feat: 补全模型能力选项(11种) + 按能力指定默认模型 + Auto自动模式

This commit is contained in:
qyyfw committed 2026-07-10 20:04:38 +08:00
1 parent 176cb35b54
commit 4c372746d5
6 files changed
+1408 -966

No files matched your search

File diff suppressed because it is too large. Load diff
+12 -5
View File
@@ -27,6 +27,7 @@ from ..models import (
) )
from ..auth import get_current_user from ..auth import get_current_user
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
router = APIRouter() router = APIRouter()
@@ -981,9 +982,14 @@ async def send_message(
ai_model = None ai_model = None
skills = [] # 初始化避免UnboundLocalError skills = [] # 初始化避免UnboundLocalError
agents = [] # 初始化避免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() 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 [] 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): if ai_model and ("image" in caps or "video" in caps):
@@ -1137,7 +1143,7 @@ async def send_message(
else: else:
ai_content = "请选择一个已配置的AI模型进行对话。" ai_content = "请选择一个已配置的AI模型进行对话。"
else: else:
ai_content = "请先在聊天设置中选择一个AI模型。" ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。"
ai_token_count = len(ai_content) // 2 ai_token_count = len(ai_content) // 2
ai_msg = Message( ai_msg = Message(
@@ -1146,7 +1152,7 @@ async def send_message(
role="assistant", role="assistant",
content=ai_content, content=ai_content,
token_used=ai_token_count, 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, skill_ids=req.skill_ids if req.skill_ids else None,
agent_ids=req.agent_ids if req.agent_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, knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None,
@@ -1168,8 +1174,8 @@ async def send_message(
token_usage = TokenUsageLog( token_usage = TokenUsageLog(
id=gen_uuid(), id=gen_uuid(),
user_id=current_user.id, user_id=current_user.id,
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),
model_name=ai_model.name if model_id and ai_model else None, model_name=ai_model.name if ai_model else None,
conversation_id=conv_id, conversation_id=conv_id,
prompt_tokens=prompt_tokens or len(req.content) // 2, prompt_tokens=prompt_tokens or len(req.content) // 2,
completion_tokens=completion_tokens or ai_token_count, 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"])) result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"]))
return {"code": 0, "data": result, "message": "ok"} return {"code": 0, "data": result, "message": "ok"}
+97
View File
@@ -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
+5
View File
@@ -315,6 +315,11 @@ export const adminAPI = {
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data), updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`), deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
getModelStats: () => get<any>('/admin/models/stats'), 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'), getSiteSettings: () => get<any>('/admin/settings/site'),
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data), updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
+86 -8
View File
@@ -15,13 +15,15 @@ const formatNumber = (n: number) => {
}; };
const capabilityColors: Record<string, string> = { const capabilityColors: Record<string, string> = {
text: '#5B5FED', vision: '#219B5E', audio: '#E8922C', text: '#5B5FED', thinking: '#8B5CF6', vision: '#219B5E', tool: '#3B82F6',
video: '#D9383A', image: '#D9383A', thinking: '#8B5CF6', tool: '#3B82F6', image: '#D9383A', audio: '#E8922C', asr: '#0EA5E9', ocr: '#F59E0B',
embedding: '#10B981', rerank: '#EC4899', video: '#EF4444',
}; };
const capabilityLabels: Record<string, string> = { const capabilityLabels: Record<string, string> = {
text: '文本', vision: '视觉', audio: '音频', text: '文本对话', thinking: '推理思考', vision: '视觉理解', tool: '工具调用',
video: '视频', image: '图像', thinking: '思考', tool: '工具', image: '图像生成', audio: '语音合成', asr: '语音识别', ocr: 'OCR识别',
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
}; };
interface TestResult { interface TestResult {
@@ -101,6 +103,11 @@ const Models: React.FC = () => {
const [showModelEditor, setShowModelEditor] = useState(false); const [showModelEditor, setShowModelEditor] = useState(false);
const [editingModel, setEditingModel] = useState<EditingModel | null>(null); const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
const [modelSaving, setModelSaving] = useState(false); 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({ const [providerForm, setProviderForm] = useState({
name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '', name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '',
@@ -109,10 +116,11 @@ const Models: React.FC = () => {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const [provRes, modelsRes, protoRes] = await Promise.all([ const [provRes, modelsRes, protoRes, capRes] = await Promise.all([
adminAPI.getProviders(), adminAPI.getProviders(),
adminAPI.getAdminModels({ page_size: 100 }), adminAPI.getAdminModels({ page_size: 100 }),
adminAPI.getProtocols(), adminAPI.getProtocols(),
adminAPI.getCapabilityDefaults().catch(() => null),
]); ]);
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || [])); setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || [])); setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
@@ -130,6 +138,12 @@ const Models: React.FC = () => {
totalTokens: m.totalTokens ?? m.total_tokens ?? 0, totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
})); }));
setModels(mappedModels); setModels(mappedModels);
if (capRes) {
const cd = capRes.data || capRes;
setCapList(cd.capabilities || []);
setCapModels(cd.models || []);
setCapDefaults(cd.defaults || {});
}
} catch (err: any) { } catch (err: any) {
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err))); setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
} finally { } 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]); useEffect(() => { loadData(); }, [loadData]);
// Filtered models // Filtered models
@@ -193,17 +225,27 @@ const Models: React.FC = () => {
if (!caps || caps.length === 0) return 'text'; if (!caps || caps.length === 0) return 'text';
const lower = caps.map(c => c.toLowerCase()); const lower = caps.map(c => c.toLowerCase());
if (lower.includes('image')) return 'image'; if (lower.includes('image')) return 'image';
if (lower.includes('audio')) return 'audio';
if (lower.includes('video')) return 'video'; 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('vision')) return 'vision';
if (lower.includes('thinking') || lower.includes('tool')) return 'thinking';
return 'text'; return 'text';
}; };
const modelTypeLabels: Record<string, { label: string; color: string }> = { const modelTypeLabels: Record<string, { label: string; color: string }> = {
text: { label: '文本', color: '#5B5FED' }, text: { label: '文本', color: '#5B5FED' },
thinking: { label: '推理/工具', color: '#8B5CF6' },
image: { label: '图像生成', color: '#D9383A' }, image: { label: '图像生成', color: '#D9383A' },
audio: { label: '音频', color: '#E8922C' }, audio: { label: '语音合成', color: '#E8922C' },
video: { label: '视频', color: '#EC4899' }, 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' }, vision: { label: '视觉理解', color: '#219B5E' },
}; };
@@ -379,6 +421,42 @@ const Models: React.FC = () => {
{/* Stats */} {/* Stats */}
<ModelStatsPanel models={models} providers={providers} /> <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 && ( {error && (
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}> <div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button> {error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
+20 -4
View File
@@ -338,8 +338,9 @@ const ChatPage: React.FC = () => {
const data = res.data || res; const data = res.data || res;
const models = Array.isArray(data) ? data : (data?.items || []); const models = Array.isArray(data) ? data : (data?.items || []);
setChatModels(models); setChatModels(models);
if (models.length > 0 && !selectedModelId) { // 全平台默认使用 Auto(按能力自动匹配平台默认模型)
setSelectedModelId(models[0].id); if (!selectedModelId) {
setSelectedModelId('auto');
} }
}).catch(() => { }).catch(() => {
setChatModels([]); 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))]; return [...pinned.filter(c => c.title.toLowerCase().includes(q)), ...unpinned.filter(c => c.title.toLowerCase().includes(q))];
}, [conversations, searchQuery]); }, [conversations, searchQuery]);
// Selected model info // Selected model info(Auto 为按能力自动匹配平台默认模型)
const selectedModel = chatModels.find(m => m.id === selectedModelId) || chatModels[0] || { id: '', name: '选择模型', status: 'free', provider_name: '' }; 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 // Scroll to bottom when messages change
useEffect(() => { useEffect(() => {
@@ -792,6 +796,18 @@ const ChatPage: React.FC = () => {
</div> </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 => ( {chatModels.filter(m => m.is_enabled !== false).map(model => (
<button <button
key={model.id} key={model.id}