Files
qihuaiflow/backend/python/app/api/chat_service.py
T

8638 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 threading
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 = "<<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)
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
# Phase 1: Bill tokens on stream completion
try:
_pt = prompt_tokens if "prompt_tokens" in dir() else 0
_ct = completion_tokens if "completion_tokens" in dir() else 0
_uid = user_id if "user_id" in dir() else "unknown"
if _pt or _ct:
import asyncio
asyncio.create_task(_bill_tokens(
_uid, _pt, _ct,
ai_model if "ai_model" in dir() else "unknown",
conv_id))
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)
auth_header = provider.auth_header if provider and hasattr(provider, "auth_header") 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:
_sname = re.sub(r'[^A-Za-z0-9_-]', '_', _srv.get("name","unknown")[:30])
_tname = re.sub(r'[^A-Za-z0-9_-]', '_', _t.get("name","tool")[:50])
_fname = f"mcp__{_sname}__{_tname}__{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": {}}
_schema = _normalize_mcp_schema(_schema)
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 到平台。\n"
"\n"
"【何时调用】用户明确提供链接并表示要安装/导入/添加时。例如:'帮我安装这个技能 https://github.com/...'\n"
"【何时不调用】用户只询问安装方法、讨论某个工具但没有提供可安装链接时。\n"
"【注意】安装完成后该技能/MCP/Agent 可在本对话中直接使用,无需额外配置。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "要安装的资源完整地址。必须是 http/https URL,支持 GitHub 仓库、mcp.so、modelscope 等平台链接。"},
"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 = 15 # 对齐 GPT-4.1/Anthropic 标准: 足够完成复杂多步工具调用
# ===== 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,
auth_header=auth_header,
):
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,
auth_header=auth_header,
)
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__") 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__"):
# MCP 工具调用:代理到对应 MCP 服务执行 tools/call
_info = mcp_tool_map.get(func_name)
tool_result = "[MCP 工具未找到] 建议: 检查工具名或尝试其他MCP工具。可用: " + str(list(mcp_tool_map.keys())[:5])
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 "<img" in _ingest_text
or "![]( " in _ingest_text
):
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 = []
# 媒体分支直接返回,不会进入 LLM 工具调用;提前初始化避免未绑定
called_skill_names = []
called_mcp_names = []
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
# 更新对话统计
# 注意: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:
_launch_media_task_bg(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 _normalize_mcp_schema(schema: dict) -> dict:
"""MCP JSON Schema -> OpenAI function calling 兼容。
处理: $ref, oneOf/anyOf -> first option, 剥离非标关键字"""
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
if "$ref" in schema:
return {"type": "object", "properties": {}}
if "oneOf" in schema or "anyOf" in schema:
opts = schema.get("oneOf") or schema.get("anyOf") or []
if opts and isinstance(opts[0], dict):
return _normalize_mcp_schema(opts[0])
return {"type": "object", "properties": {}}
if "allOf" in schema:
merged = {"type": "object", "properties": {}}
for s in schema.get("allOf", []):
sub = _normalize_mcp_schema(s)
if sub.get("properties"):
merged["properties"].update(sub["properties"])
return merged
result = {}
for k in ("type", "properties", "required", "description", "enum", "items"):
if k in schema:
result[k] = schema[k]
if result.get("type") != "object":
result["type"] = "object"
if "properties" not in result:
result["properties"] = {}
return result
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_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}
# 后台线程独立事件循环运行媒体生成,确保 SSE 响应结束后任务不被 uviorn 取消,
# 避免图片/视频永远停留在「生成中…」。_generate_media_task 内部自管 DB 会话。
def _launch_media_task_bg(ai_msg_id, ai_model_id, prompt, user_id):
def _run():
try:
asyncio.run(_generate_media_task(ai_msg_id, ai_model_id, prompt, user_id))
except Exception:
pass
_t = threading.Thread(target=_run, daemon=True)
_t.start()
# Phase 1: Token billing
async def _bill_tokens(user_id, prompt_tokens, completion_tokens, model_name, conv_id):
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
total = prompt_tokens + completion_tokens
cost = round(total * 0.002 / 1000, 6)
db.execute(text("UPDATE user_balances SET balance = balance - :c, total_used = total_used + :c, updated_at = NOW() WHERE user_id = :u AND balance >= :c"), {"c": cost, "u": user_id})
db.execute(text("INSERT INTO token_usage_logs (id, user_id, model_name, conversation_id, prompt_tokens, completion_tokens, total_tokens, cost, created_at) VALUES (gen_random_uuid(), :u, :m, :cv, :pt, :ct, :tt, :c, NOW())"), {"u": user_id, "m": model_name, "cv": conv_id, "pt": prompt_tokens, "ct": completion_tokens, "tt": total, "c": cost})
db.commit()
db.close()
except Exception:
pass
async def get_user_balance(user_id):
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
r = db.execute(text("SELECT balance, total_used FROM user_balances WHERE user_id = :u"), {"u": user_id}).fetchone()
db.close()
if r: return {"balance": float(r[0]), "total_used": float(r[1])}
return {"balance": 10.0, "total_used": 0}