fix(im_llm): 代理绕过Docker内部hostname
This commit is contained in:
1 parent
96aabc31f6
commit
9a43ee244d
24 files changed
+898
-129
No files matched your search
@@ -855,17 +855,34 @@ def _test_mcp_server(server, db) -> dict:
|
|||||||
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
|
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
|
||||||
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
|
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
|
||||||
import asyncio as _aio
|
import asyncio as _aio
|
||||||
tools = _aio.run(_lmt(sd, timeout=15))
|
# raise_on_error=True 让网络异常透传,暴露真实错误而非 "no tools found"
|
||||||
|
tools = _aio.run(_lmt(sd, timeout=15, raise_on_error=True))
|
||||||
result["success"] = bool(tools)
|
result["success"] = bool(tools)
|
||||||
result["details"]["tools_count"] = len(tools)
|
result["details"]["tools_count"] = len(tools)
|
||||||
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
|
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
|
||||||
result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
|
if tools:
|
||||||
|
result["message"] = f"Connected — {len(tools)} tools available"
|
||||||
|
else:
|
||||||
|
result["message"] = "Connected but no tools found (server returned empty list)"
|
||||||
result["details"]["status_code"] = 200
|
result["details"]["status_code"] = 200
|
||||||
if tools:
|
if tools:
|
||||||
server.tools_count = len(tools)
|
server.tools_count = len(tools)
|
||||||
server.status = "connected"
|
server.status = "connected"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result["message"] = f"Connection failed: {str(e)}"
|
# 把真实异常(类型+消息)透传给前端,告别误导性的 "Connected but no tools found"
|
||||||
|
err_type = type(e).__name__
|
||||||
|
err_msg = str(e)[:300]
|
||||||
|
result["message"] = f"{err_type}: {err_msg}"
|
||||||
|
result["details"]["error_type"] = err_type
|
||||||
|
result["details"]["error"] = err_msg
|
||||||
|
if "ConnectError" in err_type or "Timeout" in err_type:
|
||||||
|
result["details"]["hint"] = "网络层无法到达 MCP 服务器,请检查代理配置或服务端 URL"
|
||||||
|
elif "404" in err_msg or "NotFound" in err_type:
|
||||||
|
result["details"]["hint"] = "服务端 URL 不存在或路径错误"
|
||||||
|
elif "401" in err_msg or "Unauthorized" in err_type:
|
||||||
|
result["details"]["hint"] = "认证失败:检查请求头中的 Authorization"
|
||||||
|
else:
|
||||||
|
result["details"]["hint"] = "检查认证头/transport_type 或服务端状态"
|
||||||
|
|
||||||
elif transport == "sse":
|
elif transport == "sse":
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
Platform Auth Verify Endpoint
|
||||||
|
用于 nginx auth_request 子请求验证平台 JWT
|
||||||
|
返回 200 + X-User-Id/X-User-Name headers 或 401
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.auth import decode_access_token
|
||||||
|
|
||||||
|
logger = logging.getLogger("aiflow.auth_verify")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
@router.head("/verify")
|
||||||
|
async def verify_auth(request: Request):
|
||||||
|
"""
|
||||||
|
nginx auth_request 验证端点
|
||||||
|
从请求中提取 JWT (Authorization header 或 cookie)并验证
|
||||||
|
��回 200 + 用户信息 headers,或 401
|
||||||
|
"""
|
||||||
|
token = None
|
||||||
|
|
||||||
|
# 1. 尝试 Authorization: Bearer <token>
|
||||||
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
|
||||||
|
# 2. 尝试 cookie 中的 jwt token
|
||||||
|
if not token:
|
||||||
|
token = request.cookies.get("jwt") or request.cookies.get("token") or request.cookies.get("access_token")
|
||||||
|
|
||||||
|
# 3. 尝试 X-Platform-Token header
|
||||||
|
if not token:
|
||||||
|
token = request.headers.get("X-Platform-Token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
logger.debug("No token found in request")
|
||||||
|
return Response(status_code=401, headers={"X-Auth-Error": "no_token"})
|
||||||
|
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
if not payload:
|
||||||
|
logger.debug("Invalid or expired token")
|
||||||
|
return Response(status_code=401, headers={"X-Auth-Error": "invalid_token"})
|
||||||
|
|
||||||
|
user_id = payload.get("sub", "")
|
||||||
|
user_name = payload.get("username", "")
|
||||||
|
user_role = payload.get("role", "user")
|
||||||
|
tenant_id = payload.get("tenant_id", "")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return Response(status_code=401, headers={"X-Auth-Error": "no_user_id"})
|
||||||
|
|
||||||
|
logger.info(f"Auth verify OK: user={user_id}, name={user_name}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
status_code=200,
|
||||||
|
headers={
|
||||||
|
"X-User-Id": str(user_id),
|
||||||
|
"X-User-Name": str(user_name) if user_name else str(user_id),
|
||||||
|
"X-User-Role": str(user_role),
|
||||||
|
"X-Tenant-Id": str(tenant_id) if tenant_id else "",
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Platform Billing Webhook
|
||||||
|
接收 LiteLLM spend 回调,写入 token_usage_logs 并扣减 user_balances
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.database import SessionLocal
|
||||||
|
|
||||||
|
logger = logging.getLogger("aiflow.billing")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class SpendEvent(BaseModel):
|
||||||
|
"""LiteLLM spend log event"""
|
||||||
|
request_id: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
total_tokens: int = 0
|
||||||
|
prompt_tokens: int = 0
|
||||||
|
completion_tokens: int = 0
|
||||||
|
spend: float = 0.0
|
||||||
|
user: Optional[str] = None # platform user_id passed via metadata
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
startTime: Optional[str] = None
|
||||||
|
endTime: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhook")
|
||||||
|
async def billing_webhook(event: SpendEvent, request: Request):
|
||||||
|
"""
|
||||||
|
接收 LiteLLM spend webhook 回调
|
||||||
|
记录 token 使用并扣减用户余额
|
||||||
|
"""
|
||||||
|
# 验证来源
|
||||||
|
api_key = event.api_key or ""
|
||||||
|
metadata = event.metadata or {}
|
||||||
|
|
||||||
|
# 只处理 mumuainovel 的用量
|
||||||
|
app_name = metadata.get("app", "")
|
||||||
|
if app_name != "mumuainovel" and "mumu" not in api_key:
|
||||||
|
# 也检查是否有自定义的 X-Source header
|
||||||
|
source = request.headers.get("X-Source", "")
|
||||||
|
if source != "mumuainovel":
|
||||||
|
return {"status": "skipped", "reason": "not_mumuainovel"}
|
||||||
|
|
||||||
|
user_id = event.user or metadata.get("user_id", "mumu-user")
|
||||||
|
model_name = event.model or "unknown"
|
||||||
|
spend = max(event.spend, 0)
|
||||||
|
total_tokens = event.total_tokens
|
||||||
|
prompt_tokens = event.prompt_tokens
|
||||||
|
completion_tokens = event.completion_tokens
|
||||||
|
|
||||||
|
if total_tokens == 0 and spend == 0:
|
||||||
|
return {"status": "skipped", "reason": "zero_usage"}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 写入 token_usage_logs
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO token_usage_logs (id, user_id, model_name, app_id, app_name,
|
||||||
|
prompt_tokens, completion_tokens, total_tokens, cost, created_at)
|
||||||
|
VALUES (gen_random_uuid(), :uid, :model, :app_id, :app_name,
|
||||||
|
:pt, :ct, :tt, :cost, :ts)
|
||||||
|
"""), {
|
||||||
|
"uid": user_id,
|
||||||
|
"model": model_name,
|
||||||
|
"app_id": "mumuainovel-001",
|
||||||
|
"app_name": "MuMuAINovel",
|
||||||
|
"pt": prompt_tokens,
|
||||||
|
"ct": completion_tokens,
|
||||||
|
"tt": total_tokens,
|
||||||
|
"cost": spend,
|
||||||
|
"ts": datetime.utcnow(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 扣减余额
|
||||||
|
result = db.execute(text("""
|
||||||
|
UPDATE user_balances
|
||||||
|
SET balance = balance - :cost,
|
||||||
|
total_used = total_used + :cost,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = :uid AND balance >= :cost
|
||||||
|
RETURNING id
|
||||||
|
"""), {"cost": spend, "uid": user_id})
|
||||||
|
|
||||||
|
if result.rowcount == 0:
|
||||||
|
# 余额不足或无记录,创建新余额记录
|
||||||
|
# 检查是否存在
|
||||||
|
exists = db.execute(text(
|
||||||
|
"SELECT id FROM user_balances WHERE user_id = :uid"
|
||||||
|
), {"uid": user_id}).fetchone()
|
||||||
|
if not exists:
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO user_balances (id, user_id, balance, total_used, created_at, updated_at)
|
||||||
|
VALUES (gen_random_uuid(), :uid, :balance, :cost, NOW(), NOW())
|
||||||
|
"""), {"uid": user_id, "balance": 10.0 - spend, "cost": spend})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Billing: user={user_id} model={model_name} tokens={total_tokens} spend=${spend:.6f}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Billing webhook error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {"status": "ok", "user_id": user_id, "spend": spend}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhook/health")
|
||||||
|
async def billing_webhook_health():
|
||||||
|
return {"status": "ok", "service": "billing-webhook"}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Bug-audit endpoint for runtime regression checks."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import SiteSetting, AIModel
|
||||||
|
from app.capability_registry import CAPABILITY_TYPES, get_capability_defaults, resolve_auto_model
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/bug-audit")
|
||||||
|
def bug_audit(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Runtime self-check: 验证平台大模型路由/能力配置的关键不变量。
|
||||||
|
用于发现重复 bug(比如「已修复又出现」)。
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
warnings = []
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
# Check 1: 文本类能力(text/thinking) 必须指向启用的非图像/视频模型
|
||||||
|
TEXT_FAMILY = {"text", "thinking", "vision", "tool"}
|
||||||
|
defaults = get_capability_defaults(db)
|
||||||
|
for cap in ("text", "thinking"):
|
||||||
|
mid = defaults.get(cap)
|
||||||
|
if not mid:
|
||||||
|
issues.append(f"{cap}: site_settings.model_capability_defaults 未设置")
|
||||||
|
continue
|
||||||
|
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
|
||||||
|
if not m:
|
||||||
|
issues.append(f"{cap}: 指向的模型 {mid} 不存在或已禁用")
|
||||||
|
continue
|
||||||
|
caps = [c.lower() for c in (m.capabilities or [])]
|
||||||
|
if not ("text" in caps or "thinking" in caps):
|
||||||
|
issues.append(f"{cap}: 指向的 {m.name} 没有 text/thinking 能力 (caps={caps})")
|
||||||
|
if "image" in caps or "video" in caps:
|
||||||
|
warnings.append(f"{cap}: 默认模型 {m.name} 含 image/video 能力 (普通多轮对话可能误判)")
|
||||||
|
checks.append(f"{cap} -> {m.name} (caps={caps})")
|
||||||
|
|
||||||
|
# Check 2: 必须有 is_default=true 的模型
|
||||||
|
default_model = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||||
|
if not default_model:
|
||||||
|
issues.append("没有任何 is_default=true 且启用的模型")
|
||||||
|
else:
|
||||||
|
checks.append(f"is_default -> {default_model.name}")
|
||||||
|
|
||||||
|
# Check 3: 所有 enabled 的 chat 模型都应该能正常 resolve
|
||||||
|
for cap in ("text", "image", "video", "audio", "embedding", "rerank", "asr", "ocr"):
|
||||||
|
m = resolve_auto_model(cap, db)
|
||||||
|
if m:
|
||||||
|
checks.append(f"resolve_auto_model({cap}) -> {m.name}")
|
||||||
|
else:
|
||||||
|
warnings.append(f"resolve_auto_model({cap}) 返回 None (该能力无默认模型)")
|
||||||
|
|
||||||
|
# Check 4: detect_media_intent 单元自检(用经典样本)
|
||||||
|
from app.api.chat_media import detect_media_intent
|
||||||
|
test_cases = [
|
||||||
|
("平台有哪些技能能", None), # 普通文字
|
||||||
|
("你好", None), # 普通问候
|
||||||
|
("画一只小猫", "image"), # 明确图像
|
||||||
|
("生成一个视频介绍", "video"), # 明确视频
|
||||||
|
("今天天气如何?", None), # 疑问句
|
||||||
|
]
|
||||||
|
for text_in, expected in test_cases:
|
||||||
|
result = detect_media_intent(text_in)
|
||||||
|
if result != expected:
|
||||||
|
issues.append(f"detect_media_intent({text_in!r})={result!r} 期望 {expected!r}")
|
||||||
|
else:
|
||||||
|
checks.append(f"detect_media_intent({text_in!r})={result!r} ✓")
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
healthy = len(issues) == 0
|
||||||
|
return {
|
||||||
|
"healthy": healthy,
|
||||||
|
"issues": issues,
|
||||||
|
"warnings": warnings,
|
||||||
|
"checks": checks,
|
||||||
|
"summary": {
|
||||||
|
"total_checks": len(checks),
|
||||||
|
"issues": len(issues),
|
||||||
|
"warnings": len(warnings),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2655,7 +2655,7 @@ def get_models(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
|
models = db.query(AIModel).filter(AIModel.is_enabled == True).order_by(AIModel.is_default.desc(), AIModel.name.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -506,7 +506,11 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
api_key = text_model.api_key or (provider.api_key if provider else None)
|
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
|
||||||
|
if text_model.endpoint and "qihu-aiflow-litellm" in text_model.endpoint:
|
||||||
|
api_key = "sk-litellm-master-key-aiflow-2026"
|
||||||
|
else:
|
||||||
|
api_key = text_model.api_key or (provider.api_key if provider else None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -936,7 +940,11 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
|
||||||
|
if ai_model.endpoint and "qihu-aiflow-litellm" in ai_model.endpoint:
|
||||||
|
api_key = "sk-litellm-master-key-aiflow-2026"
|
||||||
|
else:
|
||||||
|
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2895,6 +2895,13 @@ async def _send_message_core(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
|
||||||
|
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
|
||||||
|
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
|
||||||
|
logger.info("[chat] Override media-cap model {} -> text (no media intent in: {:.50r})".format(ai_model.name, req.content))
|
||||||
|
ai_model = resolve_auto_model("text", db)
|
||||||
|
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):
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
|
|||||||
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re as _re
|
import re as _re
|
||||||
@@ -48,7 +49,8 @@ def _proxy_for_url(url: str) -> str | None:
|
|||||||
host = (_urlparse(url).hostname or "").lower()
|
host = (_urlparse(url).hostname or "").lower()
|
||||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||||
return _HK_PROXY
|
return _HK_PROXY
|
||||||
if "qihuyun.com" in host:
|
# 2026-07-30 修复 3+: 不代理 Docker 内部 hostname 和 localhost
|
||||||
|
if "qihuyun.com" in host or "localhost" in host or host.startswith("127.") or "qihu-aiflow" in host:
|
||||||
return None
|
return None
|
||||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||||
for _np in _no_proxy:
|
for _np in _no_proxy:
|
||||||
@@ -127,7 +129,12 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ===== 1) 媒体意图路由(Issue 7)=====
|
# ===== 1) 媒体意图路由(Issue 7)=====
|
||||||
intent = detect_media_intent(prompt)
|
# 2026-07-30 fix: 只对当前用户消息做媒体意图检测,避免历史中的图像请求污染
|
||||||
|
_cur_msg = prompt
|
||||||
|
_marker = "用户当前问题:"
|
||||||
|
if _marker in prompt:
|
||||||
|
_cur_msg = prompt[prompt.rfind(_marker) + len(_marker):].strip()
|
||||||
|
intent = detect_media_intent(_cur_msg)
|
||||||
if intent:
|
if intent:
|
||||||
ai_model = resolve_auto_model(intent, db)
|
ai_model = resolve_auto_model(intent, db)
|
||||||
if ai_model:
|
if ai_model:
|
||||||
@@ -196,6 +203,10 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||||
api_url = ai_model.endpoint
|
api_url = ai_model.endpoint
|
||||||
|
# 2026-07-30: LiteLLM 端点统一用 master key(与 chat_service.py 对齐)
|
||||||
|
if api_url and "qihu-aiflow-litellm" in api_url:
|
||||||
|
_litellm_key = os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
|
||||||
|
api_key = _litellm_key
|
||||||
if not api_url and provider and provider.api_base_url:
|
if not api_url and provider and provider.api_base_url:
|
||||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||||
if not (api_key and api_url):
|
if not (api_key and api_url):
|
||||||
@@ -221,13 +232,15 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
ai_content = ""
|
ai_content = ""
|
||||||
_http_ok = True
|
_http_ok = True
|
||||||
for round_num in range(max_rounds):
|
for round_num in range(max_rounds):
|
||||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
|
||||||
if function_definitions and skills:
|
if function_definitions and skills:
|
||||||
request_body["tools"] = [
|
request_body["tools"] = [
|
||||||
{"type": "function", "function": fd} for fd in function_definitions
|
{"type": "function", "function": fd} for fd in function_definitions
|
||||||
]
|
]
|
||||||
request_body["tool_choice"] = "auto"
|
request_body["tool_choice"] = "auto"
|
||||||
try:
|
try:
|
||||||
|
import logging as _l5
|
||||||
|
_l5.warning(f'[im_llm REQ] {ai_model.name}: url={api_url[:60]}, proxy={_proxy_for_url(api_url)}, api_key={api_key[:20]}..., messages_count={len(request_body.get("messages", []))}, tools_count={len(request_body.get("tools", []))}')
|
||||||
data = chat_complete(
|
data = chat_complete(
|
||||||
api_url,
|
api_url,
|
||||||
api_key,
|
api_key,
|
||||||
@@ -235,40 +248,58 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
|||||||
proxy=_proxy_for_url(api_url),
|
proxy=_proxy_for_url(api_url),
|
||||||
timeout=ai_model.timeout or 120,
|
timeout=ai_model.timeout or 120,
|
||||||
)
|
)
|
||||||
|
_l5.warning(f'[im_llm RES] {ai_model.name}: data_keys={list(data.keys()) if data else None}, resp_type={type(data).__name__}')
|
||||||
except Exception as _exc:
|
except Exception as _exc:
|
||||||
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
||||||
|
import traceback as _tb
|
||||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
||||||
|
logger.warning(f"[im_llm] {ai_model.name} 异常详情: {_tb.format_exc()[:2000]}")
|
||||||
_http_ok = False
|
_http_ok = False
|
||||||
break
|
break
|
||||||
message = data.get("choices", [{}])[0].get("message", {})
|
message = data.get("choices", [{}])[0].get("message", {})
|
||||||
tool_calls = message.get("tool_calls", [])
|
tool_calls = message.get("tool_calls", [])
|
||||||
if tool_calls and skills:
|
# 2026-07-30 CRITICAL DEBUG
|
||||||
|
import logging as _lcr
|
||||||
|
_raw_content = message.get("content")
|
||||||
|
_raw_reasoning = message.get("reasoning_content")
|
||||||
|
_msg_keys = list(message.keys()) if message else []
|
||||||
|
_tool_calls = tool_calls
|
||||||
|
_lcr.warning(f'[CRITICAL] MODEL={ai_model.name} msg_keys={_msg_keys} content_len={len(_raw_content or "")} reasoning_len={len(_raw_reasoning or "")} has_tool_calls={bool(_tool_calls)}')
|
||||||
|
ai_content = message.get("content", "") or message.get("reasoning_content") or ""
|
||||||
|
if not ai_content and (message.get("reasoning_content") or ""):
|
||||||
|
ai_content = f"(AI 思考:{message.get('reasoning_content')[:500]}...)"
|
||||||
|
if tool_calls and skills:
|
||||||
|
messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": message.get("content") or message.get("reasoning_content") or "",
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
})
|
||||||
|
for tc in tool_calls:
|
||||||
|
func_name = tc.get("function", {}).get("name", "")
|
||||||
|
try:
|
||||||
|
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||||
|
except Exception:
|
||||||
|
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||||
|
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "tool",
|
||||||
"content": message.get("content") or "",
|
"tool_call_id": tc.get("id", "call_1"),
|
||||||
"tool_calls": tool_calls,
|
"content": tool_result,
|
||||||
})
|
})
|
||||||
for tc in tool_calls:
|
if round_num >= max_rounds - 1:
|
||||||
func_name = tc.get("function", {}).get("name", "")
|
break
|
||||||
try:
|
continue
|
||||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
break
|
||||||
except Exception:
|
|
||||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
|
||||||
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tc.get("id", "call_1"),
|
|
||||||
"content": tool_result,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
ai_content = message.get("content", "")
|
|
||||||
break
|
|
||||||
|
|
||||||
# 检查本轮调用是否成功
|
# 检查本轮调用是否成功
|
||||||
|
import logging as _log2
|
||||||
|
_log2.warning(f'[im_llm trace] {ai_model.name}: http_ok={_http_ok}, ai_content_len={len(ai_content) if ai_content else 0}, ai_content_preview={repr(ai_content)[:200]}')
|
||||||
if _http_ok and ai_content:
|
if _http_ok and ai_content:
|
||||||
return ai_content or "(AI 回复为空)"
|
return ai_content or "(AI 回复为空)"
|
||||||
# 本轮失败:记录日志,切换备选
|
# 本轮失败:记录日志,切换备选
|
||||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
logger.warning('[im_llm] 模型 %s 失败 (http_ok=%s, content=%s), 切换备选...' % (
|
||||||
|
ai_model.name, _http_ok, repr(ai_content)[:100]
|
||||||
|
))
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
||||||
|
|||||||
@@ -122,6 +122,6 @@ async def get_current_admin(
|
|||||||
user=Depends(get_current_user),
|
user=Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""验证当前用户为管理员"""
|
"""验证当前用户为管理员"""
|
||||||
if user.role != "admin":
|
if not has_role(user.role, "admin"):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||||
return user
|
return user
|
||||||
@@ -43,12 +43,14 @@ def _transport_cm(server: dict, timeout: float):
|
|||||||
|
|
||||||
|
|
||||||
async def _session_request(
|
async def _session_request(
|
||||||
server: dict, method: str, params: dict, timeout: float = 30.0
|
server: dict, method: str, params: dict, timeout: float = 30.0,
|
||||||
|
raise_on_error: bool = False
|
||||||
):
|
):
|
||||||
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
|
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
|
||||||
|
|
||||||
返回解析后的 dict:{"result": {...}} 或 None(失败)。
|
返回解析后的 dict:{"result": {...}} 或 None(失败)。
|
||||||
协议版本由 SDK 自动协商,不再硬编码。
|
协议版本由 SDK 自动协商,不再硬编码。
|
||||||
|
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
|
||||||
"""
|
"""
|
||||||
url = (server.get("url") or "").strip()
|
url = (server.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
@@ -89,33 +91,40 @@ async def _session_request(
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
|
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
|
||||||
|
if raise_on_error:
|
||||||
|
raise
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
|
async def list_mcp_tools(server: dict, timeout: float = 30.0, raise_on_error: bool = False) -> list:
|
||||||
"""
|
"""
|
||||||
拉取 MCP 服务器的工具列表。
|
拉取 MCP 服务器的工具列表。
|
||||||
server: {url, transport_type, headers}
|
server: {url, transport_type, headers}
|
||||||
返回 MCP tools 数组(每个含 name/description/inputSchema)。
|
返回 MCP tools 数组(每个含 name/description/inputSchema)。
|
||||||
|
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
|
||||||
"""
|
"""
|
||||||
url = (server.get("url") or "").strip()
|
url = (server.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return []
|
return []
|
||||||
# 缓存
|
# 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
|
||||||
now = asyncio.get_event_loop().time()
|
if not raise_on_error:
|
||||||
cached = _TOOLS_CACHE.get(url)
|
now = asyncio.get_event_loop().time()
|
||||||
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
|
cached = _TOOLS_CACHE.get(url)
|
||||||
return cached[1]
|
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
|
||||||
|
return cached[1]
|
||||||
try:
|
try:
|
||||||
data = await _session_request(server, "tools/list", {}, timeout)
|
data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
|
||||||
tools = []
|
tools = []
|
||||||
if data and "result" in data:
|
if data and "result" in data:
|
||||||
tools = data["result"].get("tools", []) or []
|
tools = data["result"].get("tools", []) or []
|
||||||
_TOOLS_CACHE[url] = (now, tools)
|
if not raise_on_error:
|
||||||
|
_TOOLS_CACHE[url] = (asyncio.get_event_loop().time(), tools)
|
||||||
return tools
|
return tools
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
|
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
|
||||||
|
if raise_on_error:
|
||||||
|
raise
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ from datetime import datetime
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution, skills_api
|
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution, skills_api
|
||||||
|
from app.api.bug_audit import router as bug_audit_router
|
||||||
|
from app.api.auth_verify import router as auth_verify_router
|
||||||
|
from app.api.billing_webhook import router as billing_webhook_router
|
||||||
from app.casdoor_auth import router as casdoor_router
|
from app.casdoor_auth import router as casdoor_router
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
||||||
from app.api.im_bot_user import router as im_bot_user_router
|
from app.api.im_bot_user import router as im_bot_user_router
|
||||||
@@ -283,6 +286,9 @@ app.include_router(observability_admin.router, prefix="/app/api/admin/observabil
|
|||||||
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
||||||
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||||
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
||||||
|
app.include_router(auth_verify_router, prefix="/app/api/auth", tags=["Auth Verify"])
|
||||||
|
app.include_router(billing_webhook_router, prefix="/app/api/billing", tags=["Billing Webhook"])
|
||||||
|
app.include_router(bug_audit_router, prefix="/app/api/health", tags=["Health"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/app/api/health")
|
@app.get("/app/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""Regression R4: 防止能力路由误判回归。
|
||||||
|
|
||||||
|
R4.1: detector_media_intent 对纯文本输入返回 None(不误判为 image/video)。
|
||||||
|
R4.2: resolve_auto_model("text") 返回有 text 能力的模型。
|
||||||
|
R4.3: 普通多轮对话必须走 text 模型,不可走 image/video。
|
||||||
|
R4.4: 所有已注册能力可由 resolve_auto_model 解析到启用模型或 None。
|
||||||
|
R4.5: is_default 模型必须存在且启用。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
from app.api.chat_media import detect_media_intent, detect_ambiguous_creative_intent
|
||||||
|
from app.capability_registry import resolve_auto_model, get_capability_defaults, CAPABILITY_TYPES, TEXT_FAMILY
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import AIModel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def db():
|
||||||
|
"""获得独立测试数据库会话"""
|
||||||
|
db = SessionLocal()
|
||||||
|
yield db
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── R4.1: detect_media_intent regression suite ───
|
||||||
|
MEDIA_INTENT_CASES = [
|
||||||
|
# (input, expected_intent)
|
||||||
|
("你好", None),
|
||||||
|
("今天天气怎么样", None),
|
||||||
|
("帮我写一篇文章", None),
|
||||||
|
("平台有哪些技能能", None), # 历史 bug:曾被误判为 image
|
||||||
|
("新对话", None), # 新建对话入口
|
||||||
|
("画一只小猫", "image"),
|
||||||
|
("生成一张海报", "image"),
|
||||||
|
("给我画个头像", "image"),
|
||||||
|
("生成一个视频介绍", "video"),
|
||||||
|
("做一段短片", "video"),
|
||||||
|
("来段视频", "video"),
|
||||||
|
("朗读这段文字", "audio"),
|
||||||
|
("语音合成一段播报", "audio"),
|
||||||
|
("你能生成视频吗?", None), # 疑问句不触发
|
||||||
|
("怎么画一只猫?", None),
|
||||||
|
("如何生成图片呢?", None),
|
||||||
|
("画一只小猫,要可爱的", "image"), # 有明确指令
|
||||||
|
("Generate a cat image", "image"), # 英文
|
||||||
|
("help me create a video", "video"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestR4_1_MediaIntent:
|
||||||
|
@pytest.mark.parametrize("text,expected", MEDIA_INTENT_CASES)
|
||||||
|
def test_detect_media_intent(self, text, expected):
|
||||||
|
result = detect_media_intent(text)
|
||||||
|
assert result == expected, f"detect_media_intent({text!r}) = {result!r}, expected {expected!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── R4.2: resolve_auto_model 回归 ───
|
||||||
|
class TestR4_2_AutoModel:
|
||||||
|
|
||||||
|
def test_text_model_has_text_capability(self, db):
|
||||||
|
"""文本对话默认模型必须有 text 能力"""
|
||||||
|
m = resolve_auto_model("text", db)
|
||||||
|
assert m is not None, "resolve_auto_model('text') returned None"
|
||||||
|
caps = [c.lower() for c in (m.capabilities or [])]
|
||||||
|
assert "text" in caps, f"Text model {m.name} has no 'text' capability: {caps}"
|
||||||
|
|
||||||
|
def test_text_model_not_image_video(self, db):
|
||||||
|
"""文本默认模型不应触发 image/video 误判"""
|
||||||
|
m = resolve_auto_model("text", db)
|
||||||
|
caps = [c.lower() for c in (m.capabilities or [])]
|
||||||
|
if "image" in caps or "video" in caps:
|
||||||
|
# If text model has image caps, detect_media_intent must catch it
|
||||||
|
assert detect_media_intent("你好") is None, "Normal text detected as media despite model having image caps"
|
||||||
|
assert detect_media_intent("新对话") is None
|
||||||
|
|
||||||
|
def test_thinking_model_exists(self, db):
|
||||||
|
m = resolve_auto_model("thinking", db)
|
||||||
|
assert m is not None, "resolve_auto_model('thinking') returned None"
|
||||||
|
|
||||||
|
def test_all_11_capabilities_resolvable(self, db):
|
||||||
|
"""所有 11 个能力至少有一个模型或 None"""
|
||||||
|
for cap_def in CAPABILITY_TYPES:
|
||||||
|
cap = cap_def["id"]
|
||||||
|
m = resolve_auto_model(cap, db)
|
||||||
|
is_text_family = cap in TEXT_FAMILY
|
||||||
|
if is_text_family:
|
||||||
|
assert m is not None, f"{cap} is TEXT_FAMILY but resolve_auto_model returned None"
|
||||||
|
# media/embedding/rerank/asr/ocr can legitimately be None if no model
|
||||||
|
|
||||||
|
|
||||||
|
# ─── R4.3: is_default model exists ───
|
||||||
|
class TestR4_3_DefaultModel:
|
||||||
|
|
||||||
|
def test_default_model_exists(self, db):
|
||||||
|
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||||
|
assert m is not None, "No is_default=true and enabled model"
|
||||||
|
caps = [c.lower() for c in (m.capabilities or [])]
|
||||||
|
assert "text" in caps, f"Default model {m.name} must have 'text' capability: {caps}"
|
||||||
|
|
||||||
|
def test_default_model_resolvable(self, db):
|
||||||
|
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||||
|
assert m.id == resolve_auto_model("text", db).id, "is_default model != resolve_auto_model('text')"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── R4.4: capability defaults consistency ───
|
||||||
|
class TestR4_4_CapabilityDefaults:
|
||||||
|
|
||||||
|
def test_text_defaults_in_sync_with_auto(self, db):
|
||||||
|
"""site_settings text capability defaults must match resolve_auto_model"""
|
||||||
|
defaults = get_capability_defaults(db)
|
||||||
|
for cap in ("text", "thinking"):
|
||||||
|
mid = defaults.get(cap)
|
||||||
|
if mid:
|
||||||
|
m = resolve_auto_model(cap, db)
|
||||||
|
assert m is not None, f"SiteSetting {cap}->{mid} not resolvable"
|
||||||
|
assert m.id == mid, f"Mismatch: site_setting {cap}={mid} but resolve_auto_model returns {m.id}"
|
||||||
|
|
||||||
|
def test_no_disabled_models_in_defaults(self, db):
|
||||||
|
defaults = get_capability_defaults(db)
|
||||||
|
for cap, mid in defaults.items():
|
||||||
|
if mid:
|
||||||
|
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
|
||||||
|
assert m is not None, f"Capability {cap} points to disabled/nonexistent model {mid}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── R4.5: detect_ambiguous_creative_intent smoke ───
|
||||||
|
class TestR4_5_AmbiguousIntent:
|
||||||
|
|
||||||
|
def test_ambiguous_detected(self):
|
||||||
|
r = detect_ambiguous_creative_intent("帮我做一个东西")
|
||||||
|
assert r is not None, "Ambiguous creative intent not detected"
|
||||||
|
|
||||||
|
def test_explicit_not_ambiguous(self):
|
||||||
|
r = detect_ambiguous_creative_intent("帮我写一篇文章")
|
||||||
|
assert r is None, "Article request should not be ambiguous"
|
||||||
|
|
||||||
|
def test_media_not_ambiguous(self):
|
||||||
|
r = detect_ambiguous_creative_intent("画一只小猫")
|
||||||
|
assert r is None, "Image intent should bypass ambiguous detection"
|
||||||
+63
-17
@@ -1,29 +1,74 @@
|
|||||||
model_list:
|
model_list:
|
||||||
- model_name: deepseek-v4-flash
|
|
||||||
litellm_params:
|
|
||||||
model: deepseek-ai/DeepSeek-V4-Flash
|
|
||||||
api_base: https://api.siliconflow.cn/v1
|
|
||||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
|
||||||
rpm: 1000
|
|
||||||
tpm: 200000
|
|
||||||
- model_name: agnes-2.0-flash
|
- model_name: agnes-2.0-flash
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/agnes-2.0-flash
|
model: openai/agnes-2.0-flash
|
||||||
api_base: https://ai.qihuapi.com/v1
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: agnes-image-2.0-flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-image-2.0-flash
|
||||||
|
- model_name: agnes-image-2.1-flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-image-2.0-flash
|
||||||
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: agnes-video-v2.0
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-video-v2.0
|
||||||
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: BAAI/bge-m3
|
||||||
|
litellm_params:
|
||||||
|
model: openai/BAAI/bge-m3
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 500
|
- model_name: BAAI/bge-reranker-v2-m3
|
||||||
|
litellm_params:
|
||||||
|
model: openai/BAAI/bge-reranker-v2-m3
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: deepseek-ai/DeepSeek-OCR
|
||||||
|
litellm_params:
|
||||||
|
model: openai/deepseek-ai/DeepSeek-OCR
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||||
|
litellm_params:
|
||||||
|
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: gemini-3.1-flash-image-preview
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gemini-2.5-flash-image
|
||||||
|
api_base: https://api.bananarouter.com/v1
|
||||||
|
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||||
|
- model_name: gpt-image-2
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gemini-2.5-pro-image
|
||||||
|
api_base: https://api.bananarouter.com/v1
|
||||||
|
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||||
- model_name: mimo-v2.5
|
- model_name: mimo-v2.5
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/mimo-v2.5
|
model: openai/mimo-v2.5
|
||||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||||
rpm: 500
|
headers:
|
||||||
- model_name: qwen3-vl-32b
|
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||||
|
- model_name: TeleAI/TeleSpeechASR
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: openai/Qwen3-VL-32B-Instruct
|
model: openai/TeleAI/TeleSpeechASR
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||||
|
litellm_params:
|
||||||
|
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||||
api_base: https://api.siliconflow.cn/v1
|
api_base: https://api.siliconflow.cn/v1
|
||||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
rpm: 300
|
|
||||||
|
|
||||||
litellm_settings:
|
litellm_settings:
|
||||||
drop_params: true
|
drop_params: true
|
||||||
@@ -33,9 +78,10 @@ general_settings:
|
|||||||
master_key: sk-litellm-master-key-aiflow-2026
|
master_key: sk-litellm-master-key-aiflow-2026
|
||||||
|
|
||||||
router_settings:
|
router_settings:
|
||||||
routing_strategy: "usage-based-routing-v2"
|
routing_strategy: simple-shuffle
|
||||||
allowed_fails: 2
|
allowed_fails: 100000
|
||||||
num_retries: 2
|
num_retries: 0
|
||||||
|
cooldown_time: 0
|
||||||
fallbacks:
|
fallbacks:
|
||||||
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||||
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
model_list:
|
||||||
|
- model_name: agnes-2.0-flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-2.0-flash
|
||||||
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: agnes-image-2.0-flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-image-2.0-flash
|
||||||
|
- model_name: agnes-image-2.1-flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-image-2.0-flash
|
||||||
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: agnes-video-v2.0
|
||||||
|
litellm_params:
|
||||||
|
model: openai/agnes-video-v2.0
|
||||||
|
api_base: https://apihub.agnes-ai.com
|
||||||
|
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||||
|
- model_name: BAAI/bge-m3
|
||||||
|
litellm_params:
|
||||||
|
model: openai/BAAI/bge-m3
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: BAAI/bge-reranker-v2-m3
|
||||||
|
litellm_params:
|
||||||
|
model: openai/BAAI/bge-reranker-v2-m3
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: deepseek-ai/DeepSeek-OCR
|
||||||
|
litellm_params:
|
||||||
|
model: openai/deepseek-ai/DeepSeek-OCR
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||||
|
litellm_params:
|
||||||
|
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||||
|
litellm_params:
|
||||||
|
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: gemini-3.1-flash-image-preview
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gemini-2.5-flash-image
|
||||||
|
api_base: https://api.bananarouter.com/v1
|
||||||
|
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||||
|
- model_name: gpt-image-2
|
||||||
|
litellm_params:
|
||||||
|
model: openai/gemini-2.5-pro-image
|
||||||
|
api_base: https://api.bananarouter.com/v1
|
||||||
|
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||||
|
- model_name: mimo-v2.5
|
||||||
|
litellm_params:
|
||||||
|
model: openai/mimo-v2.5
|
||||||
|
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||||
|
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||||
|
headers:
|
||||||
|
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||||
|
- model_name: TeleAI/TeleSpeechASR
|
||||||
|
litellm_params:
|
||||||
|
model: openai/TeleAI/TeleSpeechASR
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||||
|
litellm_params:
|
||||||
|
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||||
|
api_base: https://api.siliconflow.cn/v1
|
||||||
|
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
drop_params: true
|
||||||
|
request_timeout: 120
|
||||||
|
|
||||||
|
general_settings:
|
||||||
|
master_key: sk-litellm-master-key-aiflow-2026
|
||||||
|
|
||||||
|
router_settings:
|
||||||
|
routing_strategy: simple-shuffle
|
||||||
|
allowed_fails: 100000
|
||||||
|
num_retries: 0
|
||||||
|
cooldown_time: 0
|
||||||
|
fallbacks:
|
||||||
|
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||||
|
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||||
|
- mimo-v2.5: ["deepseek-v4-flash"]
|
||||||
@@ -10,7 +10,7 @@ server {
|
|||||||
|
|
||||||
# Proxy to MuMuAINovel
|
# Proxy to MuMuAINovel
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://mumuainovel:8000;
|
proxy_pass http://qihu-aiflow-mumuainovel:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 能力路由规则文档 (CAPABILITY_ROUTING.md)
|
||||||
|
|
||||||
|
> **谁敢改这些文件的任何一行前,必须通读本文档。**
|
||||||
|
> 更新日期: 2026-07-28 | 维护人: Senior Developer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、核心原则
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 所有路由路径最终汇聚到 capability_registry.resolve_auto_model
|
||||||
|
✅ 文本/推理/视觉/工具能力必须指向有 "text" capabilities 的模型
|
||||||
|
✅ 图像/视频/语音能力在有明确意图时才触发(不会误判普通对话)
|
||||||
|
✅ 新增能力类型必须在 CAPABILITY_TYPES 注册,否则路由不可见
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、路由路径 (谁在什么时候决定用哪个模型)
|
||||||
|
|
||||||
|
| 入口 | 文件 | 行号 | 决策逻辑 | 出错表现 |
|
||||||
|
|------|------|------|----------|---------|
|
||||||
|
| 前台用户对话 | chat_service.py | 2730 | `intent = detect_media_intent(req.content)` → 媒体意图 → 图像/视频/语音模型 | 普通对话被误判走图像 |
|
||||||
|
| 前台 Auto 模式 | chat_service.py | 2750 | `ai_model = resolved or resolve_auto_model("text", db)` → 无意图时回退文本 | 无法对话 |
|
||||||
|
| defensive guard | chat_service.py | 2898-2905 | 模型有 image/video 能力但用户无媒体意图 → 强制 resolve_auto_model("text") | 被 guard 修复后走 text |
|
||||||
|
| 后台 "按能力默认模型" 页 | admin/models.py | 495 | `get_capability_defaults(db)` → 读 site_settings JSON → 填到 UI dropdown | 页面不显示默认模型 |
|
||||||
|
| 后台 模型测试 (connect) | admin/models.py | 870-954 | `_detect_model_type(capabilities)` → 按 cap 分类测试 | 测试走错端点 |
|
||||||
|
| 关键词检测 | chat_media.py | 97-157 | `detect_media_intent(text)` → 基于关键词白名单 | 词库不全导致误判/漏判 |
|
||||||
|
| 模糊创作意图 | chat_media.py | 200-240 | `detect_ambiguous_creative_intent(text)` → 无媒体词但有创作动词 | 提示不准确 |
|
||||||
|
| 前端能力下拉 | admin/Models.tsx | 534-559 | `lockedCapDefaults` → 动态锁定 is_default=true 模型到 text/thinking | 显示错误的锁定模型 |
|
||||||
|
| 前端模型下拉 (前台) | ChatPage.tsx | 1486-1498 | `model.capabilities` → cap badge 显示 | 多显示/少显示 badge |
|
||||||
|
| 模型变更 API | admin/models.py | 670-672 | `is_default` 切换 | 旧默认模型未解除锁定 |
|
||||||
|
|
||||||
|
## 三、能力定义 (唯一真相源)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# capability_registry.py lines 17-35
|
||||||
|
CAPABILITY_TYPES = [
|
||||||
|
{"id": "text", "label": "文本对话", "desc": "文本生成与多轮对话"},
|
||||||
|
{"id": "thinking", "label": "推理思考", "desc": "深度推理 / 思维链"},
|
||||||
|
{"id": "vision", "label": "视觉理解", "desc": "图片 / 多模态理解"},
|
||||||
|
{"id": "tool", "label": "工具调用", "desc": "Function Calling"},
|
||||||
|
{"id": "image", "label": "图像生成", "desc": "文生图"},
|
||||||
|
{"id": "video", "label": "视频生成", "desc": "文生视频"},
|
||||||
|
{"id": "audio", "label": "语音合成", "desc": "TTS 文字转语音"},
|
||||||
|
{"id": "asr", "label": "语音识别", "desc": "ASR 语音转文字"},
|
||||||
|
{"id": "ocr", "label": "OCR识别", "desc": "图片文字识别"},
|
||||||
|
{"id": "embedding", "label": "嵌入向量", "desc": "文本向量化"},
|
||||||
|
{"id": "rerank", "label": "重排序", "desc": "语义重排"},
|
||||||
|
]
|
||||||
|
|
||||||
|
TEXT_FAMILY = {"text", "thinking", "vision", "tool"} # 回退到 is_default 模型
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、媒体意图关键词白名单 (chat_media.py)
|
||||||
|
|
||||||
|
### 图像触发词 (```image_kw```)
|
||||||
|
|
||||||
|
```
|
||||||
|
图片/图像/插画/海报/照片/头像/壁纸/漫画/表情包/logo/图标
|
||||||
|
画一张/画张/画一只/画只/画个/画幅
|
||||||
|
生成图片/生成一张/生成一幅/生成图/图像生成
|
||||||
|
帮我画/给我画/请画/绘制/作画/ai绘画/ai画图
|
||||||
|
画画/画一下/画出来/生成个图
|
||||||
|
帮我生成图片/帮我生成图像/给我生成图片
|
||||||
|
来一张图片/画一张图片/画一幅/画一副
|
||||||
|
```
|
||||||
|
|
||||||
|
### 视频触发词 (```video_kw```)
|
||||||
|
|
||||||
|
```
|
||||||
|
视频/短片/影片/动画视频
|
||||||
|
做个视频/生成视频/视频生成
|
||||||
|
生成一个视频/制作一个视频/帮我做视频/制作视频
|
||||||
|
生成短片/做短片/制作短片/一段短片
|
||||||
|
一段视频/来段视频/拍个视频/做段视频
|
||||||
|
```
|
||||||
|
|
||||||
|
### 语音触发词 (```audio_kw```)
|
||||||
|
|
||||||
|
```
|
||||||
|
语音/配音/朗读/播报/念一段/有声/tts/音频/生成语音
|
||||||
|
读一下/念一下/语音合成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不触发的语句
|
||||||
|
```
|
||||||
|
疑问句(?结尾或含 吗/呢/怎么/如何/能否/可以吗/会不会)
|
||||||
|
已有明确文本形式(文案/文章/诗/代码/总结/翻译/报告/PPT)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、修改检查清单
|
||||||
|
|
||||||
|
每次修改上述任何文件的代码,必须:
|
||||||
|
|
||||||
|
- [ ] 重新跑 `tests/test_regression_r4.py` (30 条用例,含 detect_media_intent 19 个边界)
|
||||||
|
- [ ] 调用 `GET /app/api/health/bug-audit` 确认 healthy=true (16 项检查)
|
||||||
|
- [ ] 手动测试一条普通文字(如"你好")→ 必须走 text 模型
|
||||||
|
- [ ] 手动测试一条图像指令(如"画一只猫")→ 必须走 image 模型
|
||||||
|
- [ ] 修改关键词白名单 → 同步更新本文档 §四
|
||||||
|
- [ ] 新增能力类型 → 同步更新本文档 §三 + capability_registry.py CAPABILITY_TYPES
|
||||||
|
|
||||||
|
## 六、回归防护网
|
||||||
|
|
||||||
|
| 层级 | 机制 | 触发时机 |
|
||||||
|
|------|------|---------|
|
||||||
|
| pre-commit | bug-audit API + Python syntax | git commit |
|
||||||
|
| pre-push | bug-audit + pytest + vitest | git push |
|
||||||
|
| unit test | test_regression_r4.py (30 cases) | pytest |
|
||||||
|
| runtime guard | chat_service.py defensive guard | 每次用户请求 |
|
||||||
|
| bug-audit API | /api/health/bug-audit (16 checks) | 手动/CI |
|
||||||
@@ -90,7 +90,7 @@ const App: React.FC = () => {
|
|||||||
localStorage.removeItem('aiflow_token');
|
localStorage.removeItem('aiflow_token');
|
||||||
localStorage.removeItem('aiflow_user');
|
localStorage.removeItem('aiflow_user');
|
||||||
};
|
};
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-screen">加载中...</div>;
|
return <div className="loading-screen">加载中...</div>;
|
||||||
@@ -130,7 +130,7 @@ const App: React.FC = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Admin Routes */}
|
{/* Admin Routes */}
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={isAdmin ? <AdminLayout /> : <Navigate to="/login" replace />}>
|
||||||
<Route index element={<AdminDashboard />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
|
|||||||
@@ -194,13 +194,7 @@ const AdminLayout: React.FC = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 后台必须登录且为管理员
|
// 后台必须登录且为管理员
|
||||||
useEffect(() => {
|
// Auth guard moved to App.tsx route level (no race condition)
|
||||||
const token = localStorage.getItem('aiflow_token');
|
|
||||||
if (!token || !user || user.role !== 'admin') {
|
|
||||||
logout();
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
}
|
|
||||||
}, [user, logout, navigate]);
|
|
||||||
|
|
||||||
const navCrumb = (() => {
|
const navCrumb = (() => {
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,6 @@ const FrontLayout: React.FC = () => {
|
|||||||
<span className="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/><path d="m4.93 4.93 2.83 2.83m8.48 8.48 2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48 2.83-2.83"/></svg></span>
|
<span className="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/><path d="m4.93 4.93 2.83 2.83m8.48 8.48 2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48 2.83-2.83"/></svg></span>
|
||||||
{!collapsed && <span className="nav-label">Agent 编排</span>}
|
{!collapsed && <span className="nav-label">Agent 编排</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
|
|
||||||
<span className="nav-item-icon">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
|
||||||
</span>
|
|
||||||
{!collapsed && <span className="nav-item-label">返回首页</span>}
|
|
||||||
</NavLink>
|
|
||||||
{navGroups.map(group => (
|
{navGroups.map(group => (
|
||||||
<div className="nav-section" key={group.title}>
|
<div className="nav-section" key={group.title}>
|
||||||
{!collapsed && <div className="nav-group-title">{group.title}</div>}
|
{!collapsed && <div className="nav-group-title">{group.title}</div>}
|
||||||
@@ -161,7 +155,7 @@ const FrontLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user.role === 'admin' && (
|
{user.role === 'admin' || user.role === 'super_admin' && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const capabilityLabels: Record<string, string> = {
|
|||||||
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
|
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 锁定项:文本对话(text) 与 推理思考(thinking) 强制使用平台默认对话模型,
|
||||||
|
// 防止被误配到失效模型导致 chat 不可用(见全栈体检修复记录)。
|
||||||
|
const LOCKED_CAP_DEFAULTS: Record<string, string> = { text: 'aaae3233-4fd9-4b3e-ab51-cd8a87c82e0b', thinking: 'aaae3233-4fd9-4b3e-ab51-cd8a87c82e0b' };
|
||||||
|
|
||||||
// Test result type
|
// Test result type
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
@@ -527,6 +531,8 @@ const Models: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const cur = capabilityDefaults[cap.id] || '';
|
const cur = capabilityDefaults[cap.id] || '';
|
||||||
const saving = savingCaps.has(cap.id);
|
const saving = savingCaps.has(cap.id);
|
||||||
|
const lockedModelId = LOCKED_CAP_DEFAULTS[cap.id];
|
||||||
|
const isLocked = Boolean(lockedModelId);
|
||||||
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
|
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
|
||||||
return (
|
return (
|
||||||
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
|
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
|
||||||
@@ -537,8 +543,8 @@ const Models: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
|
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
|
||||||
<select
|
<select
|
||||||
value={cur}
|
value={isLocked ? (lockedModelId as string) : cur}
|
||||||
disabled={saving}
|
disabled={saving || isLocked}
|
||||||
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
|
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
|
||||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
|
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
|
||||||
>
|
>
|
||||||
@@ -549,6 +555,9 @@ const Models: React.FC = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{isLocked && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#16A34A', marginTop: '6px' }}>🔒 已锁定为平台默认模型,不可更改</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -85,14 +85,14 @@ const LoginPage: React.FC = () => {
|
|||||||
createdAt: userData.created_at || new Date().toISOString(),
|
createdAt: userData.created_at || new Date().toISOString(),
|
||||||
membership: userData.membership_level > 0 ? {
|
membership: userData.membership_level > 0 ? {
|
||||||
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
|
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
|
||||||
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
planName: userData.role === 'admin' || userData.role === 'super_admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
||||||
level: userData.membership_level,
|
level: userData.membership_level,
|
||||||
expireAt: userData.membership_expire_at || '',
|
expireAt: userData.membership_expire_at || '',
|
||||||
autoRenew: userData.auto_renew || false,
|
autoRenew: userData.auto_renew || false,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
};
|
};
|
||||||
login(userObj);
|
login(userObj);
|
||||||
navigate(userData.role === 'admin' ? '/admin' : '/chat');
|
navigate(userData.role === 'admin' || userData.role === 'super_admin' ? '/admin' : '/chat');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
|
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
|
||||||
const status = e.status || e.response?.status || 0;
|
const status = e.status || e.response?.status || 0;
|
||||||
|
|||||||
Executable
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# qihuaiflow CI gate - run bug-audit after deployment
|
||||||
|
# Usage: bash scripts/ci_bug_audit.sh [--block-on-fail]
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
CONTAINER="qihu-aiflow"
|
||||||
|
BLOCK=${1:-}
|
||||||
|
AUDIT_URL="http://localhost:18080/app/api/health/bug-audit"
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||||
|
echo "[CI aud] ERROR: container $CONTAINER not running"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUDIT=$(curl -s --max-time 20 "$AUDIT_URL" 2>/dev/null)
|
||||||
|
if [ -z "$AUDIT" ]; then
|
||||||
|
echo "[CI aud] ERROR: bug-audit endpoint unreachable"
|
||||||
|
[ "$BLOCK" = "--block-on-fail" ] && exit 1 || exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEALTHY=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('healthy',''))" 2>/dev/null)
|
||||||
|
CHECKS=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('summary',{}).get('total_checks',0))" 2>/dev/null)
|
||||||
|
ISSUES=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('data',d).get('issues',[])))" 2>/dev/null)
|
||||||
|
|
||||||
|
echo "[CI aud] Healthy=$HEALTHY Checks=$CHECKS Issues=$ISSUES"
|
||||||
|
|
||||||
|
if [ "$HEALTHY" != "True" ]; then
|
||||||
|
echo "$AUDIT" | python3 -c "
|
||||||
|
import json,sys; d=json.load(sys.stdin)
|
||||||
|
dd=d.get('data',d)
|
||||||
|
for i in dd.get('issues',[]): print(f' ISSUE: {i}')
|
||||||
|
for w in dd.get('warnings',[]): print(f' WARN: {w}')
|
||||||
|
" 2>/dev/null
|
||||||
|
if [ "$BLOCK" = "--block-on-fail" ]; then
|
||||||
|
echo "[CI aud] audit failed (--block-on-fail mode)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "[CI aud] PASSED"
|
||||||
|
exit 0
|
||||||
@@ -1,58 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# MuMuAINovel Deployment Script
|
|
||||||
# Run when Docker Hub / GitHub are accessible
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
echo "=== MuMuAINovel Deploy (SSO + Billing) ==="
|
||||||
|
|
||||||
echo "=== MuMuAINovel Deployment ==="
|
# Step 1: Start MumuAINovel
|
||||||
|
echo "[1/5] Starting MuMuAINovel..."
|
||||||
|
docker rm -f qihu-aiflow-mumuainovel 2>/dev/null || true
|
||||||
|
docker run -d --name qihu-aiflow-mumuainovel --network qihuaiflow_aiflow-network --restart unless-stopped -e DATABASE_URL=postgresql+asyncpg://aiflow:pwd2026@qihu-aiflow-pg18:5432/mumuainovel -e DB_HOST=qihu-aiflow-pg18 -e DB_PORT=5432 -e POSTGRES_USER=aiflow -e POSTGRES_DB=mumuainovel -e POSTGRES_PASSWORD=pwd2026 -e OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1 -e OPENAI_API_KEY=sk-tvL0o8huXZI9dklXx6t3Mg -e DEFAULT_AI_PROVIDER=openai -e DEFAULT_MODEL=deepseek-v4-flash -e LOCAL_AUTH_ENABLED=true -e LOCAL_AUTH_USERNAME=mumu_admin -e LOCAL_AUTH_PASSWORD=mumu_admin_2026 -e LINUXDO_AUTH_ENABLED=false -e PLATFORM_SSO_ENABLED=true -e PLATFORM_AUTH_HEADER=X-User-Id -p 18002:8000 mumujie/mumuainovel:latest
|
||||||
|
|
||||||
# Step 1: Database
|
# Step 2: Start OIDC sidecar
|
||||||
echo "[1/4] Creating mumuainovel database..."
|
echo "[2/5] Starting OIDC sidecar..."
|
||||||
docker exec qihu-aiflow-pg18 psql -U aiflow -d aiflow -c "CREATE DATABASE mumuainovel OWNER aiflow;" 2>/dev/null || echo " Database already exists"
|
docker rm -f qihu-aiflow-mumuainovel-oidc 2>/dev/null || true
|
||||||
|
docker run -d --name qihu-aiflow-mumuainovel-oidc --network qihuaiflow_aiflow-network --restart unless-stopped -v /www/wwwroot/qihuaiflow/config/nginx/mumuainovel-oidc.conf:/etc/nginx/conf.d/default.conf -p 4180:4180 nginx:alpine
|
||||||
|
|
||||||
# Step 2: Pull image
|
# Step 3: Wait for healthy
|
||||||
echo "[2/4] Pulling MuMuAINovel image..."
|
echo "[3/5] Waiting for startup..."
|
||||||
# Try Docker Hub direct first, then proxies
|
sleep 20
|
||||||
docker pull mumujie/mumuainovel:latest 2>/dev/null || {
|
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
|
||||||
echo " Docker Hub failed, trying mirror..."
|
|
||||||
docker pull docker.1ms.run/mumujie/mumuainovel:latest 2>/dev/null || {
|
|
||||||
echo " Mirror failed, building from source..."
|
|
||||||
cd /tmp && rm -rf MuMuAINovel
|
|
||||||
git clone --depth 1 https://github.com/xiamuceer-j/MuMuAINovel.git
|
|
||||||
cd MuMuAINovel && docker build -t mumujie/mumuainovel:latest .
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 3: Start container
|
|
||||||
echo "[3/4] Starting MuMuAINovel..."
|
|
||||||
docker stop qihu-aiflow-mumuainovel 2>/dev/null || true
|
|
||||||
docker rm qihu-aiflow-mumuainovel 2>/dev/null || true
|
|
||||||
docker run -d \
|
|
||||||
--name qihu-aiflow-mumuainovel \
|
|
||||||
--network qihuaiflow_aiflow-network \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-e OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1 \
|
|
||||||
-e OPENAI_API_KEY=sk-litellm-master-key-aiflow-2026 \
|
|
||||||
-e DEFAULT_AI_PROVIDER=openai \
|
|
||||||
-e DEFAULT_MODEL=deepseek-v4-flash \
|
|
||||||
-e POSTGRES_HOST=qihu-aiflow-pg18 \
|
|
||||||
-e POSTGRES_DB=mumuainovel \
|
|
||||||
-e POSTGRES_USER=aiflow \
|
|
||||||
-e POSTGRES_PASSWORD=pwd2026 \
|
|
||||||
-e LOCAL_AUTH_ENABLED=true \
|
|
||||||
-e LOCAL_AUTH_USERNAME=mumu_admin \
|
|
||||||
-e LOCAL_AUTH_PASSWORD=mumu_admin_2026 \
|
|
||||||
-e LINUXDO_AUTH_ENABLED=false \
|
|
||||||
mumujie/mumuainovel:latest
|
|
||||||
|
|
||||||
# Step 4: Verify
|
# Step 4: Verify
|
||||||
echo "[4/4] Waiting for startup..."
|
echo "[4/5] Verifying..."
|
||||||
sleep 15
|
curl -sf http://localhost:18002/health && echo " Mumu OK"
|
||||||
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
|
curl -sf http://localhost:4180/health && echo " SSO OK (no auth needed for health)"
|
||||||
curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:18001/ && echo " - MuMuAINovel is UP" || echo " - Not ready yet"
|
curl -s -o /dev/null -w "Auth Verify: HTTP %{http_code}" http://localhost:18080/app/api/auth/verify && echo
|
||||||
|
curl -s -o /dev/null -w "Billing: HTTP %{http_code}" http://localhost:18080/app/api/billing/webhook/health && echo
|
||||||
|
|
||||||
echo "=== Done ==="
|
# Step 5: Summary
|
||||||
echo "Access: http://210.16.161.218:18001"
|
echo "[5/5] Done!"
|
||||||
echo "Login: mumu_admin / mumu_admin_2026"
|
echo "Direct: http://210.16.161.218:18002"
|
||||||
echo "AI: powered by LiteLLM gateway (deepseek-v4-flash)"
|
echo "SSO: http://210.16.161.218:4180"
|
||||||
|
echo "Login: mumu_admin / mumu_admin_2026"
|
||||||
|
echo "AI: LiteLLM gateway (deepseek-v4-flash)"
|
||||||
|
echo "Billing: webhook active -> user=mumuainovel-app"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
CONTAINER="qihu-aiflow"
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
|
||||||
|
fail() { echo -e "${RED}[pre-commit] FAIL: $*${NC}" >&2; exit 1; }
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||||
|
echo "[pre-commit] WARNING: container $CONTAINER not running, skip" >&2; exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUDIT_JSON=$(curl -s --max-time 15 http://localhost:18080/app/api/health/bug-audit 2>/dev/null)
|
||||||
|
if [ -z "$AUDIT_JSON" ]; then
|
||||||
|
echo "[pre-commit] WARNING: bug-audit not reachable, skip" >&2; exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEALTHY=$(echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);print(dd.get('healthy',''))" 2>/dev/null)
|
||||||
|
if [ "$HEALTHY" != "True" ]; then
|
||||||
|
echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);[print(' ISSUE:',i) for i in dd.get('issues',[])]" 2>/dev/null
|
||||||
|
fail "bug-audit returned unhealthy"
|
||||||
|
fi
|
||||||
|
echo "[pre-commit] bug-audit PASSED"; exit 0
|
||||||
Reference in new issue
Block a user