fix(im_llm): 代理绕过Docker内部hostname

This commit is contained in:
QihuAI Design committed 2026-07-31 08:51:15 +08:00
1 parent 96aabc31f6
commit 9a43ee244d
24 files changed
+872 -103

No files matched your search

+20 -3
View File
@@ -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 {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
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["details"]["tools_count"] = len(tools)
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
if tools:
server.tools_count = len(tools)
server.status = "connected"
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":
+68
View File
@@ -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",
},
)
+118
View File
@@ -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"}
+86
View File
@@ -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),
}
}
+1 -1
View File
@@ -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()
+8
View File
@@ -506,6 +506,10 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
# 调用 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,6 +940,10 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
# 调用 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)
+7
View File
@@ -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):
+37 -6
View File
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
"""
import asyncio
import os
import json
import logging
import re as _re
@@ -48,7 +49,8 @@ def _proxy_for_url(url: str) -> str | None:
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
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
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
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)=====
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:
ai_model = resolve_auto_model(intent, db)
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()
api_key = ai_model.api_key or (provider.api_key if provider else None)
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:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
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 = ""
_http_ok = True
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:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
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(
api_url,
api_key,
@@ -235,17 +248,30 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
proxy=_proxy_for_url(api_url),
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:
_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} 异常详情: {_tb.format_exc()[:2000]}")
_http_ok = False
break
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
# 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 "",
"content": message.get("content") or message.get("reasoning_content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
@@ -260,15 +286,20 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
if round_num >= max_rounds - 1:
break
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:
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
except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
+1 -1
View File
@@ -122,6 +122,6 @@ async def get_current_admin(
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="需要管理员权限")
return user
+14 -5
View File
@@ -43,12 +43,14 @@ def _transport_cm(server: dict, timeout: float):
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 -> 目标请求)。
返回解析后的 dict:{"result": {...}} 或 None(失败)。
协议版本由 SDK 自动协商,不再硬编码。
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
"""
url = (server.get("url") or "").strip()
if not url:
@@ -89,33 +91,40 @@ async def _session_request(
}
except Exception as e:
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
if raise_on_error:
raise
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 服务器的工具列表。
server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
"""
url = (server.get("url") or "").strip()
if not url:
return []
# 缓存
# 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
if not raise_on_error:
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
try:
data = await _session_request(server, "tools/list", {}, timeout)
data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
tools = []
if data and "result" in data:
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
except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
if raise_on_error:
raise
return []
+6
View File
@@ -40,6 +40,9 @@ from datetime import datetime
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.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.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
@@ -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(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(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")
+143
View File
@@ -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
View File
@@ -1,29 +1,74 @@
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
litellm_params:
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
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
litellm_params:
model: openai/mimo-v2.5
api_base: https://token-plan-cn.xiaomimimo.com/v1
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
rpm: 500
- model_name: qwen3-vl-32b
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
headers:
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
- model_name: TeleAI/TeleSpeechASR
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_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
rpm: 300
litellm_settings:
drop_params: true
@@ -33,9 +78,10 @@ general_settings:
master_key: sk-litellm-master-key-aiflow-2026
router_settings:
routing_strategy: "usage-based-routing-v2"
allowed_fails: 2
num_retries: 2
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"]
+88
View File
@@ -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"]
+1 -1
View File
@@ -10,7 +10,7 @@ server {
# Proxy to MuMuAINovel
location / {
proxy_pass http://mumuainovel:8000;
proxy_pass http://qihu-aiflow-mumuainovel:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+109
View File
@@ -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 |
+2 -2
View File
@@ -90,7 +90,7 @@ const App: React.FC = () => {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
@@ -130,7 +130,7 @@ const App: React.FC = () => {
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route path="/admin" element={isAdmin ? <AdminLayout /> : <Navigate to="/login" replace />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
@@ -194,13 +194,7 @@ const AdminLayout: React.FC = () => {
const location = useLocation();
// 后台必须登录且为管理员
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
if (!token || !user || user.role !== 'admin') {
logout();
navigate('/login', { replace: true });
}
}, [user, logout, navigate]);
// Auth guard moved to App.tsx route level (no race condition)
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>
{!collapsed && <span className="nav-label">Agent 编排</span>}
</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 => (
<div className="nav-section" key={group.title}>
{!collapsed && <div className="nav-group-title">{group.title}</div>}
@@ -161,7 +155,7 @@ const FrontLayout: React.FC = () => {
</div>
)}
</div>
{user.role === 'admin' && (
{user.role === 'admin' || user.role === 'super_admin' && (
<NavLink
to="/admin"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
+11 -2
View File
@@ -26,6 +26,10 @@ const capabilityLabels: Record<string, string> = {
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
interface TestResult {
modelId: string;
@@ -527,6 +531,8 @@ const Models: React.FC = () => {
);
const cur = capabilityDefaults[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';
return (
<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 style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
<select
value={cur}
disabled={saving}
value={isLocked ? (lockedModelId as string) : cur}
disabled={saving || isLocked}
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' }}
>
@@ -549,6 +555,9 @@ const Models: React.FC = () => {
</option>
))}
</select>
{isLocked && (
<div style={{ fontSize: '11px', color: '#16A34A', marginTop: '6px' }}>🔒 已锁定为平台默认模型,不可更改</div>
)}
</div>
);
})}
+2 -2
View File
@@ -85,14 +85,14 @@ const LoginPage: React.FC = () => {
createdAt: userData.created_at || new Date().toISOString(),
membership: userData.membership_level > 0 ? {
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,
expireAt: userData.membership_expire_at || '',
autoRenew: userData.auto_renew || false,
} : undefined,
};
login(userObj);
navigate(userData.role === 'admin' ? '/admin' : '/chat');
navigate(userData.role === 'admin' || userData.role === 'super_admin' ? '/admin' : '/chat');
} catch (e: any) {
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
const status = e.status || e.response?.status || 0;
+40
View File
@@ -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
+24 -49
View File
@@ -1,58 +1,33 @@
#!/bin/bash
# MuMuAINovel Deployment Script
# Run when Docker Hub / GitHub are accessible
# ============================================
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
echo "[1/4] Creating mumuainovel database..."
docker exec qihu-aiflow-pg18 psql -U aiflow -d aiflow -c "CREATE DATABASE mumuainovel OWNER aiflow;" 2>/dev/null || echo " Database already exists"
# Step 2: Start OIDC sidecar
echo "[2/5] Starting OIDC sidecar..."
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
echo "[2/4] Pulling MuMuAINovel image..."
# Try Docker Hub direct first, then proxies
docker pull mumujie/mumuainovel:latest 2>/dev/null || {
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 3: Wait for healthy
echo "[3/5] Waiting for startup..."
sleep 20
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
# Step 4: Verify
echo "[4/4] Waiting for startup..."
sleep 15
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:18001/ && echo " - MuMuAINovel is UP" || echo " - Not ready yet"
echo "[4/5] Verifying..."
curl -sf http://localhost:18002/health && echo " Mumu OK"
curl -sf http://localhost:4180/health && echo " SSO OK (no auth needed for health)"
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 ==="
echo "Access: http://210.16.161.218:18001"
# Step 5: Summary
echo "[5/5] Done!"
echo "Direct: http://210.16.161.218:18002"
echo "SSO: http://210.16.161.218:4180"
echo "Login: mumu_admin / mumu_admin_2026"
echo "AI: powered by LiteLLM gateway (deepseek-v4-flash)"
echo "AI: LiteLLM gateway (deepseek-v4-flash)"
echo "Billing: webhook active -> user=mumuainovel-app"
+21
View File
@@ -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