2091 lines
33 KiB
Python
2091 lines
33 KiB
Python
# 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)
|
|
|
|
async def _send_message_core(
|
|
|
|
|
|
|
|
conv_id: str,
|
|
|
|
|
|
|
|
req: SendMessageRequest,
|
|
|
|
|
|
|
|
db: Session = Depends(get_db),
|
|
|
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
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 = "<<CLARIFY>>" + json.dumps(
|
|
|
|
|
|
|
|
{"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False
|
|
|
|
|
|
|
|
) + "<<CLARIFY>>"
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = _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),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 构建消息列表
|
|
|
|
|
|
|
|
# 实际调用追踪(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)一并发给模型,
|
|
|
|
|
|
|
|
# 否则用户在对话窗上传文件后模型“看不到”文件内容。
|
|
|
|
|
|
|
|
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 httpx.AsyncClient(
|
|
|
|
|
|
|
|
timeout=ai_model.timeout or 120,
|
|
|
|
|
|
|
|
proxy=_proxy_for_url(api_url),
|
|
|
|
|
|
|
|
trust_env=False,
|
|
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
|
|
async with span("llm.completion", model=ai_model.id if ai_model else None):
|
|
resp = await client.post(
|
|
|
|
|
|
|
|
api_url,
|
|
|
|
|
|
|
|
headers={
|
|
|
|
|
|
|
|
"Authorization": f"Bearer {api_key}",
|
|
|
|
|
|
|
|
"Content-Type": "application/json"
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
json=request_body
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
|
|
|
if round_num == 0:
|
|
|
|
|
|
|
|
ai_content = f"AI模型调用失败 (HTTP {resp.status_code}), 请检查模型配置。"
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
|
|
|
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", [])
|
|
|
|
|
|
|
|
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("mcp.call", name=_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", ""))
|
|
|
|
else:
|
|
|
|
async with span("skill.execute", name=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", ""))
|
|
|
|
|
|
|
|
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 模式下将自动使用平台默认模型。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 "<img" in _ingest_text
|
|
|
|
|
|
|
|
or ":
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
asyncio.create_task(_ingest_answer_to_dify(conv_id, ai_msg.id, _ingest_text))
|
|
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 更新used_skills / used_mcp:优先反馈用户显式指定的;否则反馈本轮实际调用的
|
|
|
|
|
|
|
|
used_skills = []
|
|
|
|
|
|
|
|
used_mcp = []
|
|
|
|
|
|
|
|
if effective_skill_ids:
|
|
|
|
|
|
|
|
# 用户显式选择了技能 -> 反馈配对的技能名
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 更新对话统计
|
|
|
|
|
|
|
|
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"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}
|