From 9a43ee244d422d0531fa03d19933018675c1145c Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Fri, 31 Jul 2026 08:51:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(im=5Fllm):=20=E4=BB=A3=E7=90=86=E7=BB=95?= =?UTF-8?q?=E8=BF=87Docker=E5=86=85=E9=83=A8hostname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/admin/mcp_admin.py | 23 ++- backend/python/app/api/auth_verify.py | 68 +++++++++ backend/python/app/api/billing_webhook.py | 118 +++++++++++++++ backend/python/app/api/bug_audit.py | 86 +++++++++++ backend/python/app/api/chat.py | 2 +- backend/python/app/api/chat_media.py | 12 +- backend/python/app/api/chat_service.py | 7 + backend/python/app/api/im_llm.py | 81 +++++++--- backend/python/app/auth.py | 2 +- backend/python/app/mcp_client.py | 27 ++-- backend/python/main.py | 6 + backend/python/tests/test_regression_r4.py | 143 ++++++++++++++++++ config/litellm_config.yaml | 80 +++++++--- config/litellm_config.yaml.git | 88 +++++++++++ config/nginx/mumuainovel-oidc.conf | 2 +- docs/CAPABILITY_ROUTING.md | 109 +++++++++++++ frontend/src/App.tsx | 4 +- .../src/components/layout/AdminLayout.tsx | 8 +- .../src/components/layout/FrontLayout.tsx | 8 +- frontend/src/pages/admin/Models.tsx | 13 +- frontend/src/pages/front/LoginPage.tsx | 4 +- scripts/ci_bug_audit.sh | 40 +++++ scripts/deploy_mumuainovel.sh | 75 +++------ scripts/git-hooks/pre-commit | 21 +++ 24 files changed, 898 insertions(+), 129 deletions(-) create mode 100644 backend/python/app/api/auth_verify.py create mode 100644 backend/python/app/api/billing_webhook.py create mode 100644 backend/python/app/api/bug_audit.py create mode 100644 backend/python/tests/test_regression_r4.py create mode 100644 config/litellm_config.yaml.git create mode 100644 docs/CAPABILITY_ROUTING.md create mode 100755 scripts/ci_bug_audit.sh create mode 100755 scripts/git-hooks/pre-commit diff --git a/backend/python/app/api/admin/mcp_admin.py b/backend/python/app/api/admin/mcp_admin.py index b11e6f8..8dd6eed 100644 --- a/backend/python/app/api/admin/mcp_admin.py +++ b/backend/python/app/api/admin/mcp_admin.py @@ -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": diff --git a/backend/python/app/api/auth_verify.py b/backend/python/app/api/auth_verify.py new file mode 100644 index 0000000..53d65f0 --- /dev/null +++ b/backend/python/app/api/auth_verify.py @@ -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 + 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", + }, + ) diff --git a/backend/python/app/api/billing_webhook.py b/backend/python/app/api/billing_webhook.py new file mode 100644 index 0000000..3960f3a --- /dev/null +++ b/backend/python/app/api/billing_webhook.py @@ -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"} diff --git a/backend/python/app/api/bug_audit.py b/backend/python/app/api/bug_audit.py new file mode 100644 index 0000000..19b3b8c --- /dev/null +++ b/backend/python/app/api/bug_audit.py @@ -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), + } + } diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 747b22c..bf52e25 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -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() diff --git a/backend/python/app/api/chat_media.py b/backend/python/app/api/chat_media.py index 29537dd..a6e047e 100644 --- a/backend/python/app/api/chat_media.py +++ b/backend/python/app/api/chat_media.py @@ -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) diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index eebd856..fbf04d5 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -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): diff --git a/backend/python/app/api/im_llm.py b/backend/python/app/api/im_llm.py index 6888219..b819890 100644 --- a/backend/python/app/api/im_llm.py +++ b/backend/python/app/api/im_llm.py @@ -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,40 +248,58 @@ 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", []) - if tool_calls and skills: + 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 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({ - "role": "assistant", - "content": message.get("content") or "", - "tool_calls": tool_calls, + "role": "tool", + "tool_call_id": tc.get("id", "call_1"), + "content": tool_result, }) - 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({ - "role": "tool", - "tool_call_id": tc.get("id", "call_1"), - "content": tool_result, - }) - continue - ai_content = message.get("content", "") - break + if round_num >= max_rounds - 1: + break + continue + 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]}") diff --git a/backend/python/app/auth.py b/backend/python/app/auth.py index c3c759d..d658f63 100644 --- a/backend/python/app/auth.py +++ b/backend/python/app/auth.py @@ -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 diff --git a/backend/python/app/mcp_client.py b/backend/python/app/mcp_client.py index 8a4d24f..574b038 100644 --- a/backend/python/app/mcp_client.py +++ b/backend/python/app/mcp_client.py @@ -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 [] - # 缓存 - now = asyncio.get_event_loop().time() - cached = _TOOLS_CACHE.get(url) - if cached and (now - cached[0]) < _TOOLS_CACHE_TTL: - return cached[1] + # 缓存(仅在正常返回 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 [] diff --git a/backend/python/main.py b/backend/python/main.py index 66bb5da..74e7035 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -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") diff --git a/backend/python/tests/test_regression_r4.py b/backend/python/tests/test_regression_r4.py new file mode 100644 index 0000000..9007b02 --- /dev/null +++ b/backend/python/tests/test_regression_r4.py @@ -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" diff --git a/config/litellm_config.yaml b/config/litellm_config.yaml index 5b67cb3..69bd0ed 100644 --- a/config/litellm_config.yaml +++ b/config/litellm_config.yaml @@ -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"] diff --git a/config/litellm_config.yaml.git b/config/litellm_config.yaml.git new file mode 100644 index 0000000..69bd0ed --- /dev/null +++ b/config/litellm_config.yaml.git @@ -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"] diff --git a/config/nginx/mumuainovel-oidc.conf b/config/nginx/mumuainovel-oidc.conf index e6516a5..840a5ec 100644 --- a/config/nginx/mumuainovel-oidc.conf +++ b/config/nginx/mumuainovel-oidc.conf @@ -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; diff --git a/docs/CAPABILITY_ROUTING.md b/docs/CAPABILITY_ROUTING.md new file mode 100644 index 0000000..9548917 --- /dev/null +++ b/docs/CAPABILITY_ROUTING.md @@ -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 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 792be0c..148c024 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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
加载中...
; @@ -130,7 +130,7 @@ const App: React.FC = () => { {/* Admin Routes */} - }> + : }> } /> } /> } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index ca4415b..19cea14 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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 = (() => { diff --git a/frontend/src/components/layout/FrontLayout.tsx b/frontend/src/components/layout/FrontLayout.tsx index 362373b..9974a09 100644 --- a/frontend/src/components/layout/FrontLayout.tsx +++ b/frontend/src/components/layout/FrontLayout.tsx @@ -109,12 +109,6 @@ const FrontLayout: React.FC = () => { {!collapsed && Agent 编排} - `nav-item nav-home-link`} end> - - - - {!collapsed && 返回首页} - {navGroups.map(group => (
{!collapsed &&
{group.title}
} @@ -161,7 +155,7 @@ const FrontLayout: React.FC = () => {
)} - {user.role === 'admin' && ( + {user.role === 'admin' || user.role === 'super_admin' && ( `nav-item ${isActive ? 'active' : ''}`} diff --git a/frontend/src/pages/admin/Models.tsx b/frontend/src/pages/admin/Models.tsx index fc81ae6..4692033 100644 --- a/frontend/src/pages/admin/Models.tsx +++ b/frontend/src/pages/admin/Models.tsx @@ -26,6 +26,10 @@ const capabilityLabels: Record = { embedding: '嵌入向量', rerank: '重排序', video: '视频生成', }; +// 锁定项:文本对话(text) 与 推理思考(thinking) 强制使用平台默认对话模型, +// 防止被误配到失效模型导致 chat 不可用(见全栈体检修复记录)。 +const LOCKED_CAP_DEFAULTS: Record = { 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 (
@@ -537,8 +543,8 @@ const Models: React.FC = () => {
{cap.desc}
+ {isLocked && ( +
🔒 已锁定为平台默认模型,不可更改
+ )} ); })} diff --git a/frontend/src/pages/front/LoginPage.tsx b/frontend/src/pages/front/LoginPage.tsx index 8f8cc4a..ca8adba 100644 --- a/frontend/src/pages/front/LoginPage.tsx +++ b/frontend/src/pages/front/LoginPage.tsx @@ -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; diff --git a/scripts/ci_bug_audit.sh b/scripts/ci_bug_audit.sh new file mode 100755 index 0000000..2a35782 --- /dev/null +++ b/scripts/ci_bug_audit.sh @@ -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 diff --git a/scripts/deploy_mumuainovel.sh b/scripts/deploy_mumuainovel.sh index c069036..0c5913a 100755 --- a/scripts/deploy_mumuainovel.sh +++ b/scripts/deploy_mumuainovel.sh @@ -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" -echo "Login: mumu_admin / mumu_admin_2026" -echo "AI: powered by LiteLLM gateway (deepseek-v4-flash)" +# 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: LiteLLM gateway (deepseek-v4-flash)" +echo "Billing: webhook active -> user=mumuainovel-app" diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit new file mode 100755 index 0000000..94e4f77 --- /dev/null +++ b/scripts/git-hooks/pre-commit @@ -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