8624 lines
57 KiB
Python
8624 lines
57 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 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
|
||
|
||
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 ":
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
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}
|