diff --git a/backend/python/app/api/admin/mcp_admin.py b/backend/python/app/api/admin/mcp_admin.py index e085456..d936831 100644 --- a/backend/python/app/api/admin/mcp_admin.py +++ b/backend/python/app/api/admin/mcp_admin.py @@ -842,110 +842,29 @@ def _test_mcp_server(server, db) -> dict: if transport in ("streamable_http", "streamablehttp"): - - - url = server.url - - - if not url: - - - result["message"] = "Streamable HTTP endpoint URL is required" - - - else: - - - - import httpx - - - try: - - - - # A real streamable-http MCP endpoint accepts POST JSON-RPC. - - - - # Some implementations also accept GET for SSE events. - - - - # Try GET first (cheapest), fall back to a minimal POST. - - - - resp = httpx.get(url, headers=req_headers, timeout=10.0, follow_redirects=True) - - - - if resp.status_code == 405: - - - - resp = httpx.post( - url, - headers=req_headers, - json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, - timeout=10.0, - follow_redirects=True, - ) - - - - # Any HTTP response (including 4xx) means the host is reachable. - - - - result["success"] = resp.status_code < 500 - - - - result["message"] = f"HTTP responded with {resp.status_code}" - - - - result["details"]["status_code"] = resp.status_code - - - - result["details"]["content_type"] = resp.headers.get("content-type", "") - - - - except httpx.ConnectError as e: - - - - result["message"] = f"Connection refused - server may be down: {str(e)}" - - - - except httpx.TimeoutException: - - - - result["message"] = "Connection timed out" - - - + # Use the real MCP SDK to verify connectivity (same as chat flow). + # A naive HTTP GET/POST can give false negatives for endpoints + # that only accept proper MCP JSON-RPC over POST (e.g., AnySearch). + from ...mcp_client import list_mcp_tools as _lmt + 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)) + 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" + result["details"]["status_code"] = 200 + if tools: + server.tools_count = len(tools) + server.status = "connected" except Exception as e: - - - - result["message"] = f"HTTP request failed: {str(e)}" - - - - - - + result["message"] = f"Connection failed: {str(e)}" elif transport == "sse": @@ -3156,7 +3075,7 @@ async def test_mcp_connection( - result = _test_mcp_server(server, db) + result = await asyncio.get_running_loop().run_in_executor(None, _test_mcp_server, server, db) diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index 46e489c..56c6871 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -1,8549 +1,8549 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). - - - -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - - - - - - - -from fastapi import APIRouter, Depends, HTTPException, Query, Response - - - -from sqlalchemy.orm import Session - - - -from sqlalchemy import and_, desc, asc, func - - - -from pydantic import BaseModel, Field - - - -from typing import Optional - - - -import uuid - - - -from datetime import datetime, timezone, timedelta - - - -import re - - - -import json - - - -import asyncio - - - -import math - - - -import httpx - - - -import os - - - -import logging - - - -from ..database import get_db, SessionLocal - - - -from app.core.observability import span, get_trace_id - - - -from ..models import ( - - - - - - - - - - - - - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - - - - - - - - - - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - - - - - - - - - - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - - - - - - - - - - - - - MCPServer, - - - - - - - - - - - - - - - - gen_uuid - - - - - - - - - - - - - - - -) - - - -from ..auth import get_current_user - - - -from ..platform_knowledge import get_platform_knowledge - - - -from ..mcp_client import list_mcp_tools, call_mcp_tool - - - -from ..middleware.log_service import LogService - - - -from ..capability_registry import resolve_auto_model - - - -from app.api.chat_agents import (_call_external_agent) - - - -from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) - - - -from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) - - - -from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) - - - -from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills) - - - -from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) - -# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 - -from app.core.llm_client import achat_complete - - - - - - - -async def _send_message_core( - - - - - - - - - - - - - - - - conv_id: str, - - - - - - - - - - - - - - - - req: SendMessageRequest, - - - - - - - - - - - - - - - - db: Session = Depends(get_db), - - - - - - - - - - - - - - - - current_user: User = Depends(get_current_user), - stream: bool = False, - on_event=None, - - - - - - - - - - - - - - - -): - - - - - - - - - - - - - - - - conv = db.query(Conversation).filter( - - - - - - - - - - - - - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - - - - - - - - - - - - - ).first() - - - - - - - - - - - - - - - - if not conv: - - - - - - - - - - - - - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, - - - - - - - - - - - - - - - - # 从对话历史中继承上一次的选择 - - - - - - - - - - - - - - - - effective_skill_ids = req.skill_ids or [] - - - - - - - - - - - - - - - - effective_agent_ids = req.agent_ids or [] - - - - - - - - - - - - - - - - effective_kb_ids = req.knowledge_base_ids or [] - - - - - - - - - - - - - - - - effective_mcp_ids = req.mcp_ids or [] - - - - - - - - - - - - - - - - effective_use_tools = req.use_tools or False - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: - - - - - - - - - - - - - - - - # 从对话上下文继承 - - - - - - - - - - - - - - - - prev_context = _get_conversation_context(db, conv_id) - - - - - - - - - - - - - - - - if prev_context: - - - - - - - - - - - - - - - - effective_skill_ids = prev_context.get("skill_ids", []) - - - - - - - - - - - - - - - - effective_agent_ids = prev_context.get("agent_ids", []) - - - - - - - - - - - - - - - - effective_kb_ids = prev_context.get("knowledge_base_ids", []) - - - - - - - - - - - - - - - - effective_mcp_ids = prev_context.get("mcp_ids", []) - - - - - - - - - - - - - - - - if effective_skill_ids or effective_agent_ids or effective_mcp_ids: - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) - - - - - - - - - - - - - - - - attachment_block = "" - - - - - - - - - - - - - - - - if req.attachments: - - - - - - - - - - - - - - - - parts = [] - - - - - - - - - - - - - - - - for att in req.attachments: - - - - - - - - - - - - - - - - name = att.get("name", "附件") if isinstance(att, dict) else str(att) - - - - - - - - - - - - - - - - ftype = att.get("file_type", "") if isinstance(att, dict) else "" - - - - - - - - - - - - - - - - content = att.get("content", "") if isinstance(att, dict) else "" - - - - - - - - - - - - - - - - if content: - - - - - - - - - - - - - - - - parts.append(f"【附件: {name}】\n{content}") - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") - - - - - - - - - - - - - - - - if parts: - - - - - - - - - - - - - - - - attachment_block = "\n\n" + "\n\n".join(parts) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) - - - - - - - - - - - - - - - - user_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="user", - - - - - - - - - - - - - - - - content=req.content + attachment_block, - - - - - - - - - - - - - - - - token_used=len(req.content) // 2, - - - - - - - - - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - - - - - - - - - mcp_ids=req.mcp_ids if req.mcp_ids else None, - - - - - - - - - - - - - - - - attachments=req.attachments if req.attachments else None, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(user_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 模糊创作意图拦截 ===== - - - - - - - - - - - - - - - - # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, - - - - - - - - - - - - - - - - # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 - - - - - - - - - - - - - - - - clarify = detect_ambiguous_creative_intent(req.content) - - - - - - - - - - - - - - - - if clarify: - - - - - - - - - - - - - - - - clarify_content = "<>" + json.dumps( - - - - - - - - - - - - - - - - {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False - - - - - - - - - - - - - - - - ) + "<>" - - - - - - - - - - - - - - - - ai_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="assistant", - - - - - - - - - - - - - - - - content=clarify_content, - - - - - - - - - - - - - - - - token_used=0, - - - - - - - - - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(ai_msg) - - - - - - - - - - - - - - - - db.commit() - - - - - - - - - - - - - - - - db.refresh(user_msg) - - - - - - - - - - - - - - - - db.refresh(ai_msg) - - - - - - - - - - - - - - - - if on_event is not None: - try: - try: - _mid = ai_msg.id - except NameError: - _mid = None - try: - _dc = ai_content - except NameError: - _dc = (ai_msg.content if "ai_msg" in dir() else "") - try: - _ps = process_steps - except NameError: - _ps = [] - try: - _us = used_skills - except NameError: - _us = [] - try: - _um = used_mcp - except NameError: - _um = [] - try: - _mg = media_generating - except NameError: - _mg = False - await on_event({ - "type": "done", - "message_id": _mid, - "conversation_id": conv_id, - "content": _dc, - "process_steps": _ps, - "used_skills": _us, - "used_mcp": _um, - "generating": _mg, - }) - except Exception: - pass - - return { - - - - - - - - - - - - - - - - "code": 0, - - - - - - - - - - - - - - - - "data": { - - - - - - - - - - - - - - - - "user_message": { - - - - - - - - - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "assistant_message": { - - - - - - - - - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, - - - - - - - - - - - - - - - - "created_at": str(ai_msg.created_at), "clarify": clarify, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "message": "请选择创作方向", - - - - - - - - - - - - - - - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 核心聊天引擎 ===== - - - - - - - - - - - - - - - - user_id = current_user.id - - - - - - - - - - - - - - - - model_id = req.model_id or conv.model_id - - - - - - - - - - - - - - - - prompt_tokens = 0 - - - - - - - - - - - - - - - - completion_tokens = 0 - - - - - - - - - - - - - - - - total_tokens = 0 - - - - - - - - - - - - - - - - ai_content = "" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ai_model = None - - - - - - - - - - - - - - - - skills = [] # 初始化避免UnboundLocalError - - process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 - - async def _push_step(step: dict): - """追加过程步骤,并在流式场景实时推送给前端(step 事件)。""" - await _push_step(step) - if on_event is not None: - try: - await on_event({"type": "step", "step": step, "index": len(process_steps) - 1}) - except Exception: - pass - - - - - - - - - - - - - - - - - - agents = [] # 初始化避免UnboundLocalError - - - - - - - - - - - - - - - - media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 - - - - - - - - - - - - - - - - external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== - - - - - - - - - - - - - - - - # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, - - - - - - - - - - - - - - - - # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 - - - - - - - - - - - - - - - - external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - - - - - - - - - external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) - - - - - - - - # ===== 3.3 多 Agent 编排(图引擎)快路 ===== - - - - _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) - - - - if _orchestrator: - - - - try: - - - - from app.core.orchestrator import run_graph - - - - _answer, _trace = await run_graph( - - - - graph=_orchestrator["orchestration"], - - - - query=req.content + attachment_block, - - - - db=db, - - - - user_id=user_id, - - - - conv_id=conv_id, - - - - trace_id=get_trace_id(), - - - - ) - - - - ai_content = _answer or "编排未产生内容。" - - - - logger.info( - - - - f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" - - - - ) - - - - except Exception as e: - - - - logger.warning(f"[orchestrator] 图执行失败: {e}") - - - - ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" - - - - external_handled = True - - - - external_agent = None # 阻止下方外部 Agent 分支重复执行 - - - - - - - - - - - - - - - - if external_agent: - - - - - - - - - - - - - - - - agents = external_agents - - - - - - - - - - - - - - - - # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) - - - - - - - - - - - - - - - - _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - answer, new_ext_id = await _call_external_agent( - - - - - - - - - - - - - - - - db, external_agent, - - - - - - - - - - - - - - - - query=req.content + attachment_block, - - - - - - - - - - - - - - - - user_id=user_id, - - - - - - - - - - - - - - - - ext_conversation_id=conv.ext_agent_conversation_id, - - - - - - - - - - - - - - - - history=_history, - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - ai_content = answer or "外部 Agent 未返回内容。" - - - - - - - - - - - - - - - - if new_ext_id: - - - - - - - - - - - - - - - - conv.ext_agent_conversation_id = new_ext_id - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning( - - - - - - - - - - - - - - - - f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " - - - - - - - - - - - - - - - - f"endpoint={external_agent.get('agent_endpoint')}: {e}" - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" - - - - - - - - - - - - - - - - external_handled = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) - - - - - - - - - - - - - - - - if not external_handled: - - - - - - - - - - - - - - - - if model_id and model_id != "auto": - - - - - - - - - - - - - - - - ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() - - - - - - - - - - - - - - - - if not ai_model: - - - - - - - - - - - - - - - - # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 - - - - - - - - - - - - - - - - intent = detect_media_intent(req.content) - - - - - - - - - - - - - - - - resolved = resolve_auto_model(intent, db) if intent else None - - - - - - - - - - - - - - - - ai_model = resolved or resolve_auto_model("text", db) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== Model Failover: Auto 模式下自动切换备选模型 ===== - - _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] - - if not model_id or model_id == "auto": - - # Auto 模式:附加其他已启用的文本模型作为备选 - - _fallbacks = db.query(AIModel).filter( - - AIModel.is_enabled == True, - - AIModel.id != (ai_model.id if ai_model else "") - - ).order_by(AIModel.is_default.desc()).limit(5).all() - - _try_models.extend([m for m in _fallbacks if m not in _try_models]) - - _retry_log = [] - - _all_failed = False - - - - for _try_model in _try_models: - - ai_model = _try_model - - if ai_model: - - - - - - - - - - - - - - - - # ====== 图像/视频生成模型分支 ====== - - - - - - - - - - - - - - - - caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] - - - - - - - - - - - - - - - - media_generating = False - - - - - - - - - - - - - - - - if ai_model and ("image" in caps or "video" in caps): - - - - - - - - - - - - - - - - # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, - - - - - - - - - - - - - - - - # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 - - - - - - - - - - - - - - - - media_generating = True - - - - - - - - - - - - - - - - if "video" in caps: - - - - - - - - - - - - - - - - ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "⏳ 图像生成中…(完成后自动刷新)" - - - - - - - - - - - - - - - - elif ai_model and ai_model.endpoint: - - - - - - - - - - - - - - - - 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not api_url and provider and provider.api_base_url: - - - - - - - - - - - - - - - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if api_key and api_url: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - # Stage A+B: 解析Skills和Agents - - - - - - - - - - - - - - - - skills = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - - - - - - - - - agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - - - - - - - - - mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - - - - - - - - - # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions - - - - - - - - mcp_servers_conn = mcps - - - - - - - - mcp_function_definitions = [] - - - - - - - - mcp_tool_map = {} - - - - - - - - mcp_idx = 0 - - - - - - - - for _srv in mcp_servers_conn: - - - - - - - - try: - - - - - - - - async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): - - - - _tools = await list_mcp_tools(_srv) - - - - - - - - except Exception as _e: - - - - - - - - logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") - - - - - - - - _tools = [] - - - - - - - - if _tools: - - - - - - - - try: - - - - - - - - _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() - - - - - - - - if _upd: - - - - - - - - _upd.tools_count = len(_tools) - - - - - - - - db.commit() - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - for _t in _tools: - - - - - - - - _fname = f"mcp_tool_{mcp_idx}" - - - - - - - - mcp_tool_map[_fname] = { - - - - - - - - "server_id": _srv["id"], - - - - - - - - "name": _t.get("name"), - - - - - - - - "server_name": _srv["name"], - - - - - - - - } - - - - - - - - _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} - - - - - - - - if not isinstance(_schema, dict) or _schema.get("type") != "object": - - - - - - - - _schema = {"type": "object", "properties": {}} - - - - - - - - mcp_function_definitions.append({ - - - - - - - - "name": _fname, - - - - - - - - "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", - - - - - - - - "parameters": _schema, - - - - - - - - }) - - - - - - - - mcp_idx += 1 - - - - - - - - if mcp_function_definitions: - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) - - - - - - - - - - - - - - - - # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 - - - - - - - - - - - - - - - - used_auto_skills = False - - - - - - - - - - - - - - - - if not effective_skill_ids: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - discovered = _discover_installed_skills(db, user_id) - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"自动发现已安装技能失败: {e}") - - - - - - - - - - - - - - - - discovered = [] - - - - - - - - - - - - - - - - if discovered: - - - - - - - - - - - - - - - - skills = discovered - - - - - - - - - - - - - - - - used_auto_skills = True - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.1: Agent可能自带skill_ids和kb_ids - - - - - - - - - - - - - - - - agent_extra_skill_ids = [] - - - - - - - - - - - - - - - - agent_extra_kb_ids = [] - - - - - - - - - - - - - - - - agent_model_override = None - - - - - - - - - - - - - - - - for agent in agents: - - - - - - - - - - - - - - - - if agent.get("skill_ids"): - - - - - - - - - - - - - - - - agent_extra_skill_ids.extend(agent["skill_ids"]) - - - - - - - - - - - - - - - - if agent.get("knowledge_base_ids"): - - - - - - - - - - - - - - - - agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) - - - - - - - - - - - - - - - - if agent.get("model_id"): - - - - - - - - - - - - - - - - agent_model_override = agent["model_id"] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 合并Agent自带的skills和KBs - - - - - - - - - - - - - - - - all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) - - - - - - - - - - - - - - - - all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if agent_extra_skill_ids or agent_extra_kb_ids: - - - - - - - - - - - - - - - - # 重新解析合并后的skills - - - - - - - - - - - - - - - - skills = _resolve_market_skills(db, all_skill_ids, user_id) - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage C: 增强版KB检索 - - - - - - - - - - - - - - - - kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B: 动态组装system prompt + function definitions - - - - - - - - - - - - - - - - system_prompt, function_definitions = _build_system_prompt( - - - - - - - - - - - - - - - - skills=skills, - - - - - - - - - - - - - - - - agents=agents, - - - - - - - - - - - - - - - - kb_context=kb_context, - - - - - - - - - - - - - - - - user_content=req.content, - - - - - - - - - - - - - - - - mcp=mcps, - - - - - - - - mcp_functions=mcp_function_definitions, - - - - - - - - auto_discovered=used_auto_skills, - - - - all_mcp=_list_enabled_mcp_servers(db), - - - - db=db, - - - - ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 构建消息列表 - - - - - - - - - - - - - - - - # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) - - - - called_skill_names = [] - - - - called_mcp_names = [] - - - - - - - - messages = [{"role": "system", "content": system_prompt}] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) - - - - messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Issue 1: 必须把附件内容(attachment_block)一并发给模型, - - - - - - - - - - - - - - - - # 否则用户在对话窗上传文件后模型“看不到”文件内容。 - - - - - - - - - - - - - - - - # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 - - try: - - from app.api.url_reader import fetch_url_context - - _url_ctx = await fetch_url_context(req.content) - - except Exception as _e: - - import logging as _logging - - _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) - - _url_ctx = None - - if _url_ctx and _url_ctx.get("text"): - - messages.append({ - - "role": "system", - - "content": ( - - "【用户随消息提供的参考链接的真实内容】\n" - - f"链接地址: {_url_ctx.get('url')}\n" - - "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" - - "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," - - "不要凭空编造步骤:\n\n" + _url_ctx["text"] - - ), - - }) - - await _push_step({ - - "type": "skill", - - "name": "网页/仓库读取", - - "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", - - }) - - messages.append({"role": "user", "content": req.content + attachment_block}) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.3: Function Calling循环(最多5轮) - - - - - - - - - - - - - - - - max_tool_rounds = 5 - - - - - - - - - - - - - - - - # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== - - - - _agent_tool_policy = _resolve_agent_tool_policy(agents) - - - - _tool_call_count = 0 - - - - for round_num in range(max_tool_rounds): - - - - _round_tool_count = 0 - - - - - - - - - - - - - - - - request_body = { - - - - - - - - - - - - - - - - "model": ai_model.name, - - - - - - - - - - - - - - - - "messages": messages, - - - - - - - - - - - - - - - - "max_tokens": 2048, - - - - - - - - - - - - - - - - } - - - - - - - - - - - - - - - - if function_definitions and effective_use_tools: - - - - - - - - - - - - - - - - request_body["tools"] = [ - - - - - - - - - - - - - - - - {"type": "function", "function": fd} - - - - - - - - - - - - - - - - for fd in function_definitions - - - - - - - - - - - - - - - - ] - - - - - - - - - - - - - - - - request_body["tool_choice"] = "auto" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - async with span("llm.completion", model=ai_model.id if ai_model else None): - - try: - - if stream and on_event is not None: - - _sc = "" - _sr = "" - _final_msg = None - async for _kind, _text in await achat_complete( - api_url, api_key, request_body, - proxy=_proxy_for_url(api_url), - timeout=ai_model.timeout or 120, - stream=True, - ): - if _kind == "content": - _sc += _text - await on_event({"type": "delta", "content": _text}) - elif _kind == "reasoning": - _sr += _text - await on_event({"type": "reasoning", "content": _text}) - elif _kind == "message": - _final_msg = _text - if _final_msg is not None: - _final_msg = dict(_final_msg) - if "reasoning_content" in _final_msg: - _final_msg["reasoning"] = _final_msg.pop("reasoning_content") - message = _final_msg - else: - message = {"content": _sc, "tool_calls": [], "reasoning": _sr} - choice = {"message": message} - data = {"choices": [choice], "usage": {}} - else: - data = await achat_complete( - api_url, - api_key, - request_body, - proxy=_proxy_for_url(api_url), - timeout=ai_model.timeout or 120, - ) - - except Exception as _exc: - - if round_num == 0: - - ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" - - logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") - - break - - choice = data.get("choices", [{}])[0] - - message = choice.get("message", {}) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 记录token使用量 - - - - - - - - - - - - - - - - usage = data.get("usage", {}) - - - - - - - - - - - - - - - - prompt_tokens += usage.get("prompt_tokens", 0) - - - - - - - - - - - - - - - - completion_tokens += usage.get("completion_tokens", 0) - - - - - - - - - - - - - - - - total_tokens += usage.get("total_tokens", 0) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 检查是否有tool_calls - - - - - - - - - - - - - - - - tool_calls = message.get("tool_calls", []) - - # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) - - _think = (message.get("content") or "").strip() - - if _think and tool_calls: - - await _push_step({ - - "type": "thinking", - - "text": _think, - - }) - - - - - - - - - - - - - - - - - - if tool_calls and effective_use_tools: - - - - - - - - - - - - - - - - # 将assistant消息(含tool_calls)加入上下文 - - - - - - - - - - - - - - - - messages.append({ - - - - - - - - - - - - - - - - "role": "assistant", - - - - - - - - - - - - - - - - "content": message.get("content") or "", - - - - - - - - - - - - - - - - "tool_calls": tool_calls, - - - - - - - - - - - - - - - - }) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage A.2: 真实Skill执行 - - - - - - - - - - - - - - - - for tc in tool_calls: - - - - - - - - - - - - - - - - func_name = tc.get("function", {}).get("name", "") - - - - # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== - - - - _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None - - - - _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) - - - - if not _policy_check["allowed"]: - - - - _reason = _policy_check["reason"] - - - - logger.warning( - - - - f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " - - - - f"tool={func_name} reason={_reason} trace={get_trace_id()}" - - - - ) - - - - tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" - - - - messages.append({ - - - - "role": "tool", - - - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - - - "content": tool_result, - - - - }) - - - - _tool_call_count += 1 - - - - _round_tool_count += 1 - - - - continue - - - - _tool_call_count += 1 - - - - _round_tool_count += 1 - - - - - - - - - - - - - - - - func_args_str = tc.get("function", {}).get("arguments", "{}") - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - func_args = json.loads(func_args_str) - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - func_args = {"input": func_args_str} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if func_name.startswith("mcp_tool_"): - - - - - - - - # MCP 工具调用:代理到对应 MCP 服务执行 tools/call - - - - - - - - _info = mcp_tool_map.get(func_name) - - - - - - - - tool_result = "[MCP 工具未找到或映射缺失]" - - - - - - - - if _info: - - - - - - - - _srv = next( - - - - - - - - (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), - - - - - - - - None, - - - - - - - - ) - - - - - - - - if _srv: - - - - - - - - async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): - - - - tool_result = await call_mcp_tool( - - - - - - - - _srv, _info["name"], func_args - - - - - - - - ) - - - - - - - - called_mcp_names.append(_srv.get("name", "")) - - await _push_step({ - - "type": "mcp", - - "name": _info.get("name") if isinstance(_info, dict) else func_name, - - "server": _srv.get("name", ""), - - "args": func_args, - - "result": (tool_result or "")[:800], - - }) - - - - - - - - - - else: - - - - - - - - async with span(f"skill.execute:{func_name}"): - - - - tool_result = _execute_skill_tool( - - - - - - - - db, func_name, func_args, skills, req.content - - - - - - - - ) - - - - - - - - _sk = next((s for s in skills if s.get("func_name") == func_name), None) - - - - - - - - if _sk: - - - - - - - - called_skill_names.append(_sk.get("name", "")) - - await _push_step({ - - "type": "skill", - - "name": (_sk.get("name") if _sk else func_name), - - "args": func_args, - - "result": (tool_result or "")[:800], - - }) - - - - - - - - - - - - - - - - - - messages.append({ - - - - - - - - - - - - - - - - "role": "tool", - - - - - - - - - - - - - - - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - - - - - - - - - - - - - - - "content": tool_result, - - - - - - - - - - - - - - - - }) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 继续下一轮让模型处理tool结果 - - - - - - - - - - - - - - - - continue - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - # 普通回复 - - - - - - - - - - - - - - - - ai_content = message.get("content", "") - - - - - - - - - - - - - - - - if not ai_content and round_num > 0: - - - - - - - - - - - - - - - - ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" - - - - - - - - - - - - - - - - break - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not ai_content: - - - - - - - - - - - - - - - - ai_content = "AI模型返回了空响应, 请稍后重试。" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"AI API调用异常: {e}") - - - - - - - - - - - - - - - - ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "请选择一个已配置的AI模型进行对话。" - - - - - - - - - - - - - - - - elif not external_handled: - - - - - - - - - - - - - - - - ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" - - - - - - - - - - # ===== Post-check: 失败则切换备选,成功则跳出 ===== - - _is_error = ( - - ai_content and ( - - "AI模型调用失败" in str(ai_content) - - or "AI服务暂时不可用" in str(ai_content) - - or "AI模型返回了空响应" in str(ai_content) - - or "该模型缺少API密钥" in str(ai_content) - - ) - - ) - - if _is_error: - - _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") - - logger.warning( - - f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." - - ) - - continue # try next model - - - - # Success — exit failover loop - - break - - else: - - # Loop completed without break = all models failed - - _all_failed = True - - - - # ===== All models failed? ===== - - if _all_failed: - - _names = ", ".join([m.name for m in _try_models]) - - _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" - - ai_content = ( - - f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" - - f"最后错误: {_details}\n" - - f"请稍后重试或检查模型配置。" - - ) - - logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ai_token_count = len(ai_content) // 2 - - - - - - - - - - - - - - - - ai_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="assistant", - - - - - - - - - - - - - - - - content=ai_content, - - - - - - - - - - - - - - - - token_used=ai_token_count, - - - - - - - - - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(ai_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) - - - - - - - - - - - - - - - - _ingest_text = (ai_content or "").strip() - - - - - - - - - - - - - - - - if len(_ingest_text) > 120 and not ( - - - - - - - - - - - - - - - - _ingest_text.startswith("[MEDIA") - - - - - - - - - - - - - - - - or _ingest_text.startswith("data:") - - - - - - - - - - - - - - - - or _ingest_text.startswith("http") - - - - - - - - - - - - - - - - or " 反馈配对的技能名 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - _sel = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - - - - - - - - - used_skills = [s["name"] for s in _sel] - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - used_skills = [] - - - - - - - - - - - - - - - - elif called_skill_names: - - - - - - - - - - - - - - - - # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) - - - - - - - - - - - - - - - - used_skills = list(dict.fromkeys(called_skill_names)) - - - - - - - - - - - - - - - - if effective_mcp_ids: - - - - - - - - - - - - - - - - # 用户显式选择了 MCP -> 反馈配对的 MCP 名 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - - - - - - - - - used_mcp = [m["name"] for m in _sel_mcp] - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - used_mcp = [] - - - - - - - - - - - - - - - - elif called_mcp_names: - - - - - - - - - - - - - - - - used_mcp = list(dict.fromkeys(called_mcp_names)) - - - - - - - - - - - - - - - - ai_msg.used_skills = used_skills - - - - - - - - - - - - - - - - ai_msg.used_mcp = used_mcp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 更新对话统计 - - - - - - - - - - - - - - - - # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, - - # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 - - - - - - - - - - - - - - - - conv.updated_at = cst_now() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) - - - - - - - - - - - - - - - - if not media_generating: - - - - - - - - - - - - - - - - token_usage = TokenUsageLog( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - user_id=current_user.id, - - - - - - - - - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - - - - - - - - - model_name=ai_model.name if ai_model else None, - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - trace_id=get_trace_id(), - - - - - - - - - - - - - - - - - - - - prompt_tokens=prompt_tokens or len(req.content) // 2, - - - - - - - - - - - - - - - - completion_tokens=completion_tokens or ai_token_count, - - - - - - - - - - - - - - - - total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(token_usage) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 进化轨迹记录 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - existing = db.query(EvolutionTrajectory).filter( - - - - - - - - - - - - - - - - EvolutionTrajectory.conversation_id == conv_id - - - - - - - - - - - - - - - - ).first() - - - - - - - - - - - - - - - - if not existing: - - - - - - - - - - - - - - - - trajectory = EvolutionTrajectory( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - user_id=current_user.id, - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - status="discovered", - - - - - - - - - - - - - - - - token_used=ai_token_count, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(trajectory) - - - - - - - - - - - - - - - - db.flush() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - from app.services.evolution_service import EvolutionWatcherManager - - - - - - - - - - - - - - - - watcher = EvolutionWatcherManager() - - - - - - - - - - - - - - - - status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) - - - - - - - - - - - - - - - - if not status.get("scheduler_running"): - - - - - - - - - - - - - - - - watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) - - - - - - - - - - - - - - - - logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") - - - - - - - - - - - - - - - - except Exception as evolution_err: - - - - - - - - - - - - - - - - logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - pass - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - db.commit() - - - - - - - - - - - - - - - - # 后台异步压缩历史(分层记忆),不阻塞本次响应 - - - - asyncio.create_task(_maybe_compress_history(conv_id)) - - - - - - - - db.refresh(user_msg) - - - - - - - - - - - - - - - - db.refresh(ai_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 - - - - - - - - - - - - - - - - if media_generating: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - asyncio.create_task( - - - - - - - - - - - - - - - - _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) - - # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 - - try: - - ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" - - db.commit() - - except Exception: - - pass - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if on_event is not None: - try: - try: - _mid = ai_msg.id - except NameError: - _mid = None - try: - _dc = ai_content - except NameError: - _dc = (ai_msg.content if "ai_msg" in dir() else "") - try: - _ps = process_steps - except NameError: - _ps = [] - try: - _us = used_skills - except NameError: - _us = [] - try: - _um = used_mcp - except NameError: - _um = [] - try: - _mg = media_generating - except NameError: - _mg = False - await on_event({ - "type": "done", - "message_id": _mid, - "conversation_id": conv_id, - "content": _dc, - "process_steps": _ps, - "used_skills": _us, - "used_mcp": _um, - "generating": _mg, - }) - except Exception: - pass - - return { - - - - - - - - - - - - - - - - "code": 0, - - - - - - - - - - - - - - - - "data": { - - - - - - - - - - - - - - - - "user_message": { - - - - - - - - - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "assistant_message": { - - - - - - - - - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, - - - - - - - - - - - - - - - - "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), - - - - - - - - - - - - - - - - "used_skills": used_skills, - - - - "used_mcp": used_mcp, - - - - - - - - - - - - - - - - "generating": media_generating, - - "process_steps": process_steps, - - - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "message": "发送成功", - - - - - - - - - - - - - - - - } - - - - - - - - - - - -# ====================================================================== - - - -# 3.1 tool_policy 沙箱辅助函数 - - - -# ====================================================================== - - - -def _resolve_agent_tool_policy(agents): - - - - """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" - - - - if not agents: - - - - return None - - - - for ag in agents: - - - - tp = ag.get("tool_policy") - - - - if tp and isinstance(tp, dict): - - - - tp = dict(tp) - - - - tp["agent_id"] = ag.get("id") - - - - return tp - - - - return None - - - - - - - - - - - -def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): - - - - """校验单个工具调用是否被策略允许。 - - - - - - - - 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} - - - - policy 为 None/空 -> 允许(向后兼容)。 - - - - """ - - - - if not policy: - - - - return {"allowed": True, "tool_id": None} - - - - allowed = policy.get("allowed_tools") or {} - - - - skill_ids = allowed.get("skill_ids") or [] - - - - mcp_ids = allowed.get("mcp_ids") or [] - - - - is_mcp = func_name.startswith("mcp_tool_") - - - - tool_id = None - - - - if is_mcp: - - - - if mcp_info and isinstance(mcp_info, dict): - - - - tool_id = mcp_info.get("server_id") - - - - if mcp_ids and tool_id not in mcp_ids: - - - - return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, - - - - "message": "MCP 服务不在该智能体的允许列表中"} - - - - else: - - - - if skills: - - - - _s = next((s for s in skills if s.get("func_name") == func_name), None) - - - - if _s: - - - - tool_id = _s.get("id") - - - - if skill_ids and tool_id not in skill_ids: - - - - return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, - - - - "message": "技能不在该智能体的允许列表中"} - - - - budget = policy.get("max_calls_per_conversation") - - - - if budget is not None and call_count >= int(budget): - - - - return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, - - - - "message": policy.get("fallback_message") - - - - or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} - - - - per_round = policy.get("per_round_cap") - - - - if per_round is not None and round_count >= int(per_round): - - - - return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, - - - - "message": "本轮工具调用已达上限"} - - - - return {"allowed": True, "tool_id": tool_id} - - - - +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). + + + +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + + + + + + + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + + + +from sqlalchemy.orm import Session + + + +from sqlalchemy import and_, desc, asc, func + + + +from pydantic import BaseModel, Field + + + +from typing import Optional + + + +import uuid + + + +from datetime import datetime, timezone, timedelta + + + +import re + + + +import json + + + +import asyncio + + + +import math + + + +import httpx + + + +import os + + + +import logging + + + +from ..database import get_db, SessionLocal + + + +from app.core.observability import span, get_trace_id + + + +from ..models import ( + + + + + + + + + + + + + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + + + + + + + + + + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + + + + + + + + + + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + + + + + + + + + + + + + MCPServer, + + + + + + + + + + + + + + + + gen_uuid + + + + + + + + + + + + + + + +) + + + +from ..auth import get_current_user + + + +from ..platform_knowledge import get_platform_knowledge + + + +from ..mcp_client import list_mcp_tools, call_mcp_tool + + + +from ..middleware.log_service import LogService + + + +from ..capability_registry import resolve_auto_model + + + +from app.api.chat_agents import (_call_external_agent) + + + +from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) + + + +from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) + + + +from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) + + + +from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills, _execute_install_app) + + + +from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) + +# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 + +from app.core.llm_client import achat_complete + + + + + + + +async def _send_message_core( + + + + + + + + + + + + + + + + conv_id: str, + + + + + + + + + + + + + + + + req: SendMessageRequest, + + + + + + + + + + + + + + + + db: Session = Depends(get_db), + + + + + + + + + + + + + + + + current_user: User = Depends(get_current_user), + stream: bool = False, + on_event=None, + + + + + + + + + + + + + + + +): + + + + + + + + + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not conv: + + + + + + + + + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, + + + + + + + + + + + + + + + + # 从对话历史中继承上一次的选择 + + + + + + + + + + + + + + + + effective_skill_ids = req.skill_ids or [] + + + + + + + + + + + + + + + + effective_agent_ids = req.agent_ids or [] + + + + + + + + + + + + + + + + effective_kb_ids = req.knowledge_base_ids or [] + + + + + + + + + + + + + + + + effective_mcp_ids = req.mcp_ids or [] + + + + + + + + + + + + + + + + effective_use_tools = req.use_tools or False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: + + + + + + + + + + + + + + + + # 从对话上下文继承 + + + + + + + + + + + + + + + + prev_context = _get_conversation_context(db, conv_id) + + + + + + + + + + + + + + + + if prev_context: + + + + + + + + + + + + + + + + effective_skill_ids = prev_context.get("skill_ids", []) + + + + + + + + + + + + + + + + effective_agent_ids = prev_context.get("agent_ids", []) + + + + + + + + + + + + + + + + effective_kb_ids = prev_context.get("knowledge_base_ids", []) + + + + + + + + + + + + + + + + effective_mcp_ids = prev_context.get("mcp_ids", []) + + + + + + + + + + + + + + + + if effective_skill_ids or effective_agent_ids or effective_mcp_ids: + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) + + + + + + + + + + + + + + + + attachment_block = "" + + + + + + + + + + + + + + + + if req.attachments: + + + + + + + + + + + + + + + + parts = [] + + + + + + + + + + + + + + + + for att in req.attachments: + + + + + + + + + + + + + + + + name = att.get("name", "附件") if isinstance(att, dict) else str(att) + + + + + + + + + + + + + + + + ftype = att.get("file_type", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + content = att.get("content", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + if content: + + + + + + + + + + + + + + + + parts.append(f"【附件: {name}】\n{content}") + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") + + + + + + + + + + + + + + + + if parts: + + + + + + + + + + + + + + + + attachment_block = "\n\n" + "\n\n".join(parts) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) + + + + + + + + + + + + + + + + user_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="user", + + + + + + + + + + + + + + + + content=req.content + attachment_block, + + + + + + + + + + + + + + + + token_used=len(req.content) // 2, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + mcp_ids=req.mcp_ids if req.mcp_ids else None, + + + + + + + + + + + + + + + + attachments=req.attachments if req.attachments else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(user_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 模糊创作意图拦截 ===== + + + + + + + + + + + + + + + + # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, + + + + + + + + + + + + + + + + # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 + + + + + + + + + + + + + + + + clarify = detect_ambiguous_creative_intent(req.content) + + + + + + + + + + + + + + + + if clarify: + + + + + + + + + + + + + + + + clarify_content = "<>" + json.dumps( + + + + + + + + + + + + + + + + {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False + + + + + + + + + + + + + + + + ) + "<>" + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=clarify_content, + + + + + + + + + + + + + + + + token_used=0, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, + + + + + + + + + + + + + + + + "created_at": str(ai_msg.created_at), "clarify": clarify, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "请选择创作方向", + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 核心聊天引擎 ===== + + + + + + + + + + + + + + + + user_id = current_user.id + + + + + + + + + + + + + + + + model_id = req.model_id or conv.model_id + + + + + + + + + + + + + + + + prompt_tokens = 0 + + + + + + + + + + + + + + + + completion_tokens = 0 + + + + + + + + + + + + + + + + total_tokens = 0 + + + + + + + + + + + + + + + + ai_content = "" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_model = None + + + + + + + + + + + + + + + + skills = [] # 初始化避免UnboundLocalError + + process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 + + + + + + + + + + + + + + + + + + agents = [] # 初始化避免UnboundLocalError + + + + + + + + + + + + + + + + media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 + + + + + + + + + + + + + + + + external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== + + + + + + + + + + + + + + + + # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, + + + + + + + + + + + + + + + + # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 + + + + + + + + + + + + + + + + external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) + + + + + + + + # ===== 3.3 多 Agent 编排(图引擎)快路 ===== + + + + _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) + + + + if _orchestrator: + + + + try: + + + + from app.core.orchestrator import run_graph + + + + _answer, _trace = await run_graph( + + + + graph=_orchestrator["orchestration"], + + + + query=req.content + attachment_block, + + + + db=db, + + + + user_id=user_id, + + + + conv_id=conv_id, + + + + trace_id=get_trace_id(), + + + + ) + + + + ai_content = _answer or "编排未产生内容。" + + + + logger.info( + + + + f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" + + + + ) + + + + except Exception as e: + + + + logger.warning(f"[orchestrator] 图执行失败: {e}") + + + + ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" + + + + external_handled = True + + + + external_agent = None # 阻止下方外部 Agent 分支重复执行 + + + + + + + + + + + + + + + + if external_agent: + + + + + + + + + + + + + + + + agents = external_agents + + + + + + + + + + + + + + + + # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) + + + + + + + + + + + + + + + + _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + answer, new_ext_id = await _call_external_agent( + + + + + + + + + + + + + + + + db, external_agent, + + + + + + + + + + + + + + + + query=req.content + attachment_block, + + + + + + + + + + + + + + + + user_id=user_id, + + + + + + + + + + + + + + + + ext_conversation_id=conv.ext_agent_conversation_id, + + + + + + + + + + + + + + + + history=_history, + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = answer or "外部 Agent 未返回内容。" + + + + + + + + + + + + + + + + if new_ext_id: + + + + + + + + + + + + + + + + conv.ext_agent_conversation_id = new_ext_id + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning( + + + + + + + + + + + + + + + + f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " + + + + + + + + + + + + + + + + f"endpoint={external_agent.get('agent_endpoint')}: {e}" + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" + + + + + + + + + + + + + + + + external_handled = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) + + + + + + + + + + + + + + + + if not external_handled: + + + + + + + + + + + + + + + + if model_id and model_id != "auto": + + + + + + + + + + + + + + + + ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() + + + + + + + + + + + + + + + + if not ai_model: + + + + + + + + + + + + + + + + # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 + + + + + + + + + + + + + + + + intent = detect_media_intent(req.content) + + + + + + + + + + + + + + + + resolved = resolve_auto_model(intent, db) if intent else None + + + + + + + + + + + + + + + + ai_model = resolved or resolve_auto_model("text", db) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== Model Failover: Auto 模式下自动切换备选模型 ===== + + _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] + + if not model_id or model_id == "auto": + + # Auto 模式:附加其他已启用的文本模型作为备选 + + _fallbacks = db.query(AIModel).filter( + + AIModel.is_enabled == True, + + AIModel.id != (ai_model.id if ai_model else "") + + ).order_by(AIModel.is_default.desc()).limit(5).all() + + _try_models.extend([m for m in _fallbacks if m not in _try_models]) + + _retry_log = [] + + _all_failed = False + + + + for _try_model in _try_models: + + ai_model = _try_model + + if ai_model: + + + + + + + + + + + + + + + + # ====== 图像/视频生成模型分支 ====== + + + + + + + + + + + + + + + + caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] + + + + + + + + + + + + + + + + media_generating = False + + + + + + + + + + + + + + + + if ai_model and ("image" in caps or "video" in caps): + + + + + + + + + + + + + + + + # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, + + + + + + + + + + + + + + + + # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 + + + + + + + + + + + + + + + + media_generating = True + + + + + + + + + + + + + + + + if "video" in caps: + + + + + + + + + + + + + + + + ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "⏳ 图像生成中…(完成后自动刷新)" + + + + + + + + + + + + + + + + elif ai_model and ai_model.endpoint: + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not api_url and provider and provider.api_base_url: + + + + + + + + + + + + + + + + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if api_key and api_url: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + # Stage A+B: 解析Skills和Agents + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions + + + + + + + + mcp_servers_conn = mcps + + + + + + + + mcp_function_definitions = [] + + + + + + + + mcp_tool_map = {} + + + + + + + + mcp_idx = 0 + + + + + + + + for _srv in mcp_servers_conn: + + + + + + + + try: + + + + + + + + async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): + + + + _tools = await list_mcp_tools(_srv) + + + + + + + + except Exception as _e: + + + + + + + + logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") + + + + + + + + _tools = [] + + + + + + + + if _tools: + + + + + + + + try: + + + + + + + + _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() + + + + + + + + if _upd: + + + + + + + + _upd.tools_count = len(_tools) + + + + + + + + db.commit() + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + for _t in _tools: + + + + + + + + _fname = f"mcp_tool_{mcp_idx}" + + + + + + + + mcp_tool_map[_fname] = { + + + + + + + + "server_id": _srv["id"], + + + + + + + + "name": _t.get("name"), + + + + + + + + "server_name": _srv["name"], + + + + + + + + } + + + + + + + + _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} + + + + + + + + if not isinstance(_schema, dict) or _schema.get("type") != "object": + + + + + + + + _schema = {"type": "object", "properties": {}} + + + + + + + + mcp_function_definitions.append({ + + + + + + + + "name": _fname, + + + + + + + + "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", + + + + + + + + "parameters": _schema, + + + + + + + + }) + + + + + + + + mcp_idx += 1 + + + + + + + + if mcp_function_definitions: + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) + + + + + + + + + + + + + + + + # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 + + + + + + + + + + + + + + + + used_auto_skills = False + + + + + + + + + + + + + + + + if not effective_skill_ids: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + discovered = _discover_installed_skills(db, user_id) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"自动发现已安装技能失败: {e}") + + + + + + + + + + + + + + + + discovered = [] + + + + + + + + + + + + + + + + if discovered: + + + + + + + + + + + + + + + + skills = discovered + + + + + + + + + + + + + + + + used_auto_skills = True + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.1: Agent可能自带skill_ids和kb_ids + + + + + + + + + + + + + + + + agent_extra_skill_ids = [] + + + + + + + + + + + + + + + + agent_extra_kb_ids = [] + + + + + + + + + + + + + + + + agent_model_override = None + + + + + + + + + + + + + + + + for agent in agents: + + + + + + + + + + + + + + + + if agent.get("skill_ids"): + + + + + + + + + + + + + + + + agent_extra_skill_ids.extend(agent["skill_ids"]) + + + + + + + + + + + + + + + + if agent.get("knowledge_base_ids"): + + + + + + + + + + + + + + + + agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) + + + + + + + + + + + + + + + + if agent.get("model_id"): + + + + + + + + + + + + + + + + agent_model_override = agent["model_id"] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 合并Agent自带的skills和KBs + + + + + + + + + + + + + + + + all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) + + + + + + + + + + + + + + + + all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if agent_extra_skill_ids or agent_extra_kb_ids: + + + + + + + + + + + + + + + + # 重新解析合并后的skills + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, all_skill_ids, user_id) + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage C: 增强版KB检索 + + + + + + + + + + + + + + + + kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B: 动态组装system prompt + function definitions + + + + + + + + + + + + + + + + system_prompt, function_definitions = _build_system_prompt( + + + + + + + + + + + + + + + + skills=skills, + + + + + + + + + + + + + + + + agents=agents, + + + + + + + + + + + + + + + + kb_context=kb_context, + + + + + + + + + + + + + + + + user_content=req.content, + + + + + + + + + + + + + + + + mcp=mcps, + + + + + + + + mcp_functions=mcp_function_definitions, + + + + + + + + auto_discovered=used_auto_skills, + + + + all_mcp=_list_enabled_mcp_servers(db), + + + + db=db, + + + + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 构建消息列表 + + + + + + + + + + + + + + + + # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) + + + + called_skill_names = [] + + + + called_mcp_names = [] + + + + + + + + if isinstance(function_definitions, list): + function_definitions.append({ + "name": "install_app", + "description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"}, + "type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"}, + }, + "required": ["url"], + }, + }) + + messages = [{"role": "system", "content": system_prompt}] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) + + + + messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 1: 必须把附件内容(attachment_block)一并发给模型, + + + + + + + + + + + + + + + + # 否则用户在对话窗上传文件后模型“看不到”文件内容。 + + + + + + + + + + + + + + + + # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 + + try: + + from app.api.url_reader import fetch_url_context + + _url_ctx = await fetch_url_context(req.content) + + except Exception as _e: + + import logging as _logging + + _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) + + _url_ctx = None + + if _url_ctx and _url_ctx.get("text"): + + messages.append({ + + "role": "system", + + "content": ( + + "【用户随消息提供的参考链接的真实内容】\n" + + f"链接地址: {_url_ctx.get('url')}\n" + + "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" + + "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," + + "不要凭空编造步骤:\n\n" + _url_ctx["text"] + + ), + + }) + + process_steps.append({ + + "type": "skill", + + "name": "网页/仓库读取", + + "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", + + }) + + messages.append({"role": "user", "content": req.content + attachment_block}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.3: Function Calling循环(最多5轮) + + + + + + + + + + + + + + + + max_tool_rounds = 5 + + + + + + + + + + + + + + + + # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== + + + + _agent_tool_policy = _resolve_agent_tool_policy(agents) + + + + _tool_call_count = 0 + + + + for round_num in range(max_tool_rounds): + + + + _round_tool_count = 0 + + + + + + + + + + + + + + + + request_body = { + + + + + + + + + + + + + + + + "model": ai_model.name, + + + + + + + + + + + + + + + + "messages": messages, + + + + + + + + + + + + + + + + "max_tokens": 2048, + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + if function_definitions and effective_use_tools: + + + + + + + + + + + + + + + + request_body["tools"] = [ + + + + + + + + + + + + + + + + {"type": "function", "function": fd} + + + + + + + + + + + + + + + + for fd in function_definitions + + + + + + + + + + + + + + + + ] + + + + + + + + + + + + + + + + request_body["tool_choice"] = "auto" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + async with span("llm.completion", model=ai_model.id if ai_model else None): + + try: + + if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids): + + _sc = "" + _sr = "" + async for _kind, _text in await achat_complete( + api_url, api_key, request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + stream=True, + ): + if _kind == "content": + _sc += _text + await on_event({"type": "delta", "content": _text}) + elif _kind == "reasoning": + _sr += _text + await on_event({"type": "reasoning", "content": _text}) + message = {"content": _sc, "tool_calls": [], "reasoning": _sr} + choice = {"message": message} + data = {"choices": [choice], "usage": {}} + else: + data = await achat_complete( + api_url, + api_key, + request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + ) + + except Exception as _exc: + + if round_num == 0: + + ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" + + logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") + + break + + choice = data.get("choices", [{}])[0] + + message = choice.get("message", {}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录token使用量 + + + + + + + + + + + + + + + + usage = data.get("usage", {}) + + + + + + + + + + + + + + + + prompt_tokens += usage.get("prompt_tokens", 0) + + + + + + + + + + + + + + + + completion_tokens += usage.get("completion_tokens", 0) + + + + + + + + + + + + + + + + total_tokens += usage.get("total_tokens", 0) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 检查是否有tool_calls + + + + + + + + + + + + + + + + tool_calls = message.get("tool_calls", []) + + # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) + + _think = (message.get("content") or "").strip() + + if _think and tool_calls: + + process_steps.append({ + + "type": "thinking", + + "text": _think, + + }) + + + + + + + + + + + + + + + + + + if tool_calls and effective_use_tools: + + + + + + + + + + + + + + + + # 将assistant消息(含tool_calls)加入上下文 + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "assistant", + + + + + + + + + + + + + + + + "content": message.get("content") or "", + + + + + + + + + + + + + + + + "tool_calls": tool_calls, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage A.2: 真实Skill执行 + + + + + + + + + + + + + + + + for tc in tool_calls: + + + + + + + + + + + + + + + + func_name = tc.get("function", {}).get("name", "") + + + + # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== + + + + _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None + + + + _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) + + + + if not _policy_check["allowed"]: + + + + _reason = _policy_check["reason"] + + + + logger.warning( + + + + f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " + + + + f"tool={func_name} reason={_reason} trace={get_trace_id()}" + + + + ) + + + + tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" + + + + messages.append({ + + + + "role": "tool", + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + "content": tool_result, + + + + }) + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + continue + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + + + + + + + + + + + + + func_args_str = tc.get("function", {}).get("arguments", "{}") + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + func_args = json.loads(func_args_str) + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + func_args = {"input": func_args_str} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if func_name.startswith("mcp_tool_"): + + + + + + + + # MCP 工具调用:代理到对应 MCP 服务执行 tools/call + + + + + + + + _info = mcp_tool_map.get(func_name) + + + + + + + + tool_result = "[MCP 工具未找到或映射缺失]" + + + + + + + + if _info: + + + + + + + + _srv = next( + + + + + + + + (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), + + + + + + + + None, + + + + + + + + ) + + + + + + + + if _srv: + + + + + + + + async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): + + + + tool_result = await call_mcp_tool( + + + + + + + + _srv, _info["name"], func_args + + + + + + + + ) + + + + + + + + called_mcp_names.append(_srv.get("name", "")) + + process_steps.append({ + + "type": "mcp", + + "name": _info.get("name") if isinstance(_info, dict) else func_name, + + "server": _srv.get("name", ""), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + elif func_name == "install_app": + async with span("app.install"): + _iu = func_args.get("url", "") if isinstance(func_args, dict) else "" + _itype = (func_args.get("type", "auto") if isinstance(func_args, dict) else "auto") or "auto" + tool_result = _execute_install_app(db, _iu, current_user.id if current_user else None, _itype) + process_steps.append({ + "type": "skill", + "name": "安装应用", + "args": func_args, + "result": (tool_result or "")[:800], + }) + else: + async with span(f"skill.execute:{func_name}"): + + + + tool_result = _execute_skill_tool( + + + + + + + + db, func_name, func_args, skills, req.content + + + + + + + + ) + + + + + + + + _sk = next((s for s in skills if s.get("func_name") == func_name), None) + + + + + + + + if _sk: + + + + + + + + called_skill_names.append(_sk.get("name", "")) + + process_steps.append({ + + "type": "skill", + + "name": (_sk.get("name") if _sk else func_name), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "tool", + + + + + + + + + + + + + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + + + + + + + + + + + + + "content": tool_result, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 继续下一轮让模型处理tool结果 + + + + + + + + + + + + + + + + continue + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + # 普通回复 + + + + + + + + + + + + + + + + ai_content = message.get("content", "") + + + + + + + + + + + + + + + + if not ai_content and round_num > 0: + + + + + + + + + + + + + + + + ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" + + + + + + + + + + + + + + + + break + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not ai_content: + + + + + + + + + + + + + + + + ai_content = "AI模型返回了空响应, 请稍后重试。" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"AI API调用异常: {e}") + + + + + + + + + + + + + + + + ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "请选择一个已配置的AI模型进行对话。" + + + + + + + + + + + + + + + + elif not external_handled: + + + + + + + + + + + + + + + + ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" + + + + + + + + + + # ===== Post-check: 失败则切换备选,成功则跳出 ===== + + _is_error = ( + + ai_content and ( + + "AI模型调用失败" in str(ai_content) + + or "AI服务暂时不可用" in str(ai_content) + + or "AI模型返回了空响应" in str(ai_content) + + or "该模型缺少API密钥" in str(ai_content) + + ) + + ) + + if _is_error: + + _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") + + logger.warning( + + f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." + + ) + + continue # try next model + + + + # Success — exit failover loop + + break + + else: + + # Loop completed without break = all models failed + + _all_failed = True + + + + # ===== All models failed? ===== + + if _all_failed: + + _names = ", ".join([m.name for m in _try_models]) + + _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" + + ai_content = ( + + f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" + + f"最后错误: {_details}\n" + + f"请稍后重试或检查模型配置。" + + ) + + logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_token_count = len(ai_content) // 2 + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=ai_content, + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) + + + + + + + + + + + + + + + + _ingest_text = (ai_content or "").strip() + + + + + + + + + + + + + + + + if len(_ingest_text) > 120 and not ( + + + + + + + + + + + + + + + + _ingest_text.startswith("[MEDIA") + + + + + + + + + + + + + + + + or _ingest_text.startswith("data:") + + + + + + + + + + + + + + + + or _ingest_text.startswith("http") + + + + + + + + + + + + + + + + or " 反馈配对的技能名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + used_skills = [s["name"] for s in _sel] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_skills = [] + + + + + + + + + + + + + + + + elif called_skill_names: + + + + + + + + + + + + + + + + # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) + + + + + + + + + + + + + + + + used_skills = list(dict.fromkeys(called_skill_names)) + + + + + + + + + + + + + + + + if effective_mcp_ids: + + + + + + + + + + + + + + + + # 用户显式选择了 MCP -> 反馈配对的 MCP 名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + used_mcp = [m["name"] for m in _sel_mcp] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_mcp = [] + + + + + + + + + + + + + + + + elif called_mcp_names: + + + + + + + + + + + + + + + + used_mcp = list(dict.fromkeys(called_mcp_names)) + + + + + + + + + + + + + + + + ai_msg.used_skills = used_skills + + + + + + + + + + + + + + + + ai_msg.used_mcp = used_mcp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 更新对话统计 + + + + + + + + + + + + + + + + # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, + + # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 + + + + + + + + + + + + + + + + conv.updated_at = cst_now() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) + + + + + + + + + + + + + + + + if not media_generating: + + + + + + + + + + + + + + + + token_usage = TokenUsageLog( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + model_name=ai_model.name if ai_model else None, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + trace_id=get_trace_id(), + + + + + + + + + + + + + + + + + + + + prompt_tokens=prompt_tokens or len(req.content) // 2, + + + + + + + + + + + + + + + + completion_tokens=completion_tokens or ai_token_count, + + + + + + + + + + + + + + + + total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(token_usage) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 进化轨迹记录 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + existing = db.query(EvolutionTrajectory).filter( + + + + + + + + + + + + + + + + EvolutionTrajectory.conversation_id == conv_id + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not existing: + + + + + + + + + + + + + + + + trajectory = EvolutionTrajectory( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + status="discovered", + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(trajectory) + + + + + + + + + + + + + + + + db.flush() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + from app.services.evolution_service import EvolutionWatcherManager + + + + + + + + + + + + + + + + watcher = EvolutionWatcherManager() + + + + + + + + + + + + + + + + status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + if not status.get("scheduler_running"): + + + + + + + + + + + + + + + + watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") + + + + + + + + + + + + + + + + except Exception as evolution_err: + + + + + + + + + + + + + + + + logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + # 后台异步压缩历史(分层记忆),不阻塞本次响应 + + + + asyncio.create_task(_maybe_compress_history(conv_id)) + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 + + + + + + + + + + + + + + + + if media_generating: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + asyncio.create_task( + + + + + + + + + + + + + + + + _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) + + # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 + + try: + + ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" + + db.commit() + + except Exception: + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, + + + + + + + + + + + + + + + + "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), + + + + + + + + + + + + + + + + "used_skills": used_skills, + + + + "used_mcp": used_mcp, + + + + + + + + + + + + + + + + "generating": media_generating, + + "process_steps": process_steps, + + + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "发送成功", + + + + + + + + + + + + + + + + } + + + + + + + + + + + +# ====================================================================== + + + +# 3.1 tool_policy 沙箱辅助函数 + + + +# ====================================================================== + + + +def _resolve_agent_tool_policy(agents): + + + + """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" + + + + if not agents: + + + + return None + + + + for ag in agents: + + + + tp = ag.get("tool_policy") + + + + if tp and isinstance(tp, dict): + + + + tp = dict(tp) + + + + tp["agent_id"] = ag.get("id") + + + + return tp + + + + return None + + + + + + + + + + + +def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): + + + + """校验单个工具调用是否被策略允许。 + + + + + + + + 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} + + + + policy 为 None/空 -> 允许(向后兼容)。 + + + + """ + + + + if not policy: + + + + return {"allowed": True, "tool_id": None} + + + + allowed = policy.get("allowed_tools") or {} + + + + skill_ids = allowed.get("skill_ids") or [] + + + + mcp_ids = allowed.get("mcp_ids") or [] + + + + is_mcp = func_name.startswith("mcp_tool_") + + + + tool_id = None + + + + if is_mcp: + + + + if mcp_info and isinstance(mcp_info, dict): + + + + tool_id = mcp_info.get("server_id") + + + + if mcp_ids and tool_id not in mcp_ids: + + + + return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, + + + + "message": "MCP 服务不在该智能体的允许列表中"} + + + + else: + + + + if skills: + + + + _s = next((s for s in skills if s.get("func_name") == func_name), None) + + + + if _s: + + + + tool_id = _s.get("id") + + + + if skill_ids and tool_id not in skill_ids: + + + + return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, + + + + "message": "技能不在该智能体的允许列表中"} + + + + budget = policy.get("max_calls_per_conversation") + + + + if budget is not None and call_count >= int(budget): + + + + return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, + + + + "message": policy.get("fallback_message") + + + + or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} + + + + per_round = policy.get("per_round_cap") + + + + if per_round is not None and round_count >= int(per_round): + + + + return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, + + + + "message": "本轮工具调用已达上限"} + + + + return {"allowed": True, "tool_id": tool_id} + + + + diff --git a/backend/python/app/api/chat_skill.py b/backend/python/app/api/chat_skill.py index 5466541..5bcc098 100644 --- a/backend/python/app/api/chat_skill.py +++ b/backend/python/app/api/chat_skill.py @@ -1593,3 +1593,150 @@ def _execute_skill_tool( return result + + +def _generate_install_fallback_skill(name: str, description: str) -> str: + """install_app 兜底:无法识别为 SKILL.md 时,生成一份可用技能定义。""" + desc = (description or "").strip() or f"{name} 相关能力" + return ( + f"---\n" + f"name: {name}\n" + f"description: {desc}\n" + f"category: other\n" + f"---\n\n" + f"# {name}\n\n" + f"{desc}\n\n" + f"## 使用建议\n" + f"- 明确用户意图后再执行,必要时先向用户确认方向。\n" + f"- 输出结构清晰、可直接使用。\n" + f"- 如任务超出能力范围,主动说明并给出替代方案。\n" + ) + + +def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto") -> str: + """前台 AI 聊天:通过 URL 安装 skill / mcp / agent(根治问题 #3)。 + + 逻辑对齐 market.upload_app,但本函数不引用 market 私有函数以避免循环 import: + - 同步 httpx.Client(trust_env=False, 显式 proxy) 抓取 URL 内容(与 _fetch_skill_content 同款) + - 自动识别类型:mcp / agent / skill + - 建 MarketApp(+ mcp 时 MCPServer + AppInstallation),自动标记为已安装 + + 关键约束:本函数在聊天主事件循环中【同步】执行,禁止调用 asyncio.run + (会抛 "cannot be called from a running event loop")。因此 MCP 连接测试交由后台 + 「测试」按钮在 worker 线程异步执行;新建 MCPServer 直接 is_enabled=True 即可被 + 聊天链路 _resolve_active_mcp_servers 使用(该解析仅过滤 is_enabled)。 + """ + if not url or not str(url).strip().lower().startswith("http"): + return "请提供有效的安装地址(URL),例如 GitHub 仓库、mcp.so 或 modelscope 链接。" + url = str(url).strip() + proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None + try: + with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as client: + r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; QihuAI/1.0)"}) + content = r.text if r.status_code == 200 else "" + except Exception as e: + return f"无法访问该地址:{str(e)[:200]}" + + _u = url.lower() + _c = (content or "").lower() + detected = "skill" + if app_type and app_type != "auto": + detected = app_type + else: + if "mcpservers" in _c or '"mcpservers"' in (content or "").lower() or ( + "url" in _c and ("transport" in _c or '"type"' in _c) + ): + detected = "mcp" + elif any(k in _u for k in ["dify", "n8n", "coze"]) or "agent_endpoint" in _c: + detected = "agent" + else: + detected = "skill" + + import re as _re + _name = _re.sub(r"^https?://", "", url).strip("/").split("/")[-1] or "imported_app" + _name = _name[:120] + _desc = f"通过链接导入:{url}" + + _dup = db.query(MarketApp).filter( + func.lower(MarketApp.name) == _name.lower(), + MarketApp.type == detected, + ).first() + if _dup: + return f"「{_name}」({detected}) 已存在,无需重复安装(ID: {_dup.id})。可在应用市场直接使用。" + + app = MarketApp( + id=gen_uuid(), + name=_name, + description=_desc, + type=detected, + source="user", + category="other", + tags=None, + price=0, + install_count=0, + rating_avg=0, + review_count=0, + publisher_id=user_id, + status="published", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + if detected == "mcp": + from ..api.admin.mcp_admin import parse_mcp_config + parsed = parse_mcp_config(content) + _murl = parsed.get("url") or url + app.skill_content = content + app.mcp_transport = parsed.get("transport_type", "streamable_http") + db.add(app) + db.commit() + db.refresh(app) + _hdr = json.dumps(parsed.get("headers") or {}, ensure_ascii=False) + server = MCPServer( + id=app.id, + name=app.name, + description=app.description or "", + transport_type=parsed.get("transport_type", "streamable_http"), + command="", + args="{}", + env_vars="{}", + url=_murl, + headers=_hdr, + is_enabled=True, + status="disconnected", + tools_count=0, + resources_count=0, + created_at=app.created_at, + updated_at=app.updated_at, + ) + db.add(server) + db.commit() + elif detected == "agent": + app.agent_endpoint = url + if "dify" in _u: + app.platform = "dify" + elif "n8n" in _u: + app.platform = "n8n" + elif "coze" in _u: + app.platform = "coze" + app.skill_content = content if "{" in content else None + db.add(app) + db.commit() + db.refresh(app) + else: + _is_skillmd = ("name:" in _c and "description:" in _c) or _u.endswith(".md") + app.skill_content = content if _is_skillmd else _generate_install_fallback_skill(_name, _desc) + db.add(app) + db.commit() + db.refresh(app) + + if user_id: + _inst = db.query(AppInstallation).filter( + AppInstallation.user_id == user_id, + AppInstallation.app_id == app.id, + ).first() + if not _inst: + db.add(AppInstallation(user_id=user_id, app_id=app.id)) + db.commit() + + return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"