diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index af6fbaa..747b22c 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -1,1385 +1,2818 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Response -from sqlalchemy.orm import Session -from sqlalchemy import and_, desc, asc, func -from pydantic import BaseModel, Field -from typing import Optional -import uuid -from datetime import datetime, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..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_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url) -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_knowledge import (_build_knowledge_graph) -from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp) -from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent) -from app.api.chat_skill import (_bm25_search, _discover_installed_skills, _execute_skill_tool, _parse_skill_content, _resolve_market_agents, _resolve_market_skills) -from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRequest, UpdateConversationRequest, _HK_PROXY, _extract_text_from_json, _html_escape, _outbound_proxy, _proxy_for_url, cst_now, logger) - -""" - - - -增强版 Chat API — Phase 2.5 - - - -包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入, - - - - 多轮上下文保持, BM25 KB检索, Function Calling循环增强 - - - -""" - -from app.api.chat_service import _send_message_core - -router = APIRouter() - -_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com") - -@router.get("/media/relay") - - - -async def media_relay(url: str = Query(...)): - - - - """中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。 - - - - - - - - 仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。 - - - - """ - - - - import urllib.parse as _up - - - - host = (_up.urlparse(url).hostname or "").lower() - - - - if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED): - - - - raise HTTPException(status_code=400, detail="unsupported media host") - - - - try: - - - - async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client: - - - - r = await client.get(url) - - - - if r.status_code != 200: - - - - raise HTTPException(status_code=r.status_code, detail="upstream error") - - - - content_type = r.headers.get("content-type", "application/octet-stream") - - - - data = await r.aread() - - - - return Response(content=data, media_type=content_type) - - - - except HTTPException: - - - - raise - - - - except Exception as e: - - - - raise HTTPException(status_code=502, detail=f"relay failed: {e}") - -@router.get("/conversations") - - - -def list_conversations( - - - - status: Optional[str] = Query(None), - - - - search: Optional[str] = Query(None), - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - query = db.query(Conversation).filter(Conversation.user_id == current_user.id) - - - - - - - - if status: - - - - query = query.filter(Conversation.status == status) - - - - else: - - - - query = query.filter(Conversation.status != "archived") - - - - - - - - if search: - - - - query = query.filter(Conversation.title.ilike(f"%{search}%")) - - - - - - - - query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at)) - - - - - - - - conversations = query.all() - - - - result = [] - - - - for conv in conversations: - - - - result.append({ - - - - "id": conv.id, - - - - "title": conv.title, - - - - "model_id": conv.model_id, - - - - "status": conv.status, - - - - "pinned": conv.pinned, - - - - "knowledge_graph_count": conv.knowledge_graph_count or 0, - - - - "source": getattr(conv, "source", None), - - - - "created_at": str(conv.created_at) if conv.created_at else None, - - - - "updated_at": str(conv.updated_at) if conv.updated_at else None, - - - - }) - - - - - - - - return {"code": 0, "data": result, "message": "ok"} - -@router.post("/conversations") - - - -def create_conversation( - - - - req: CreateConversationRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = Conversation( - - - - id=gen_uuid(), - - - - user_id=current_user.id, - - - - title=req.title, - - - - model_id=req.model_id, - - - - status="active", - - - - pinned=False, - - - - knowledge_graph_count=0, - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(conv) - - - - db.commit() - - - - db.refresh(conv) - - - - - - - - return { - - - - "code": 0, - - - - "data": { - - - - "id": conv.id, - - - - "title": conv.title, - - - - "model_id": conv.model_id, - - - - "status": conv.status, - - - - "pinned": conv.pinned, - - - - "knowledge_graph_count": conv.knowledge_graph_count, - - - - "created_at": str(conv.created_at) if conv.created_at else None, - - - - "updated_at": str(conv.updated_at) if conv.updated_at else None, - - - - }, - - - - "message": "创建成功", - - - - } - -@router.put("/conversations/{conv_id}") - - - -def update_conversation( - - - - conv_id: str, - - - - req: UpdateConversationRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = db.query(Conversation).filter( - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - ).first() - - - - if not conv: - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - if req.title is not None: - - - - conv.title = req.title - - - - if req.status is not None: - - - - conv.status = req.status - - - - if req.pinned is not None: - - - - conv.pinned = req.pinned - - - - # Stage B.2: 保存agent/skill/kb选择到对话上下文 - - - - # (存储在第一条system消息的knowledge_ids/agent_ids/skill_ids字段中) - - - - conv.updated_at = cst_now() - - - - - - - - db.commit() - - - - db.refresh(conv) - - - - - - - - return {"code": 0, "data": { - - - - "id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned, - - - - "updated_at": str(conv.updated_at), - - - - }, "message": "更新成功"} - -@router.delete("/conversations/{conv_id}") - - - -def delete_conversation( - - - - conv_id: str, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = db.query(Conversation).filter( - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - ).first() - - - - if not conv: - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - conv.status = "archived" - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - - - - - return {"code": 0, "message": "删除成功"} - -@router.get("/conversations/{conv_id}/messages") - - - -def get_messages( - - - - conv_id: str, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = db.query(Conversation).filter( - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - ).first() - - - - if not conv: - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - messages = ( - - - - db.query(Message) - - - - .filter(Message.conversation_id == conv_id) - - - - .order_by(asc(Message.created_at), asc(Message.id)) - - - - .all() - - - - ) - - - - - - - - result = [] - - - - for msg in messages: - - - - result.append({ - - - - "id": msg.id, - - - - "conversation_id": msg.conversation_id, - - - - "role": msg.role, - - - - "content": msg.content, - - - - "token_used": msg.token_used or 0, - - - - "model_id": msg.model_id, - - - - "used_skills": msg.used_skills or [], - "used_mcp": msg.used_mcp or [], - - - - "created_at": str(msg.created_at) if msg.created_at else None, - - - - }) - - - - - - - - return {"code": 0, "data": result, "message": "ok"} - -@router.post("/conversations/{conv_id}/messages") - - - -async def send_message( - - - - conv_id: str, - - - - req: SendMessageRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - from app.core.observability import span - async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)): - return await _send_message_core(conv_id, req, db, current_user) - - -@router.post("/conversations/{conv_id}/clear") - - - -def clear_conversation( - - - - conv_id: str, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = db.query(Conversation).filter( - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - ).first() - - - - if not conv: - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - db.query(Message).filter( - - - - Message.conversation_id == conv_id - - - - ).delete() - - - - - - - - conv.knowledge_graph_count = 0 - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - - - - - return {"code": 0, "message": "对话上下文已清空"} - -@router.post("/conversations/{conv_id}/to-knowledge-graph") - - - -def convert_to_knowledge_graph( - - - - conv_id: str, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - """将对话内容转换为知识图谱(幂等:同一对话仅生成一次知识库)""" - - - - conv = db.query(Conversation).filter( - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - ).first() - - - - if not conv: - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - messages = ( - - - - db.query(Message) - - - - .filter(Message.conversation_id == conv_id) - - - - .order_by(asc(Message.created_at), asc(Message.id)) - - - - .all() - - - - ) - - - - - - - - if not messages: - - - - raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱") - - - - - - - - # 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回 - - - - if conv.knowledge_graph_base_id: - - - - existing_kb = db.query(KnowledgeBase).filter( - - - - KnowledgeBase.id == conv.knowledge_graph_base_id - - - - ).first() - - - - if existing_kb: - - - - graph_data = _build_knowledge_graph(db, existing_kb) - - - - conv.knowledge_graph_count = 1 - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - return { - - - - "code": 0, - - - - "data": { - - - - "knowledge_base_id": existing_kb.id, - - - - "knowledge_base_name": existing_kb.name, - - - - "graph": graph_data, - - - - "knowledge_graph_count": 1, - - - - "already_exists": True, - - - - }, - - - - "message": "知识图谱已存在, 已刷新展示", - - - - } - - - - - - - - kb_name = f"对话图谱-{conv.title}" - - - - kb = KnowledgeBase( - - - - id=gen_uuid(), - - - - user_id=current_user.id, - - - - name=kb_name, - - - - description=f"从对话「{conv.title}」自动生成的知识图谱", - - - - directory_path="/", - - - - document_count=0, - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(kb) - - - - db.flush() - - - - - - - - doc_count = 0 - - - - for idx, msg in enumerate(messages): - - - - role_label = "用户" if msg.role == "user" else "AI" - - - - preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip() - - - - filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt" - - - - - - - - doc = KnowledgeDocument( - - - - id=gen_uuid(), - - - - knowledge_base_id=kb.id, - - - - filename=filename, - - - - file_type="txt", - - - - file_size=len(msg.content.encode('utf-8')), - - - - content=msg.content, - - - - status="completed", - - - - chunk_count=max(1, len(msg.content) // 500 + 1), - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(doc) - - - - doc_count += 1 - - - - - - - - kb.document_count = doc_count - - - - db.flush() - - - - - - - - # 构建知识图谱节点和边 - - - - graph_data = _build_knowledge_graph(db, kb) - - - - conv.knowledge_graph_base_id = kb.id - - - - conv.knowledge_graph_count = 1 - - - - conv.updated_at = cst_now() - - - - - - - - db.commit() - - - - - - - - LogService.log_operation( - - - - user_id=current_user.id, username=current_user.username, action="convert_to_graph", - - - - target="conversation", target_id=conv_id, - - - - detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name}", - - - - ) - - - - - - - - return { - - - - "code": 0, - - - - "data": { - - - - "knowledge_base_id": kb.id, - - - - "knowledge_base_name": kb.name, - - - - "graph": graph_data, - - - - "knowledge_graph_count": 1, - - - - "already_exists": False, - - - - }, - - - - "message": f"知识图谱构建完成", - - - - } - -@router.get("/models") - - - -def get_models(db: Session = Depends(get_db)): - - - - models = db.query(AIModel).filter(AIModel.is_enabled == True).all() - - - - result = [] - - - - for m in models: - - - - provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() - - - - result.append({ - - - - "id": m.id, - - - - "name": m.name, - - - - "display_name": getattr(m, "display_name", None) or m.name, - - - - "provider_id": m.provider_id, - - - - "provider_name": provider.name if provider else "", - - - - "capabilities": m.capabilities or [], - - - - "status": m.status, - - - - "price_per_million_tokens": float(m.price_per_million_tokens or 0), - - - - "is_enabled": m.is_enabled, - - - - "is_default": bool(m.is_default) if hasattr(m, 'is_default') else False, - - - - "total_calls": m.total_calls or 0, - - - - "total_tokens": m.total_tokens or 0, - - - - "created_at": str(m.created_at) if m.created_at else None, - - - - }) - - - - result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"])) - - - - return {"code": 0, "data": result, "message": "ok"} \ No newline at end of file +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi.responses import StreamingResponse + +from sqlalchemy.orm import Session + +from sqlalchemy import and_, desc, asc, func + +from pydantic import BaseModel, Field + +from typing import Optional + +import uuid + +from datetime import datetime, timezone, timedelta + +import re + +import json + +import asyncio + +import math + +import httpx + +import os + +import logging + +from ..database import get_db, SessionLocal + +from ..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_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url) + +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_knowledge import (_build_knowledge_graph) + +from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp) + +from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent) + +from app.api.chat_skill import (_bm25_search, _discover_installed_skills, _execute_skill_tool, _parse_skill_content, _resolve_market_agents, _resolve_market_skills) + +from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRequest, UpdateConversationRequest, _HK_PROXY, _extract_text_from_json, _html_escape, _outbound_proxy, _proxy_for_url, cst_now, logger) + + + +""" + + + + + + + +增强版 Chat API — Phase 2.5 + + + + + + + +包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入, + + + + + + + + 多轮上下文保持, BM25 KB检索, Function Calling循环增强 + + + + + + + +""" + + + +from app.api.chat_service import _send_message_core + + + +router = APIRouter() + + + +_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com") + + + +@router.get("/media/relay") + + + + + + + +async def media_relay(url: str = Query(...)): + + + + + + + + """中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。 + + + + + + + + + + + + + + + + 仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。 + + + + + + + + """ + + + + + + + + import urllib.parse as _up + + + + + + + + host = (_up.urlparse(url).hostname or "").lower() + + + + + + + + if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED): + + + + + + + + raise HTTPException(status_code=400, detail="unsupported media host") + + + + + + + + try: + + + + + + + + async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client: + + + + + + + + r = await client.get(url) + + + + + + + + if r.status_code != 200: + + + + + + + + raise HTTPException(status_code=r.status_code, detail="upstream error") + + + + + + + + content_type = r.headers.get("content-type", "application/octet-stream") + + + + + + + + data = await r.aread() + + + + + + + + return Response(content=data, media_type=content_type) + + + + + + + + except HTTPException: + + + + + + + + raise + + + + + + + + except Exception as e: + + + + + + + + raise HTTPException(status_code=502, detail=f"relay failed: {e}") + + + +@router.get("/conversations") + + + + + + + +def list_conversations( + + + + + + + + status: Optional[str] = Query(None), + + + + + + + + search: Optional[str] = Query(None), + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + query = db.query(Conversation).filter(Conversation.user_id == current_user.id) + + + + + + + + + + + + + + + + if status: + + + + + + + + query = query.filter(Conversation.status == status) + + + + + + + + else: + + + + + + + + query = query.filter(Conversation.status != "archived") + + + + + + + + + + + + + + + + if search: + + + + + + + + query = query.filter(Conversation.title.ilike(f"%{search}%")) + + + + + + + + + + + + + + + + query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at)) + + + + + + + + + + + + + + + + conversations = query.all() + + + + + + + + result = [] + + + + + + + + for conv in conversations: + + + + + + + + result.append({ + + + + + + + + "id": conv.id, + + + + + + + + "title": conv.title, + + + + + + + + "model_id": conv.model_id, + + + + + + + + "status": conv.status, + + + + + + + + "pinned": conv.pinned, + + + + + + + + "knowledge_graph_count": conv.knowledge_graph_count or 0, + + + + + + + + "source": getattr(conv, "source", None), + + + + + + + + "created_at": str(conv.created_at) if conv.created_at else None, + + + + + + + + "updated_at": str(conv.updated_at) if conv.updated_at else None, + + + + + + + + }) + + + + + + + + + + + + + + + + return {"code": 0, "data": result, "message": "ok"} + + + +@router.post("/conversations") + + + + + + + +def create_conversation( + + + + + + + + req: CreateConversationRequest, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + conv = Conversation( + + + + + + + + id=gen_uuid(), + + + + + + + + user_id=current_user.id, + + + + + + + + title=req.title, + + + + + + + + model_id=req.model_id, + + + + + + + + status="active", + + + + + + + + pinned=False, + + + + + + + + knowledge_graph_count=0, + + + + + + + + created_at=cst_now(), + + + + + + + + updated_at=cst_now(), + + + + + + + + ) + + + + + + + + db.add(conv) + + + + + + + + db.commit() + + + + + + + + db.refresh(conv) + + + + + + + + + + + + + + + + return { + + + + + + + + "code": 0, + + + + + + + + "data": { + + + + + + + + "id": conv.id, + + + + + + + + "title": conv.title, + + + + + + + + "model_id": conv.model_id, + + + + + + + + "status": conv.status, + + + + + + + + "pinned": conv.pinned, + + + + + + + + "knowledge_graph_count": conv.knowledge_graph_count, + + + + + + + + "created_at": str(conv.created_at) if conv.created_at else None, + + + + + + + + "updated_at": str(conv.updated_at) if conv.updated_at else None, + + + + + + + + }, + + + + + + + + "message": "创建成功", + + + + + + + + } + + + +@router.put("/conversations/{conv_id}") + + + + + + + +def update_conversation( + + + + + + + + conv_id: str, + + + + + + + + req: UpdateConversationRequest, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + ).first() + + + + + + + + if not conv: + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + if req.title is not None: + + + + + + + + conv.title = req.title + + + + + + + + if req.status is not None: + + + + + + + + conv.status = req.status + + + + + + + + if req.pinned is not None: + + + + + + + + conv.pinned = req.pinned + + + + + + + + # Stage B.2: 保存agent/skill/kb选择到对话上下文 + + + + + + + + # (存储在第一条system消息的knowledge_ids/agent_ids/skill_ids字段中) + + + + + + + + conv.updated_at = cst_now() + + + + + + + + + + + + + + + + db.commit() + + + + + + + + db.refresh(conv) + + + + + + + + + + + + + + + + return {"code": 0, "data": { + + + + + + + + "id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned, + + + + + + + + "updated_at": str(conv.updated_at), + + + + + + + + }, "message": "更新成功"} + + + +@router.delete("/conversations/{conv_id}") + + + + + + + +def delete_conversation( + + + + + + + + conv_id: str, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + ).first() + + + + + + + + if not conv: + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + conv.status = "archived" + + + + + + + + conv.updated_at = cst_now() + + + + + + + + db.commit() + + + + + + + + + + + + + + + + return {"code": 0, "message": "删除成功"} + + + +@router.get("/conversations/{conv_id}/messages") + + + + + + + +def get_messages( + + + + + + + + conv_id: str, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + ).first() + + + + + + + + if not conv: + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + messages = ( + + + + + + + + db.query(Message) + + + + + + + + .filter(Message.conversation_id == conv_id) + + + + + + + + .order_by(asc(Message.created_at), asc(Message.id)) + + + + + + + + .all() + + + + + + + + ) + + + + + + + + + + + + + + + + result = [] + + + + + + + + for msg in messages: + + + + + + + + result.append({ + + + + + + + + "id": msg.id, + + + + + + + + "conversation_id": msg.conversation_id, + + + + + + + + "role": msg.role, + + + + + + + + "content": msg.content, + + + + + + + + "token_used": msg.token_used or 0, + + + + + + + + "model_id": msg.model_id, + + + + + + + + "used_skills": msg.used_skills or [], + + "used_mcp": msg.used_mcp or [], + + + + + + + + "created_at": str(msg.created_at) if msg.created_at else None, + + + + + + + + }) + + + + + + + + + + + + + + + + return {"code": 0, "data": result, "message": "ok"} + + + +@router.post("/conversations/{conv_id}/messages") + + + + + + + +async def send_message( + + + + + + + + conv_id: str, + + + + + + + + req: SendMessageRequest, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + from app.core.observability import span + + async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)): + + return await _send_message_core(conv_id, req, db, current_user) + + +@router.post("/conversations/{conv_id}/messages/stream") +async def send_message_stream( + conv_id: str, + req: SendMessageRequest, + current_user: User = Depends(get_current_user), +): + """SSE 流式聊天端点(Phase 0)。 + 与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event + 回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。 + """ + db = SessionLocal() + try: + queue = asyncio.Queue() + + async def on_event(e): + await queue.put(e) + + async def runner(): + try: + await _send_message_core( + conv_id, req, db, current_user, + stream=True, on_event=on_event, + ) + except Exception as exc: + logger.warning(f"[sse] chat stream error: {exc}", exc_info=True) + await queue.put({"type": "error", "message": str(exc)}) + finally: + await queue.put({"type": "__end__"}) + + task = asyncio.create_task(runner()) + + async def event_gen(): + try: + while True: + e = await queue.get() + if e.get("type") == "__end__": + break + yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n" + finally: + await task + db.close() + + return StreamingResponse(event_gen(), media_type="text/event-stream") + except Exception: + db.close() + raise + + + + + +@router.post("/conversations/{conv_id}/clear") + + + + + + + +def clear_conversation( + + + + + + + + conv_id: str, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + ).first() + + + + + + + + if not conv: + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + db.query(Message).filter( + + + + + + + + Message.conversation_id == conv_id + + + + + + + + ).delete() + + + + + + + + + + + + + + + + conv.knowledge_graph_count = 0 + + + + + + + + conv.updated_at = cst_now() + + + + + + + + db.commit() + + + + + + + + + + + + + + + + return {"code": 0, "message": "对话上下文已清空"} + + + +@router.post("/conversations/{conv_id}/to-knowledge-graph") + + + + + + + +def convert_to_knowledge_graph( + + + + + + + + conv_id: str, + + + + + + + + db: Session = Depends(get_db), + + + + + + + + current_user: User = Depends(get_current_user), + + + + + + + +): + + + + + + + + """将对话内容转换为知识图谱(幂等:同一对话仅生成一次知识库)""" + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + ).first() + + + + + + + + if not conv: + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + messages = ( + + + + + + + + db.query(Message) + + + + + + + + .filter(Message.conversation_id == conv_id) + + + + + + + + .order_by(asc(Message.created_at), asc(Message.id)) + + + + + + + + .all() + + + + + + + + ) + + + + + + + + + + + + + + + + if not messages: + + + + + + + + raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱") + + + + + + + + + + + + + + + + # 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回 + + + + + + + + if conv.knowledge_graph_base_id: + + + + + + + + existing_kb = db.query(KnowledgeBase).filter( + + + + + + + + KnowledgeBase.id == conv.knowledge_graph_base_id + + + + + + + + ).first() + + + + + + + + if existing_kb: + + + + + + + + graph_data = _build_knowledge_graph(db, existing_kb) + + + + + + + + conv.knowledge_graph_count = 1 + + + + + + + + conv.updated_at = cst_now() + + + + + + + + db.commit() + + + + + + + + return { + + + + + + + + "code": 0, + + + + + + + + "data": { + + + + + + + + "knowledge_base_id": existing_kb.id, + + + + + + + + "knowledge_base_name": existing_kb.name, + + + + + + + + "graph": graph_data, + + + + + + + + "knowledge_graph_count": 1, + + + + + + + + "already_exists": True, + + + + + + + + }, + + + + + + + + "message": "知识图谱已存在, 已刷新展示", + + + + + + + + } + + + + + + + + + + + + + + + + kb_name = f"对话图谱-{conv.title}" + + + + + + + + kb = KnowledgeBase( + + + + + + + + id=gen_uuid(), + + + + + + + + user_id=current_user.id, + + + + + + + + name=kb_name, + + + + + + + + description=f"从对话「{conv.title}」自动生成的知识图谱", + + + + + + + + directory_path="/", + + + + + + + + document_count=0, + + + + + + + + created_at=cst_now(), + + + + + + + + updated_at=cst_now(), + + + + + + + + ) + + + + + + + + db.add(kb) + + + + + + + + db.flush() + + + + + + + + + + + + + + + + doc_count = 0 + + + + + + + + for idx, msg in enumerate(messages): + + + + + + + + role_label = "用户" if msg.role == "user" else "AI" + + + + + + + + preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip() + + + + + + + + filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt" + + + + + + + + + + + + + + + + doc = KnowledgeDocument( + + + + + + + + id=gen_uuid(), + + + + + + + + knowledge_base_id=kb.id, + + + + + + + + filename=filename, + + + + + + + + file_type="txt", + + + + + + + + file_size=len(msg.content.encode('utf-8')), + + + + + + + + content=msg.content, + + + + + + + + status="completed", + + + + + + + + chunk_count=max(1, len(msg.content) // 500 + 1), + + + + + + + + created_at=cst_now(), + + + + + + + + updated_at=cst_now(), + + + + + + + + ) + + + + + + + + db.add(doc) + + + + + + + + doc_count += 1 + + + + + + + + + + + + + + + + kb.document_count = doc_count + + + + + + + + db.flush() + + + + + + + + + + + + + + + + # 构建知识图谱节点和边 + + + + + + + + graph_data = _build_knowledge_graph(db, kb) + + + + + + + + conv.knowledge_graph_base_id = kb.id + + + + + + + + conv.knowledge_graph_count = 1 + + + + + + + + conv.updated_at = cst_now() + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + LogService.log_operation( + + + + + + + + user_id=current_user.id, username=current_user.username, action="convert_to_graph", + + + + + + + + target="conversation", target_id=conv_id, + + + + + + + + detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name}", + + + + + + + + ) + + + + + + + + + + + + + + + + return { + + + + + + + + "code": 0, + + + + + + + + "data": { + + + + + + + + "knowledge_base_id": kb.id, + + + + + + + + "knowledge_base_name": kb.name, + + + + + + + + "graph": graph_data, + + + + + + + + "knowledge_graph_count": 1, + + + + + + + + "already_exists": False, + + + + + + + + }, + + + + + + + + "message": f"知识图谱构建完成", + + + + + + + + } + + + +@router.get("/models") + + + + + + + +def get_models(db: Session = Depends(get_db)): + + + + + + + + models = db.query(AIModel).filter(AIModel.is_enabled == True).all() + + + + + + + + result = [] + + + + + + + + for m in models: + + + + + + + + provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() + + + + + + + + result.append({ + + + + + + + + "id": m.id, + + + + + + + + "name": m.name, + + + + + + + + "display_name": getattr(m, "display_name", None) or m.name, + + + + + + + + "provider_id": m.provider_id, + + + + + + + + "provider_name": provider.name if provider else "", + + + + + + + + "capabilities": m.capabilities or [], + + + + + + + + "status": m.status, + + + + + + + + "price_per_million_tokens": float(m.price_per_million_tokens or 0), + + + + + + + + "is_enabled": m.is_enabled, + + + + + + + + "is_default": bool(m.is_default) if hasattr(m, 'is_default') else False, + + + + + + + + "total_calls": m.total_calls or 0, + + + + + + + + "total_tokens": m.total_tokens or 0, + + + + + + + + "created_at": str(m.created_at) if m.created_at else None, + + + + + + + + }) + + + + + + + + result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"])) + + + + + + + + return {"code": 0, "data": result, "message": "ok"} diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index 47c8678..4b10c03 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -1,4218 +1,8531 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). - -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - - - -from fastapi import APIRouter, Depends, HTTPException, Query, Response - -from sqlalchemy.orm import Session - -from sqlalchemy import and_, desc, asc, func - -from pydantic import BaseModel, Field - -from typing import Optional - -import uuid - -from datetime import datetime, timezone, timedelta - -import re - -import json - -import asyncio - -import math - -import httpx - -import os - -import logging - -from ..database import get_db, SessionLocal - -from app.core.observability import span, get_trace_id - -from ..models import ( - - - - - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - - - - - MCPServer, - - - - - - - - gen_uuid - - - - - - - -) - -from ..auth import get_current_user - -from ..platform_knowledge import get_platform_knowledge - -from ..mcp_client import list_mcp_tools, call_mcp_tool - -from ..middleware.log_service import LogService - -from ..capability_registry import resolve_auto_model - -from app.api.chat_agents import (_call_external_agent) - -from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) - -from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) - -from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) - -from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills) - -from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) -# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 -from app.core.llm_client import achat_complete - - - -async def _send_message_core( - - - - - - - - conv_id: str, - - - - - - - - req: SendMessageRequest, - - - - - - - - db: Session = Depends(get_db), - - - - - - - - current_user: User = Depends(get_current_user), - - - - - - - -): - - - - - - - - conv = db.query(Conversation).filter( - - - - - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - - - - - ).first() - - - - - - - - if not conv: - - - - - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - - - - - - - - - # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, - - - - - - - - # 从对话历史中继承上一次的选择 - - - - - - - - effective_skill_ids = req.skill_ids or [] - - - - - - - - effective_agent_ids = req.agent_ids or [] - - - - - - - - effective_kb_ids = req.knowledge_base_ids or [] - - - - - - - - effective_mcp_ids = req.mcp_ids or [] - - - - - - - - effective_use_tools = req.use_tools or False - - - - - - - - - - - - - - - - if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: - - - - - - - - # 从对话上下文继承 - - - - - - - - prev_context = _get_conversation_context(db, conv_id) - - - - - - - - if prev_context: - - - - - - - - effective_skill_ids = prev_context.get("skill_ids", []) - - - - - - - - effective_agent_ids = prev_context.get("agent_ids", []) - - - - - - - - effective_kb_ids = prev_context.get("knowledge_base_ids", []) - - - - - - - - effective_mcp_ids = prev_context.get("mcp_ids", []) - - - - - - - - if effective_skill_ids or effective_agent_ids or effective_mcp_ids: - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) - - - - - - - - attachment_block = "" - - - - - - - - if req.attachments: - - - - - - - - parts = [] - - - - - - - - for att in req.attachments: - - - - - - - - name = att.get("name", "附件") if isinstance(att, dict) else str(att) - - - - - - - - ftype = att.get("file_type", "") if isinstance(att, dict) else "" - - - - - - - - content = att.get("content", "") if isinstance(att, dict) else "" - - - - - - - - if content: - - - - - - - - parts.append(f"【附件: {name}】\n{content}") - - - - - - - - else: - - - - - - - - parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") - - - - - - - - if parts: - - - - - - - - attachment_block = "\n\n" + "\n\n".join(parts) - - - - - - - - - - - - - - - - # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) - - - - - - - - user_msg = Message( - - - - - - - - id=gen_uuid(), - - - - - - - - conversation_id=conv_id, - - - - - - - - role="user", - - - - - - - - content=req.content + attachment_block, - - - - - - - - token_used=len(req.content) // 2, - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - mcp_ids=req.mcp_ids if req.mcp_ids else None, - - - - - - - - attachments=req.attachments if req.attachments else None, - - - - - - - - created_at=cst_now(), - - - - - - - - ) - - - - - - - - db.add(user_msg) - - - - - - - - - - - - - - - - # ===== 模糊创作意图拦截 ===== - - - - - - - - # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, - - - - - - - - # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 - - - - - - - - clarify = detect_ambiguous_creative_intent(req.content) - - - - - - - - if clarify: - - - - - - - - clarify_content = "<>" + json.dumps( - - - - - - - - {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False - - - - - - - - ) + "<>" - - - - - - - - ai_msg = Message( - - - - - - - - id=gen_uuid(), - - - - - - - - conversation_id=conv_id, - - - - - - - - role="assistant", - - - - - - - - content=clarify_content, - - - - - - - - token_used=0, - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - created_at=cst_now(), - - - - - - - - ) - - - - - - - - db.add(ai_msg) - - - - - - - - db.commit() - - - - - - - - db.refresh(user_msg) - - - - - - - - db.refresh(ai_msg) - - - - - - - - return { - - - - - - - - "code": 0, - - - - - - - - "data": { - - - - - - - - "user_message": { - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - }, - - - - - - - - "assistant_message": { - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, - - - - - - - - "created_at": str(ai_msg.created_at), "clarify": clarify, - - - - - - - - }, - - - - - - - - }, - - - - - - - - "message": "请选择创作方向", - - - - - - - - } - - - - - - - - - - - - - - - - # ===== 核心聊天引擎 ===== - - - - - - - - user_id = current_user.id - - - - - - - - model_id = req.model_id or conv.model_id - - - - - - - - prompt_tokens = 0 - - - - - - - - completion_tokens = 0 - - - - - - - - total_tokens = 0 - - - - - - - - ai_content = "" - - - - - - - - - - - - - - - - ai_model = None - - - - - - - - skills = [] # 初始化避免UnboundLocalError - process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 - - - - - - - - - agents = [] # 初始化避免UnboundLocalError - - - - - - - - media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 - - - - - - - - external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 - - - - - - - - - - - - - - - - # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== - - - - - - - - # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, - - - - - - - - # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 - - - - - - - - external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) - - - - # ===== 3.3 多 Agent 编排(图引擎)快路 ===== - - _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) - - if _orchestrator: - - try: - - from app.core.orchestrator import run_graph - - _answer, _trace = await run_graph( - - graph=_orchestrator["orchestration"], - - query=req.content + attachment_block, - - db=db, - - user_id=user_id, - - conv_id=conv_id, - - trace_id=get_trace_id(), - - ) - - ai_content = _answer or "编排未产生内容。" - - logger.info( - - f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" - - ) - - except Exception as e: - - logger.warning(f"[orchestrator] 图执行失败: {e}") - - ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" - - external_handled = True - - external_agent = None # 阻止下方外部 Agent 分支重复执行 - - - - - - - - if external_agent: - - - - - - - - agents = external_agents - - - - - - - - # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) - - - - - - - - _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) - - - - - - - - try: - - - - - - - - answer, new_ext_id = await _call_external_agent( - - - - - - - - db, external_agent, - - - - - - - - query=req.content + attachment_block, - - - - - - - - user_id=user_id, - - - - - - - - ext_conversation_id=conv.ext_agent_conversation_id, - - - - - - - - history=_history, - - - - - - - - ) - - - - - - - - ai_content = answer or "外部 Agent 未返回内容。" - - - - - - - - if new_ext_id: - - - - - - - - conv.ext_agent_conversation_id = new_ext_id - - - - - - - - except Exception as e: - - - - - - - - logger.warning( - - - - - - - - f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " - - - - - - - - f"endpoint={external_agent.get('agent_endpoint')}: {e}" - - - - - - - - ) - - - - - - - - ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" - - - - - - - - external_handled = True - - - - - - - - - - - - - - - - # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) - - - - - - - - if not external_handled: - - - - - - - - if model_id and model_id != "auto": - - - - - - - - ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() - - - - - - - - if not ai_model: - - - - - - - - # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 - - - - - - - - intent = detect_media_intent(req.content) - - - - - - - - resolved = resolve_auto_model(intent, db) if intent else None - - - - - - - - ai_model = resolved or resolve_auto_model("text", db) - - - - - - - - - - - - - - - - - - - - # ===== Model Failover: Auto 模式下自动切换备选模型 ===== - _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] - if not model_id or model_id == "auto": - # Auto 模式:附加其他已启用的文本模型作为备选 - _fallbacks = db.query(AIModel).filter( - AIModel.is_enabled == True, - AIModel.id != (ai_model.id if ai_model else "") - ).order_by(AIModel.is_default.desc()).limit(5).all() - _try_models.extend([m for m in _fallbacks if m not in _try_models]) - _retry_log = [] - _all_failed = False - - for _try_model in _try_models: - ai_model = _try_model - if ai_model: - - - - - - - - # ====== 图像/视频生成模型分支 ====== - - - - - - - - caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] - - - - - - - - media_generating = False - - - - - - - - if ai_model and ("image" in caps or "video" in caps): - - - - - - - - # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, - - - - - - - - # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 - - - - - - - - media_generating = True - - - - - - - - if "video" in caps: - - - - - - - - ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" - - - - - - - - else: - - - - - - - - ai_content = "⏳ 图像生成中…(完成后自动刷新)" - - - - - - - - elif ai_model and ai_model.endpoint: - - - - - - - - provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() - - - - - - - - api_key = ai_model.api_key or (provider.api_key if provider else None) - - - - - - - - api_url = ai_model.endpoint - - - - - - - - - - - - - - - - if not api_url and provider and provider.api_base_url: - - - - - - - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - - - - - - - - - - - - - - - if api_key and api_url: - - - - - - - - try: - - - - - - - - # Stage A+B: 解析Skills和Agents - - - - - - - - skills = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions - - - - mcp_servers_conn = mcps - - - - mcp_function_definitions = [] - - - - mcp_tool_map = {} - - - - mcp_idx = 0 - - - - for _srv in mcp_servers_conn: - - - - try: - - - - async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): - - _tools = await list_mcp_tools(_srv) - - - - except Exception as _e: - - - - logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") - - - - _tools = [] - - - - if _tools: - - - - try: - - - - _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() - - - - if _upd: - - - - _upd.tools_count = len(_tools) - - - - db.commit() - - - - except Exception: - - - - pass - - - - for _t in _tools: - - - - _fname = f"mcp_tool_{mcp_idx}" - - - - mcp_tool_map[_fname] = { - - - - "server_id": _srv["id"], - - - - "name": _t.get("name"), - - - - "server_name": _srv["name"], - - - - } - - - - _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} - - - - if not isinstance(_schema, dict) or _schema.get("type") != "object": - - - - _schema = {"type": "object", "properties": {}} - - - - mcp_function_definitions.append({ - - - - "name": _fname, - - - - "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", - - - - "parameters": _schema, - - - - }) - - - - mcp_idx += 1 - - - - if mcp_function_definitions: - - - - effective_use_tools = True - - - - - - - - - - - - - - - - # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) - - - - - - - - # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 - - - - - - - - used_auto_skills = False - - - - - - - - if not effective_skill_ids: - - - - - - - - try: - - - - - - - - discovered = _discover_installed_skills(db, user_id) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"自动发现已安装技能失败: {e}") - - - - - - - - discovered = [] - - - - - - - - if discovered: - - - - - - - - skills = discovered - - - - - - - - used_auto_skills = True - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - # Stage B.1: Agent可能自带skill_ids和kb_ids - - - - - - - - agent_extra_skill_ids = [] - - - - - - - - agent_extra_kb_ids = [] - - - - - - - - agent_model_override = None - - - - - - - - for agent in agents: - - - - - - - - if agent.get("skill_ids"): - - - - - - - - agent_extra_skill_ids.extend(agent["skill_ids"]) - - - - - - - - if agent.get("knowledge_base_ids"): - - - - - - - - agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) - - - - - - - - if agent.get("model_id"): - - - - - - - - agent_model_override = agent["model_id"] - - - - - - - - - - - - - - - - # 合并Agent自带的skills和KBs - - - - - - - - all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) - - - - - - - - all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) - - - - - - - - - - - - - - - - if agent_extra_skill_ids or agent_extra_kb_ids: - - - - - - - - # 重新解析合并后的skills - - - - - - - - skills = _resolve_market_skills(db, all_skill_ids, user_id) - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - # Stage C: 增强版KB检索 - - - - - - - - kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) - - - - - - - - - - - - - - - - # Stage B: 动态组装system prompt + function definitions - - - - - - - - system_prompt, function_definitions = _build_system_prompt( - - - - - - - - skills=skills, - - - - - - - - agents=agents, - - - - - - - - kb_context=kb_context, - - - - - - - - user_content=req.content, - - - - - - - - mcp=mcps, - - - - mcp_functions=mcp_function_definitions, - - - - auto_discovered=used_auto_skills, - - all_mcp=_list_enabled_mcp_servers(db), - - db=db, - - ) - - - - - - - - - - - - - - - - # 构建消息列表 - - - - - - - - # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) - - called_skill_names = [] - - called_mcp_names = [] - - - - messages = [{"role": "system", "content": system_prompt}] - - - - - - - - - - - - - - - - # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) - - messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) - - - - - - - - - - - - - - - - # Issue 1: 必须把附件内容(attachment_block)一并发给模型, - - - - - - - - # 否则用户在对话窗上传文件后模型“看不到”文件内容。 - - - - - - - - # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 - try: - from app.api.url_reader import fetch_url_context - _url_ctx = await fetch_url_context(req.content) - except Exception as _e: - import logging as _logging - _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) - _url_ctx = None - if _url_ctx and _url_ctx.get("text"): - messages.append({ - "role": "system", - "content": ( - "【用户随消息提供的参考链接的真实内容】\n" - f"链接地址: {_url_ctx.get('url')}\n" - "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" - "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," - "不要凭空编造步骤:\n\n" + _url_ctx["text"] - ), - }) - process_steps.append({ - "type": "skill", - "name": "网页/仓库读取", - "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", - }) - messages.append({"role": "user", "content": req.content + attachment_block}) - - - - - - - - - - - - - - - - # Stage B.3: Function Calling循环(最多5轮) - - - - - - - - max_tool_rounds = 5 - - - - - - - - # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== - - _agent_tool_policy = _resolve_agent_tool_policy(agents) - - _tool_call_count = 0 - - for round_num in range(max_tool_rounds): - - _round_tool_count = 0 - - - - - - - - request_body = { - - - - - - - - "model": ai_model.name, - - - - - - - - "messages": messages, - - - - - - - - "max_tokens": 2048, - - - - - - - - } - - - - - - - - if function_definitions and effective_use_tools: - - - - - - - - request_body["tools"] = [ - - - - - - - - {"type": "function", "function": fd} - - - - - - - - for fd in function_definitions - - - - - - - - ] - - - - - - - - request_body["tool_choice"] = "auto" - - - - - - - - - - - - - - - - async with span("llm.completion", model=ai_model.id if ai_model else None): - try: - data = await achat_complete( - api_url, - api_key, - request_body, - proxy=_proxy_for_url(api_url), - timeout=ai_model.timeout or 120, - ) - except Exception as _exc: - if round_num == 0: - ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" - logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") - break - choice = data.get("choices", [{}])[0] - message = choice.get("message", {}) - - - - - - - - - - - - - - - - # 记录token使用量 - - - - - - - - usage = data.get("usage", {}) - - - - - - - - prompt_tokens += usage.get("prompt_tokens", 0) - - - - - - - - completion_tokens += usage.get("completion_tokens", 0) - - - - - - - - total_tokens += usage.get("total_tokens", 0) - - - - - - - - - - - - - - - - # 检查是否有tool_calls - - - - - - - - tool_calls = message.get("tool_calls", []) - # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) - _think = (message.get("content") or "").strip() - if _think and tool_calls: - process_steps.append({ - "type": "thinking", - "text": _think, - }) - - - - - - - - - if tool_calls and effective_use_tools: - - - - - - - - # 将assistant消息(含tool_calls)加入上下文 - - - - - - - - messages.append({ - - - - - - - - "role": "assistant", - - - - - - - - "content": message.get("content") or "", - - - - - - - - "tool_calls": tool_calls, - - - - - - - - }) - - - - - - - - - - - - - - - - # Stage A.2: 真实Skill执行 - - - - - - - - for tc in tool_calls: - - - - - - - - func_name = tc.get("function", {}).get("name", "") - - # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== - - _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None - - _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) - - if not _policy_check["allowed"]: - - _reason = _policy_check["reason"] - - logger.warning( - - f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " - - f"tool={func_name} reason={_reason} trace={get_trace_id()}" - - ) - - tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" - - messages.append({ - - "role": "tool", - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - "content": tool_result, - - }) - - _tool_call_count += 1 - - _round_tool_count += 1 - - continue - - _tool_call_count += 1 - - _round_tool_count += 1 - - - - - - - - func_args_str = tc.get("function", {}).get("arguments", "{}") - - - - - - - - try: - - - - - - - - func_args = json.loads(func_args_str) - - - - - - - - except Exception: - - - - - - - - func_args = {"input": func_args_str} - - - - - - - - - - - - - - - - if func_name.startswith("mcp_tool_"): - - - - # MCP 工具调用:代理到对应 MCP 服务执行 tools/call - - - - _info = mcp_tool_map.get(func_name) - - - - tool_result = "[MCP 工具未找到或映射缺失]" - - - - if _info: - - - - _srv = next( - - - - (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), - - - - None, - - - - ) - - - - if _srv: - - - - async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): - - tool_result = await call_mcp_tool( - - - - _srv, _info["name"], func_args - - - - ) - - - - called_mcp_names.append(_srv.get("name", "")) - process_steps.append({ - "type": "mcp", - "name": _info.get("name") if isinstance(_info, dict) else func_name, - "server": _srv.get("name", ""), - "args": func_args, - "result": (tool_result or "")[:800], - }) - - - - - else: - - - - async with span(f"skill.execute:{func_name}"): - - tool_result = _execute_skill_tool( - - - - db, func_name, func_args, skills, req.content - - - - ) - - - - _sk = next((s for s in skills if s.get("func_name") == func_name), None) - - - - if _sk: - - - - called_skill_names.append(_sk.get("name", "")) - process_steps.append({ - "type": "skill", - "name": (_sk.get("name") if _sk else func_name), - "args": func_args, - "result": (tool_result or "")[:800], - }) - - - - - - - - - messages.append({ - - - - - - - - "role": "tool", - - - - - - - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - - - - - - - "content": tool_result, - - - - - - - - }) - - - - - - - - - - - - - - - - # 继续下一轮让模型处理tool结果 - - - - - - - - continue - - - - - - - - else: - - - - - - - - # 普通回复 - - - - - - - - ai_content = message.get("content", "") - - - - - - - - if not ai_content and round_num > 0: - - - - - - - - ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" - - - - - - - - break - - - - - - - - - - - - - - - - if not ai_content: - - - - - - - - ai_content = "AI模型返回了空响应, 请稍后重试。" - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"AI API调用异常: {e}") - - - - - - - - ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" - - - - - - - - else: - - - - - - - - ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" - - - - - - - - else: - - - - - - - - ai_content = "请选择一个已配置的AI模型进行对话。" - - - - - - - - elif not external_handled: - - - - - - - - ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" - - - - - # ===== Post-check: 失败则切换备选,成功则跳出 ===== - _is_error = ( - ai_content and ( - "AI模型调用失败" in str(ai_content) - or "AI服务暂时不可用" in str(ai_content) - or "AI模型返回了空响应" in str(ai_content) - or "该模型缺少API密钥" in str(ai_content) - ) - ) - if _is_error: - _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") - logger.warning( - f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." - ) - continue # try next model - - # Success — exit failover loop - break - else: - # Loop completed without break = all models failed - _all_failed = True - - # ===== All models failed? ===== - if _all_failed: - _names = ", ".join([m.name for m in _try_models]) - _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" - ai_content = ( - f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" - f"最后错误: {_details}\n" - f"请稍后重试或检查模型配置。" - ) - logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") - - - - - - - - - - - - - - - - - - - - - ai_token_count = len(ai_content) // 2 - - - - - - - - ai_msg = Message( - - - - - - - - id=gen_uuid(), - - - - - - - - conversation_id=conv_id, - - - - - - - - role="assistant", - - - - - - - - content=ai_content, - - - - - - - - token_used=ai_token_count, - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - created_at=cst_now(), - - - - - - - - ) - - - - - - - - db.add(ai_msg) - - - - - - - - - - - - - - - - # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) - - - - - - - - _ingest_text = (ai_content or "").strip() - - - - - - - - if len(_ingest_text) > 120 and not ( - - - - - - - - _ingest_text.startswith("[MEDIA") - - - - - - - - or _ingest_text.startswith("data:") - - - - - - - - or _ingest_text.startswith("http") - - - - - - - - or " 反馈配对的技能名 - - - - - - - - try: - - - - - - - - _sel = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - used_skills = [s["name"] for s in _sel] - - - - - - - - except Exception: - - - - - - - - used_skills = [] - - - - - - - - elif called_skill_names: - - - - - - - - # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) - - - - - - - - used_skills = list(dict.fromkeys(called_skill_names)) - - - - - - - - if effective_mcp_ids: - - - - - - - - # 用户显式选择了 MCP -> 反馈配对的 MCP 名 - - - - - - - - try: - - - - - - - - _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - used_mcp = [m["name"] for m in _sel_mcp] - - - - - - - - except Exception: - - - - - - - - used_mcp = [] - - - - - - - - elif called_mcp_names: - - - - - - - - used_mcp = list(dict.fromkeys(called_mcp_names)) - - - - - - - - ai_msg.used_skills = used_skills - - - - - - - - ai_msg.used_mcp = used_mcp - - - - - - - - - - - - - - - - # 更新对话统计 - - - - - - - - # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, - # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 - - - - - - - - conv.updated_at = cst_now() - - - - - - - - - - - - - - - - # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) - - - - - - - - if not media_generating: - - - - - - - - token_usage = TokenUsageLog( - - - - - - - - id=gen_uuid(), - - - - - - - - user_id=current_user.id, - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - model_name=ai_model.name if ai_model else None, - - - - - - - - conversation_id=conv_id, - - trace_id=get_trace_id(), - - - - - - - - - - prompt_tokens=prompt_tokens or len(req.content) // 2, - - - - - - - - completion_tokens=completion_tokens or ai_token_count, - - - - - - - - total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), - - - - - - - - created_at=cst_now(), - - - - - - - - ) - - - - - - - - db.add(token_usage) - - - - - - - - - - - - - - - - # 进化轨迹记录 - - - - - - - - try: - - - - - - - - existing = db.query(EvolutionTrajectory).filter( - - - - - - - - EvolutionTrajectory.conversation_id == conv_id - - - - - - - - ).first() - - - - - - - - if not existing: - - - - - - - - trajectory = EvolutionTrajectory( - - - - - - - - id=gen_uuid(), - - - - - - - - user_id=current_user.id, - - - - - - - - conversation_id=conv_id, - - - - - - - - status="discovered", - - - - - - - - token_used=ai_token_count, - - - - - - - - created_at=cst_now(), - - - - - - - - ) - - - - - - - - db.add(trajectory) - - - - - - - - db.flush() - - - - - - - - - - - - - - - - try: - - - - - - - - from app.services.evolution_service import EvolutionWatcherManager - - - - - - - - watcher = EvolutionWatcherManager() - - - - - - - - status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) - - - - - - - - if not status.get("scheduler_running"): - - - - - - - - watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) - - - - - - - - logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") - - - - - - - - except Exception as evolution_err: - - - - - - - - logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - - - - - - - - - db.commit() - - - - - - - - # 后台异步压缩历史(分层记忆),不阻塞本次响应 - - asyncio.create_task(_maybe_compress_history(conv_id)) - - - - db.refresh(user_msg) - - - - - - - - db.refresh(ai_msg) - - - - - - - - - - - - - - - - # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 - - - - - - - - if media_generating: - - - - - - - - try: - - - - - - - - asyncio.create_task( - - - - - - - - _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) - - - - - - - - ) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) - # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 - try: - ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" - db.commit() - except Exception: - pass - - - - - - - - - - - - - - - - return { - - - - - - - - "code": 0, - - - - - - - - "data": { - - - - - - - - "user_message": { - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - }, - - - - - - - - "assistant_message": { - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, - - - - - - - - "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), - - - - - - - - "used_skills": used_skills, - - "used_mcp": used_mcp, - - - - - - - - "generating": media_generating, - "process_steps": process_steps, - - - - - - - - - }, - - - - - - - - }, - - - - - - - - "message": "发送成功", - - - - - - - - } - - - - - -# ====================================================================== - -# 3.1 tool_policy 沙箱辅助函数 - -# ====================================================================== - -def _resolve_agent_tool_policy(agents): - - """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" - - if not agents: - - return None - - for ag in agents: - - tp = ag.get("tool_policy") - - if tp and isinstance(tp, dict): - - tp = dict(tp) - - tp["agent_id"] = ag.get("id") - - return tp - - return None - - - - - -def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): - - """校验单个工具调用是否被策略允许。 - - - - 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} - - policy 为 None/空 -> 允许(向后兼容)。 - - """ - - if not policy: - - return {"allowed": True, "tool_id": None} - - allowed = policy.get("allowed_tools") or {} - - skill_ids = allowed.get("skill_ids") or [] - - mcp_ids = allowed.get("mcp_ids") or [] - - is_mcp = func_name.startswith("mcp_tool_") - - tool_id = None - - if is_mcp: - - if mcp_info and isinstance(mcp_info, dict): - - tool_id = mcp_info.get("server_id") - - if mcp_ids and tool_id not in mcp_ids: - - return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, - - "message": "MCP 服务不在该智能体的允许列表中"} - - else: - - if skills: - - _s = next((s for s in skills if s.get("func_name") == func_name), None) - - if _s: - - tool_id = _s.get("id") - - if skill_ids and tool_id not in skill_ids: - - return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, - - "message": "技能不在该智能体的允许列表中"} - - budget = policy.get("max_calls_per_conversation") - - if budget is not None and call_count >= int(budget): - - return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, - - "message": policy.get("fallback_message") - - or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} - - per_round = policy.get("per_round_cap") - - if per_round is not None and round_count >= int(per_round): - - return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, - - "message": "本轮工具调用已达上限"} - - return {"allowed": True, "tool_id": tool_id} - +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). + + + +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + + + + + + + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + + + +from sqlalchemy.orm import Session + + + +from sqlalchemy import and_, desc, asc, func + + + +from pydantic import BaseModel, Field + + + +from typing import Optional + + + +import uuid + + + +from datetime import datetime, timezone, timedelta + + + +import re + + + +import json + + + +import asyncio + + + +import math + + + +import httpx + + + +import os + + + +import logging + + + +from ..database import get_db, SessionLocal + + + +from app.core.observability import span, get_trace_id + + + +from ..models import ( + + + + + + + + + + + + + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + + + + + + + + + + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + + + + + + + + + + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + + + + + + + + + + + + + MCPServer, + + + + + + + + + + + + + + + + gen_uuid + + + + + + + + + + + + + + + +) + + + +from ..auth import get_current_user + + + +from ..platform_knowledge import get_platform_knowledge + + + +from ..mcp_client import list_mcp_tools, call_mcp_tool + + + +from ..middleware.log_service import LogService + + + +from ..capability_registry import resolve_auto_model + + + +from app.api.chat_agents import (_call_external_agent) + + + +from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) + + + +from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) + + + +from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) + + + +from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills) + + + +from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) + +# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 + +from app.core.llm_client import achat_complete + + + + + + + +async def _send_message_core( + + + + + + + + + + + + + + + + conv_id: str, + + + + + + + + + + + + + + + + req: SendMessageRequest, + + + + + + + + + + + + + + + + db: Session = Depends(get_db), + + + + + + + + + + + + + + + + current_user: User = Depends(get_current_user), + stream: bool = False, + on_event=None, + + + + + + + + + + + + + + + +): + + + + + + + + + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not conv: + + + + + + + + + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, + + + + + + + + + + + + + + + + # 从对话历史中继承上一次的选择 + + + + + + + + + + + + + + + + effective_skill_ids = req.skill_ids or [] + + + + + + + + + + + + + + + + effective_agent_ids = req.agent_ids or [] + + + + + + + + + + + + + + + + effective_kb_ids = req.knowledge_base_ids or [] + + + + + + + + + + + + + + + + effective_mcp_ids = req.mcp_ids or [] + + + + + + + + + + + + + + + + effective_use_tools = req.use_tools or False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: + + + + + + + + + + + + + + + + # 从对话上下文继承 + + + + + + + + + + + + + + + + prev_context = _get_conversation_context(db, conv_id) + + + + + + + + + + + + + + + + if prev_context: + + + + + + + + + + + + + + + + effective_skill_ids = prev_context.get("skill_ids", []) + + + + + + + + + + + + + + + + effective_agent_ids = prev_context.get("agent_ids", []) + + + + + + + + + + + + + + + + effective_kb_ids = prev_context.get("knowledge_base_ids", []) + + + + + + + + + + + + + + + + effective_mcp_ids = prev_context.get("mcp_ids", []) + + + + + + + + + + + + + + + + if effective_skill_ids or effective_agent_ids or effective_mcp_ids: + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) + + + + + + + + + + + + + + + + attachment_block = "" + + + + + + + + + + + + + + + + if req.attachments: + + + + + + + + + + + + + + + + parts = [] + + + + + + + + + + + + + + + + for att in req.attachments: + + + + + + + + + + + + + + + + name = att.get("name", "附件") if isinstance(att, dict) else str(att) + + + + + + + + + + + + + + + + ftype = att.get("file_type", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + content = att.get("content", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + if content: + + + + + + + + + + + + + + + + parts.append(f"【附件: {name}】\n{content}") + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") + + + + + + + + + + + + + + + + if parts: + + + + + + + + + + + + + + + + attachment_block = "\n\n" + "\n\n".join(parts) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) + + + + + + + + + + + + + + + + user_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="user", + + + + + + + + + + + + + + + + content=req.content + attachment_block, + + + + + + + + + + + + + + + + token_used=len(req.content) // 2, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + mcp_ids=req.mcp_ids if req.mcp_ids else None, + + + + + + + + + + + + + + + + attachments=req.attachments if req.attachments else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(user_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 模糊创作意图拦截 ===== + + + + + + + + + + + + + + + + # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, + + + + + + + + + + + + + + + + # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 + + + + + + + + + + + + + + + + clarify = detect_ambiguous_creative_intent(req.content) + + + + + + + + + + + + + + + + if clarify: + + + + + + + + + + + + + + + + clarify_content = "<>" + json.dumps( + + + + + + + + + + + + + + + + {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False + + + + + + + + + + + + + + + + ) + "<>" + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=clarify_content, + + + + + + + + + + + + + + + + token_used=0, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, + + + + + + + + + + + + + + + + "created_at": str(ai_msg.created_at), "clarify": clarify, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "请选择创作方向", + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 核心聊天引擎 ===== + + + + + + + + + + + + + + + + user_id = current_user.id + + + + + + + + + + + + + + + + model_id = req.model_id or conv.model_id + + + + + + + + + + + + + + + + prompt_tokens = 0 + + + + + + + + + + + + + + + + completion_tokens = 0 + + + + + + + + + + + + + + + + total_tokens = 0 + + + + + + + + + + + + + + + + ai_content = "" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_model = None + + + + + + + + + + + + + + + + skills = [] # 初始化避免UnboundLocalError + + process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 + + + + + + + + + + + + + + + + + + agents = [] # 初始化避免UnboundLocalError + + + + + + + + + + + + + + + + media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 + + + + + + + + + + + + + + + + external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== + + + + + + + + + + + + + + + + # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, + + + + + + + + + + + + + + + + # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 + + + + + + + + + + + + + + + + external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) + + + + + + + + # ===== 3.3 多 Agent 编排(图引擎)快路 ===== + + + + _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) + + + + if _orchestrator: + + + + try: + + + + from app.core.orchestrator import run_graph + + + + _answer, _trace = await run_graph( + + + + graph=_orchestrator["orchestration"], + + + + query=req.content + attachment_block, + + + + db=db, + + + + user_id=user_id, + + + + conv_id=conv_id, + + + + trace_id=get_trace_id(), + + + + ) + + + + ai_content = _answer or "编排未产生内容。" + + + + logger.info( + + + + f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" + + + + ) + + + + except Exception as e: + + + + logger.warning(f"[orchestrator] 图执行失败: {e}") + + + + ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" + + + + external_handled = True + + + + external_agent = None # 阻止下方外部 Agent 分支重复执行 + + + + + + + + + + + + + + + + if external_agent: + + + + + + + + + + + + + + + + agents = external_agents + + + + + + + + + + + + + + + + # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) + + + + + + + + + + + + + + + + _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + answer, new_ext_id = await _call_external_agent( + + + + + + + + + + + + + + + + db, external_agent, + + + + + + + + + + + + + + + + query=req.content + attachment_block, + + + + + + + + + + + + + + + + user_id=user_id, + + + + + + + + + + + + + + + + ext_conversation_id=conv.ext_agent_conversation_id, + + + + + + + + + + + + + + + + history=_history, + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = answer or "外部 Agent 未返回内容。" + + + + + + + + + + + + + + + + if new_ext_id: + + + + + + + + + + + + + + + + conv.ext_agent_conversation_id = new_ext_id + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning( + + + + + + + + + + + + + + + + f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " + + + + + + + + + + + + + + + + f"endpoint={external_agent.get('agent_endpoint')}: {e}" + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" + + + + + + + + + + + + + + + + external_handled = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) + + + + + + + + + + + + + + + + if not external_handled: + + + + + + + + + + + + + + + + if model_id and model_id != "auto": + + + + + + + + + + + + + + + + ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() + + + + + + + + + + + + + + + + if not ai_model: + + + + + + + + + + + + + + + + # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 + + + + + + + + + + + + + + + + intent = detect_media_intent(req.content) + + + + + + + + + + + + + + + + resolved = resolve_auto_model(intent, db) if intent else None + + + + + + + + + + + + + + + + ai_model = resolved or resolve_auto_model("text", db) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== Model Failover: Auto 模式下自动切换备选模型 ===== + + _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] + + if not model_id or model_id == "auto": + + # Auto 模式:附加其他已启用的文本模型作为备选 + + _fallbacks = db.query(AIModel).filter( + + AIModel.is_enabled == True, + + AIModel.id != (ai_model.id if ai_model else "") + + ).order_by(AIModel.is_default.desc()).limit(5).all() + + _try_models.extend([m for m in _fallbacks if m not in _try_models]) + + _retry_log = [] + + _all_failed = False + + + + for _try_model in _try_models: + + ai_model = _try_model + + if ai_model: + + + + + + + + + + + + + + + + # ====== 图像/视频生成模型分支 ====== + + + + + + + + + + + + + + + + caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] + + + + + + + + + + + + + + + + media_generating = False + + + + + + + + + + + + + + + + if ai_model and ("image" in caps or "video" in caps): + + + + + + + + + + + + + + + + # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, + + + + + + + + + + + + + + + + # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 + + + + + + + + + + + + + + + + media_generating = True + + + + + + + + + + + + + + + + if "video" in caps: + + + + + + + + + + + + + + + + ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "⏳ 图像生成中…(完成后自动刷新)" + + + + + + + + + + + + + + + + elif ai_model and ai_model.endpoint: + + + + + + + + + + + + + + + + provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() + + + + + + + + + + + + + + + + api_key = ai_model.api_key or (provider.api_key if provider else None) + + + + + + + + + + + + + + + + api_url = ai_model.endpoint + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not api_url and provider and provider.api_base_url: + + + + + + + + + + + + + + + + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if api_key and api_url: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + # Stage A+B: 解析Skills和Agents + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions + + + + + + + + mcp_servers_conn = mcps + + + + + + + + mcp_function_definitions = [] + + + + + + + + mcp_tool_map = {} + + + + + + + + mcp_idx = 0 + + + + + + + + for _srv in mcp_servers_conn: + + + + + + + + try: + + + + + + + + async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): + + + + _tools = await list_mcp_tools(_srv) + + + + + + + + except Exception as _e: + + + + + + + + logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") + + + + + + + + _tools = [] + + + + + + + + if _tools: + + + + + + + + try: + + + + + + + + _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() + + + + + + + + if _upd: + + + + + + + + _upd.tools_count = len(_tools) + + + + + + + + db.commit() + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + for _t in _tools: + + + + + + + + _fname = f"mcp_tool_{mcp_idx}" + + + + + + + + mcp_tool_map[_fname] = { + + + + + + + + "server_id": _srv["id"], + + + + + + + + "name": _t.get("name"), + + + + + + + + "server_name": _srv["name"], + + + + + + + + } + + + + + + + + _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} + + + + + + + + if not isinstance(_schema, dict) or _schema.get("type") != "object": + + + + + + + + _schema = {"type": "object", "properties": {}} + + + + + + + + mcp_function_definitions.append({ + + + + + + + + "name": _fname, + + + + + + + + "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", + + + + + + + + "parameters": _schema, + + + + + + + + }) + + + + + + + + mcp_idx += 1 + + + + + + + + if mcp_function_definitions: + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) + + + + + + + + + + + + + + + + # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 + + + + + + + + + + + + + + + + used_auto_skills = False + + + + + + + + + + + + + + + + if not effective_skill_ids: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + discovered = _discover_installed_skills(db, user_id) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"自动发现已安装技能失败: {e}") + + + + + + + + + + + + + + + + discovered = [] + + + + + + + + + + + + + + + + if discovered: + + + + + + + + + + + + + + + + skills = discovered + + + + + + + + + + + + + + + + used_auto_skills = True + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.1: Agent可能自带skill_ids和kb_ids + + + + + + + + + + + + + + + + agent_extra_skill_ids = [] + + + + + + + + + + + + + + + + agent_extra_kb_ids = [] + + + + + + + + + + + + + + + + agent_model_override = None + + + + + + + + + + + + + + + + for agent in agents: + + + + + + + + + + + + + + + + if agent.get("skill_ids"): + + + + + + + + + + + + + + + + agent_extra_skill_ids.extend(agent["skill_ids"]) + + + + + + + + + + + + + + + + if agent.get("knowledge_base_ids"): + + + + + + + + + + + + + + + + agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) + + + + + + + + + + + + + + + + if agent.get("model_id"): + + + + + + + + + + + + + + + + agent_model_override = agent["model_id"] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 合并Agent自带的skills和KBs + + + + + + + + + + + + + + + + all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) + + + + + + + + + + + + + + + + all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if agent_extra_skill_ids or agent_extra_kb_ids: + + + + + + + + + + + + + + + + # 重新解析合并后的skills + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, all_skill_ids, user_id) + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage C: 增强版KB检索 + + + + + + + + + + + + + + + + kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B: 动态组装system prompt + function definitions + + + + + + + + + + + + + + + + system_prompt, function_definitions = _build_system_prompt( + + + + + + + + + + + + + + + + skills=skills, + + + + + + + + + + + + + + + + agents=agents, + + + + + + + + + + + + + + + + kb_context=kb_context, + + + + + + + + + + + + + + + + user_content=req.content, + + + + + + + + + + + + + + + + mcp=mcps, + + + + + + + + mcp_functions=mcp_function_definitions, + + + + + + + + auto_discovered=used_auto_skills, + + + + all_mcp=_list_enabled_mcp_servers(db), + + + + db=db, + + + + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 构建消息列表 + + + + + + + + + + + + + + + + # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) + + + + called_skill_names = [] + + + + called_mcp_names = [] + + + + + + + + messages = [{"role": "system", "content": system_prompt}] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) + + + + messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 1: 必须把附件内容(attachment_block)一并发给模型, + + + + + + + + + + + + + + + + # 否则用户在对话窗上传文件后模型“看不到”文件内容。 + + + + + + + + + + + + + + + + # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 + + try: + + from app.api.url_reader import fetch_url_context + + _url_ctx = await fetch_url_context(req.content) + + except Exception as _e: + + import logging as _logging + + _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) + + _url_ctx = None + + if _url_ctx and _url_ctx.get("text"): + + messages.append({ + + "role": "system", + + "content": ( + + "【用户随消息提供的参考链接的真实内容】\n" + + f"链接地址: {_url_ctx.get('url')}\n" + + "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" + + "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," + + "不要凭空编造步骤:\n\n" + _url_ctx["text"] + + ), + + }) + + process_steps.append({ + + "type": "skill", + + "name": "网页/仓库读取", + + "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", + + }) + + messages.append({"role": "user", "content": req.content + attachment_block}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.3: Function Calling循环(最多5轮) + + + + + + + + + + + + + + + + max_tool_rounds = 5 + + + + + + + + + + + + + + + + # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== + + + + _agent_tool_policy = _resolve_agent_tool_policy(agents) + + + + _tool_call_count = 0 + + + + for round_num in range(max_tool_rounds): + + + + _round_tool_count = 0 + + + + + + + + + + + + + + + + request_body = { + + + + + + + + + + + + + + + + "model": ai_model.name, + + + + + + + + + + + + + + + + "messages": messages, + + + + + + + + + + + + + + + + "max_tokens": 2048, + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + if function_definitions and effective_use_tools: + + + + + + + + + + + + + + + + request_body["tools"] = [ + + + + + + + + + + + + + + + + {"type": "function", "function": fd} + + + + + + + + + + + + + + + + for fd in function_definitions + + + + + + + + + + + + + + + + ] + + + + + + + + + + + + + + + + request_body["tool_choice"] = "auto" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + async with span("llm.completion", model=ai_model.id if ai_model else None): + + try: + + if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids): + + _sc = "" + _sr = "" + async for _kind, _text in await achat_complete( + api_url, api_key, request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + stream=True, + ): + if _kind == "content": + _sc += _text + await on_event({"type": "delta", "content": _text}) + elif _kind == "reasoning": + _sr += _text + await on_event({"type": "reasoning", "content": _text}) + message = {"content": _sc, "tool_calls": [], "reasoning": _sr} + choice = {"message": message} + data = {"choices": [choice], "usage": {}} + else: + data = await achat_complete( + api_url, + api_key, + request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + ) + + except Exception as _exc: + + if round_num == 0: + + ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" + + logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") + + break + + choice = data.get("choices", [{}])[0] + + message = choice.get("message", {}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录token使用量 + + + + + + + + + + + + + + + + usage = data.get("usage", {}) + + + + + + + + + + + + + + + + prompt_tokens += usage.get("prompt_tokens", 0) + + + + + + + + + + + + + + + + completion_tokens += usage.get("completion_tokens", 0) + + + + + + + + + + + + + + + + total_tokens += usage.get("total_tokens", 0) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 检查是否有tool_calls + + + + + + + + + + + + + + + + tool_calls = message.get("tool_calls", []) + + # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) + + _think = (message.get("content") or "").strip() + + if _think and tool_calls: + + process_steps.append({ + + "type": "thinking", + + "text": _think, + + }) + + + + + + + + + + + + + + + + + + if tool_calls and effective_use_tools: + + + + + + + + + + + + + + + + # 将assistant消息(含tool_calls)加入上下文 + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "assistant", + + + + + + + + + + + + + + + + "content": message.get("content") or "", + + + + + + + + + + + + + + + + "tool_calls": tool_calls, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage A.2: 真实Skill执行 + + + + + + + + + + + + + + + + for tc in tool_calls: + + + + + + + + + + + + + + + + func_name = tc.get("function", {}).get("name", "") + + + + # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== + + + + _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None + + + + _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) + + + + if not _policy_check["allowed"]: + + + + _reason = _policy_check["reason"] + + + + logger.warning( + + + + f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " + + + + f"tool={func_name} reason={_reason} trace={get_trace_id()}" + + + + ) + + + + tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" + + + + messages.append({ + + + + "role": "tool", + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + "content": tool_result, + + + + }) + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + continue + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + + + + + + + + + + + + + func_args_str = tc.get("function", {}).get("arguments", "{}") + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + func_args = json.loads(func_args_str) + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + func_args = {"input": func_args_str} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if func_name.startswith("mcp_tool_"): + + + + + + + + # MCP 工具调用:代理到对应 MCP 服务执行 tools/call + + + + + + + + _info = mcp_tool_map.get(func_name) + + + + + + + + tool_result = "[MCP 工具未找到或映射缺失]" + + + + + + + + if _info: + + + + + + + + _srv = next( + + + + + + + + (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), + + + + + + + + None, + + + + + + + + ) + + + + + + + + if _srv: + + + + + + + + async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): + + + + tool_result = await call_mcp_tool( + + + + + + + + _srv, _info["name"], func_args + + + + + + + + ) + + + + + + + + called_mcp_names.append(_srv.get("name", "")) + + process_steps.append({ + + "type": "mcp", + + "name": _info.get("name") if isinstance(_info, dict) else func_name, + + "server": _srv.get("name", ""), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + else: + + + + + + + + async with span(f"skill.execute:{func_name}"): + + + + tool_result = _execute_skill_tool( + + + + + + + + db, func_name, func_args, skills, req.content + + + + + + + + ) + + + + + + + + _sk = next((s for s in skills if s.get("func_name") == func_name), None) + + + + + + + + if _sk: + + + + + + + + called_skill_names.append(_sk.get("name", "")) + + process_steps.append({ + + "type": "skill", + + "name": (_sk.get("name") if _sk else func_name), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "tool", + + + + + + + + + + + + + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + + + + + + + + + + + + + "content": tool_result, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 继续下一轮让模型处理tool结果 + + + + + + + + + + + + + + + + continue + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + # 普通回复 + + + + + + + + + + + + + + + + ai_content = message.get("content", "") + + + + + + + + + + + + + + + + if not ai_content and round_num > 0: + + + + + + + + + + + + + + + + ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" + + + + + + + + + + + + + + + + break + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not ai_content: + + + + + + + + + + + + + + + + ai_content = "AI模型返回了空响应, 请稍后重试。" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"AI API调用异常: {e}") + + + + + + + + + + + + + + + + ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "请选择一个已配置的AI模型进行对话。" + + + + + + + + + + + + + + + + elif not external_handled: + + + + + + + + + + + + + + + + ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" + + + + + + + + + + # ===== Post-check: 失败则切换备选,成功则跳出 ===== + + _is_error = ( + + ai_content and ( + + "AI模型调用失败" in str(ai_content) + + or "AI服务暂时不可用" in str(ai_content) + + or "AI模型返回了空响应" in str(ai_content) + + or "该模型缺少API密钥" in str(ai_content) + + ) + + ) + + if _is_error: + + _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") + + logger.warning( + + f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." + + ) + + continue # try next model + + + + # Success — exit failover loop + + break + + else: + + # Loop completed without break = all models failed + + _all_failed = True + + + + # ===== All models failed? ===== + + if _all_failed: + + _names = ", ".join([m.name for m in _try_models]) + + _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" + + ai_content = ( + + f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" + + f"最后错误: {_details}\n" + + f"请稍后重试或检查模型配置。" + + ) + + logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_token_count = len(ai_content) // 2 + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=ai_content, + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) + + + + + + + + + + + + + + + + _ingest_text = (ai_content or "").strip() + + + + + + + + + + + + + + + + if len(_ingest_text) > 120 and not ( + + + + + + + + + + + + + + + + _ingest_text.startswith("[MEDIA") + + + + + + + + + + + + + + + + or _ingest_text.startswith("data:") + + + + + + + + + + + + + + + + or _ingest_text.startswith("http") + + + + + + + + + + + + + + + + or " 反馈配对的技能名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + used_skills = [s["name"] for s in _sel] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_skills = [] + + + + + + + + + + + + + + + + elif called_skill_names: + + + + + + + + + + + + + + + + # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) + + + + + + + + + + + + + + + + used_skills = list(dict.fromkeys(called_skill_names)) + + + + + + + + + + + + + + + + if effective_mcp_ids: + + + + + + + + + + + + + + + + # 用户显式选择了 MCP -> 反馈配对的 MCP 名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + used_mcp = [m["name"] for m in _sel_mcp] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_mcp = [] + + + + + + + + + + + + + + + + elif called_mcp_names: + + + + + + + + + + + + + + + + used_mcp = list(dict.fromkeys(called_mcp_names)) + + + + + + + + + + + + + + + + ai_msg.used_skills = used_skills + + + + + + + + + + + + + + + + ai_msg.used_mcp = used_mcp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 更新对话统计 + + + + + + + + + + + + + + + + # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, + + # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 + + + + + + + + + + + + + + + + conv.updated_at = cst_now() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) + + + + + + + + + + + + + + + + if not media_generating: + + + + + + + + + + + + + + + + token_usage = TokenUsageLog( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + model_name=ai_model.name if ai_model else None, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + trace_id=get_trace_id(), + + + + + + + + + + + + + + + + + + + + prompt_tokens=prompt_tokens or len(req.content) // 2, + + + + + + + + + + + + + + + + completion_tokens=completion_tokens or ai_token_count, + + + + + + + + + + + + + + + + total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(token_usage) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 进化轨迹记录 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + existing = db.query(EvolutionTrajectory).filter( + + + + + + + + + + + + + + + + EvolutionTrajectory.conversation_id == conv_id + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not existing: + + + + + + + + + + + + + + + + trajectory = EvolutionTrajectory( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + status="discovered", + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(trajectory) + + + + + + + + + + + + + + + + db.flush() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + from app.services.evolution_service import EvolutionWatcherManager + + + + + + + + + + + + + + + + watcher = EvolutionWatcherManager() + + + + + + + + + + + + + + + + status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + if not status.get("scheduler_running"): + + + + + + + + + + + + + + + + watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") + + + + + + + + + + + + + + + + except Exception as evolution_err: + + + + + + + + + + + + + + + + logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + # 后台异步压缩历史(分层记忆),不阻塞本次响应 + + + + asyncio.create_task(_maybe_compress_history(conv_id)) + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 + + + + + + + + + + + + + + + + if media_generating: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + asyncio.create_task( + + + + + + + + + + + + + + + + _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) + + # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 + + try: + + ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" + + db.commit() + + except Exception: + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, + + + + + + + + + + + + + + + + "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), + + + + + + + + + + + + + + + + "used_skills": used_skills, + + + + "used_mcp": used_mcp, + + + + + + + + + + + + + + + + "generating": media_generating, + + "process_steps": process_steps, + + + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "发送成功", + + + + + + + + + + + + + + + + } + + + + + + + + + + + +# ====================================================================== + + + +# 3.1 tool_policy 沙箱辅助函数 + + + +# ====================================================================== + + + +def _resolve_agent_tool_policy(agents): + + + + """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" + + + + if not agents: + + + + return None + + + + for ag in agents: + + + + tp = ag.get("tool_policy") + + + + if tp and isinstance(tp, dict): + + + + tp = dict(tp) + + + + tp["agent_id"] = ag.get("id") + + + + return tp + + + + return None + + + + + + + + + + + +def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): + + + + """校验单个工具调用是否被策略允许。 + + + + + + + + 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} + + + + policy 为 None/空 -> 允许(向后兼容)。 + + + + """ + + + + if not policy: + + + + return {"allowed": True, "tool_id": None} + + + + allowed = policy.get("allowed_tools") or {} + + + + skill_ids = allowed.get("skill_ids") or [] + + + + mcp_ids = allowed.get("mcp_ids") or [] + + + + is_mcp = func_name.startswith("mcp_tool_") + + + + tool_id = None + + + + if is_mcp: + + + + if mcp_info and isinstance(mcp_info, dict): + + + + tool_id = mcp_info.get("server_id") + + + + if mcp_ids and tool_id not in mcp_ids: + + + + return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, + + + + "message": "MCP 服务不在该智能体的允许列表中"} + + + + else: + + + + if skills: + + + + _s = next((s for s in skills if s.get("func_name") == func_name), None) + + + + if _s: + + + + tool_id = _s.get("id") + + + + if skill_ids and tool_id not in skill_ids: + + + + return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, + + + + "message": "技能不在该智能体的允许列表中"} + + + + budget = policy.get("max_calls_per_conversation") + + + + if budget is not None and call_count >= int(budget): + + + + return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, + + + + "message": policy.get("fallback_message") + + + + or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} + + + + per_round = policy.get("per_round_cap") + + + + if per_round is not None and round_count >= int(per_round): + + + + return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, + + + + "message": "本轮工具调用已达上限"} + + + + return {"allowed": True, "tool_id": tool_id} + + + + diff --git a/backend/python/app/core/llm_client.py b/backend/python/app/core/llm_client.py index e26b81a..c871466 100644 --- a/backend/python/app/core/llm_client.py +++ b/backend/python/app/core/llm_client.py @@ -1,181 +1,182 @@ -"""Shared, standards-based LLM client for web chat + IM bot. - -平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK -(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲 -OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得: - * 原生 SSE 流式(`stream=True`); - * 结构化的 tool_calls / usage 解析; - * 统一的代理(trust_env=False + 显式 proxy)与超时处理。 - -本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与 -IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」 -红线在模型层的落地。 - -API: - achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False) - - stream=False -> dict,形状对齐上游 `data` 响应: - {"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}} - - stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量 -""" -from __future__ import annotations - -import httpx -from openai import AsyncOpenAI, OpenAI - - -def _base_url(api_url: str) -> str: - """从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。""" - u = (api_url or "").rstrip() - for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"): - if u.endswith(suf): - return u[: -len(suf)] - return u - - -def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI: - base = _base_url(api_url) - # 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502; - # 代理经 proxy 显式传入(AGNES 等),未传则直连。 - http_client = httpx.AsyncClient(proxy=proxy, trust_env=False) - return AsyncOpenAI( - base_url=base, - api_key=api_key or "EMPTY", - timeout=timeout, - http_client=http_client, - ) - - -def _create_kwargs(request_body: dict) -> dict: - kw = { - "model": request_body.get("model"), - "messages": request_body.get("messages", []), - "stream": False, - } - if request_body.get("temperature") is not None: - kw["temperature"] = request_body["temperature"] - if request_body.get("top_p") is not None: - kw["top_p"] = request_body["top_p"] - if request_body.get("max_tokens") is not None: - kw["max_tokens"] = request_body["max_tokens"] - if request_body.get("stop") is not None: - kw["stop"] = request_body["stop"] - tools = request_body.get("tools") - if tools: - kw["tools"] = tools - kw["tool_choice"] = request_body.get("tool_choice", "auto") - return kw - - -def _normalize_tool_calls(tcs): - if not tcs: - return None - out = [] - for tc in tcs: - out.append( - { - "id": getattr(tc, "id", None), - "type": "function", - "function": { - "name": getattr(tc.function, "name", None), - "arguments": getattr(tc.function, "arguments", "") or "", - }, - } - ) - return out - - -async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False): - """统一的 OpenAI 兼容聊天补全。详见模块 docstring。""" - if stream: - - async def _gen(): - client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) - try: - gen = await client.chat.completions.create( - **_create_kwargs(request_body), stream=True - ) - async for chunk in gen: - if not chunk.choices: - continue - delta = chunk.choices[0].delta - rc = getattr(delta, "reasoning_content", None) - if rc: - yield ("reasoning", rc) - ct = getattr(delta, "content", None) - if ct: - yield ("content", ct) - finally: - await client.close() - - return _gen() - - client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) - async with client: - resp = await client.chat.completions.create(**_create_kwargs(request_body)) - msg = resp.choices[0].message - return { - "choices": [ - { - "message": { - "content": msg.content or "", - "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), - "reasoning_content": getattr(msg, "reasoning_content", None), - } - } - ], - "usage": (resp.usage.model_dump() if resp.usage else {}), - } - - -def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI: - """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。""" - base = _base_url(api_url) - http_client = httpx.Client(proxy=proxy, trust_env=False) - return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client) - - -def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120): - """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。""" - client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) - with client: - resp = client.chat.completions.create(**_create_kwargs(request_body)) - msg = resp.choices[0].message - return { - "choices": [ - { - "message": { - "content": msg.content or "", - "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), - "reasoning_content": getattr(msg, "reasoning_content", None), - } - } - ], - "usage": (resp.usage.model_dump() if resp.usage else {}), - } - - -# --------------------------------------------------------------------------- -# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用 -# --------------------------------------------------------------------------- -def _embed_kwargs(input, model): - return {"model": model, "input": input if isinstance(input, list) else [input]} - - -async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: - """统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。 - - 供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx - 嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。 - """ - client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) - async with client: - resp = await client.embeddings.create(**_embed_kwargs(input, model)) - return [d.embedding for d in resp.data] - - -def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: - """同步版嵌入(与非 async 上下文复用同一标准模型层)。""" - client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) - with client: - resp = client.embeddings.create(**_embed_kwargs(input, model)) - return [d.embedding for d in resp.data] +"""Shared, standards-based LLM client for web chat + IM bot. + +平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK +(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲 +OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得: + * 原生 SSE 流式(`stream=True`); + * 结构化的 tool_calls / usage 解析; + * 统一的代理(trust_env=False + 显式 proxy)与超时处理。 + +本模块取代 chat_service.py 与 im_llm.py 中两套手写 httpx 循环,使 web chat 与 +IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——这是「各端能力统一对齐」 +红线在模型层的落地。 + +API: + achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False) + - stream=False -> dict,形状对齐上游 `data` 响应: + {"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}} + - stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量 +""" +from __future__ import annotations + +import httpx +from openai import AsyncOpenAI, OpenAI + + +def _base_url(api_url: str) -> str: + """从完整 /v1/chat/completions 或 /v1/embeddings 地址推导 OpenAI SDK 所需的 base_url。""" + u = (api_url or "").rstrip() + for suf in ("/v1/chat/completions", "/chat/completions", "/v1/embeddings", "/embeddings"): + if u.endswith(suf): + return u[: -len(suf)] + return u + + +def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI: + base = _base_url(api_url) + # 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502; + # 代理经 proxy 显式传入(AGNES 等),未传则直连。 + http_client = httpx.AsyncClient(proxy=proxy, trust_env=False) + return AsyncOpenAI( + base_url=base, + api_key=api_key or "EMPTY", + timeout=timeout, + http_client=http_client, + ) + + +def _create_kwargs(request_body: dict, stream: bool = False) -> dict: + kw = { + "model": request_body.get("model"), + "messages": request_body.get("messages", []), + "stream": stream, + } + if request_body.get("temperature") is not None: + kw["temperature"] = request_body["temperature"] + if request_body.get("top_p") is not None: + kw["top_p"] = request_body["top_p"] + if request_body.get("max_tokens") is not None: + kw["max_tokens"] = request_body["max_tokens"] + if request_body.get("stop") is not None: + kw["stop"] = request_body["stop"] + tools = request_body.get("tools") + if tools: + kw["tools"] = tools + kw["tool_choice"] = request_body.get("tool_choice", "auto") + return kw + + +def _normalize_tool_calls(tcs): + if not tcs: + return None + out = [] + for tc in tcs: + out.append( + { + "id": getattr(tc, "id", None), + "type": "function", + "function": { + "name": getattr(tc.function, "name", None), + "arguments": getattr(tc.function, "arguments", "") or "", + }, + } + ) + return out + + +async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False): + """统一的 OpenAI 兼容聊天补全。详见模块 docstring。""" + if stream: + + async def _gen(): + client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) + try: + gen = await client.chat.completions.create( + **_create_kwargs(request_body, stream=True) + ) + async for chunk in gen: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + rc = getattr(delta, "reasoning_content", None) + if rc: + yield ("reasoning", rc) + ct = getattr(delta, "content", None) + if ct: + yield ("content", ct) + finally: + await client.close() + + return _gen() + + client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) + async with client: + resp = await client.chat.completions.create(**_create_kwargs(request_body)) + msg = resp.choices[0].message + return { + "choices": [ + { + "message": { + "content": msg.content or "", + "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), + "reasoning_content": getattr(msg, "reasoning_content", None), + } + } + ], + "usage": (resp.usage.model_dump() if resp.usage else {}), + } + + +def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI: + """同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。""" + base = _base_url(api_url) + http_client = httpx.Client(proxy=proxy, trust_env=False) + return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client) + + +def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120): + """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。""" + client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) + with client: + resp = client.chat.completions.create(**_create_kwargs(request_body)) + msg = resp.choices[0].message + return { + "choices": [ + { + "message": { + "content": msg.content or "", + "tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), + "reasoning_content": getattr(msg, "reasoning_content", None), + } + } + ], + "usage": (resp.usage.model_dump() if resp.usage else {}), + } + + +# --------------------------------------------------------------------------- +# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用 +# --------------------------------------------------------------------------- +def _embed_kwargs(input, model): + return {"model": model, "input": input if isinstance(input, list) else [input]} + + +async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: + """统一的 OpenAI 兼容文本嵌入(异步)。返回 list[list[float]]。 + + 供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx + 嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。 + """ + client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout) + async with client: + resp = await client.embeddings.create(**_embed_kwargs(input, model)) + return [d.embedding for d in resp.data] + + +def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list: + """同步版嵌入(与非 async 上下文复用同一标准模型层)。""" + client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout) + with client: + resp = client.embeddings.create(**_embed_kwargs(input, model)) + return [d.embedding for d in resp.data] + diff --git a/frontend/src/pages/front/ChatPage.css b/frontend/src/pages/front/ChatPage.css index f8bc2fd..a5ed728 100644 --- a/frontend/src/pages/front/ChatPage.css +++ b/frontend/src/pages/front/ChatPage.css @@ -1759,3 +1759,23 @@ html.dark .ai-sending-timeout { .ai-cancel-btn:hover { background: color-mix(in srgb, #e74c3c 15%, var(--color-bg-tertiary)) !important; } + +/* ===== Phase 0 SSE 流式:思考链实时渲染 ===== */ +/* 思考链文本保留模型原始换行(deepseek 原生 reasoning 含多段换行) */ +.ai-process-thinking .ai-process-text { + white-space: pre-wrap; +} + +/* 流式生成中提示 */ +.streaming-hint { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: 14px; +} + +.streaming-hint svg { + width: 16px; + height: 16px; +} diff --git a/frontend/src/pages/front/ChatPage.tsx b/frontend/src/pages/front/ChatPage.tsx index 2da97c3..2cfa216 100644 --- a/frontend/src/pages/front/ChatPage.tsx +++ b/frontend/src/pages/front/ChatPage.tsx @@ -559,6 +559,8 @@ const ChatPage: React.FC = () => { const [sending, setSending] = useState(false); const [sendingTimeout, setSendingTimeout] = useState(false); const sendingTimerRef = useRef | null>(null); + // 正在 SSE 流式渲染的助手消息 id;非空时隐藏"思考中"占位气泡 + const [streamingId, setStreamingId] = useState(null); const [conversations, setConversations] = useState([]); const [messages, setMessages] = useState([]); const [loadingConversations, setLoadingConversations] = useState(true); @@ -933,7 +935,162 @@ const ChatPage: React.FC = () => { } }; - // Handle send message - CALLS REAL API + // 非流式兜底发送(与历史行为一致)——供 SSE 连接/流式失败时回退 + const sendLegacy = async ( + payload: any, + tempUserMsg: Message, + ) => { + const res = await chatAPI.sendMessage(selectedConversation!.id, payload); + const data = unwrapData(res); + + // Replace temporary user message and add assistant response + setMessages(prev => { + const filtered = prev.filter(m => m.id !== tempUserMsg.id); + const newMsgs: Message[] = []; + if (data.user_message) { + newMsgs.push({ + id: data.user_message.id, + conversationId: selectedConversation?.id || '', + role: 'user', + content: data.user_message.content, + tokenUsed: data.user_message.token_used || 0, + createdAt: data.user_message.created_at || new Date().toISOString(), + }); + } + if (data.assistant_message) { + newMsgs.push({ + id: data.assistant_message.id, + conversationId: selectedConversation?.id || '', + role: 'assistant', + content: data.assistant_message.content, + tokenUsed: data.assistant_message.token_used || 0, + createdAt: data.assistant_message.created_at || new Date().toISOString(), + used_skills: data.assistant_message.used_skills || [], + used_mcp: data.assistant_message.used_mcp || [], + process_steps: data.assistant_message.process_steps || [], + }); + } + return [...filtered, ...newMsgs]; + }); + + // 标记本轮新产生的助手消息,触发"思考过程"首屏展开 + if (data.assistant_message) { + setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id)); + } + + // 媒体异步生成:启动轮询,待后端回写后自动刷新 + if (data.assistant_message?.generating) { + startMediaPolling(selectedConversation!.id, data.assistant_message.id); + } + + // Refresh conversations to update lastMessage + loadConversations(); + // 发送后清空本次附件(选择状态仍保留) + setAttachments([]); + + // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI) + if (selectedConversation?.id && data.assistant_message) { + const recentMsgs = [ + ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []), + { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 }, + ]; + evolutionAPI.submitTrajectory({ + conversation_id: selectedConversation.id, + messages: recentMsgs, + }).catch(() => {}); + } + }; + + // SSE 流式发送(Phase 0)——实时增量渲染 AI 回复文本与思考链(reasoning) + const streamSend = async ( + convId: string, + payload: any, + tempUserMsg: Message, + assistantId: string, + ): Promise => { + const token = localStorage.getItem('aiflow_token'); + const resp = await fetch(`/app/api/chat/conversations/${convId}/messages/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(payload), + }); + if (!resp.ok || !resp.body) { + throw new Error(`SSE 连接失败 (${resp.status})`); + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let accReasoning = ''; + let gotDone = false; + + const patchAssistant = (patch: Partial) => { + setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, ...patch } : m))); + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) >= 0) { + const raw = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + const dataLine = raw.split('\n').find(l => l.startsWith('data: ')); + if (!dataLine) continue; + let evt: any; + try { evt = JSON.parse(dataLine.slice(6)); } catch { continue; } + if (evt.type === 'delta') { + const piece = evt.content || ''; + setMessages(prev => prev.map(m => (m.id === assistantId ? { ...m, content: (m.content || '') + piece } : m))); + } else if (evt.type === 'reasoning') { + accReasoning += evt.content || ''; + patchAssistant({ reasoning: accReasoning } as any); + } else if (evt.type === 'done') { + gotDone = true; + const finalId = evt.message_id || assistantId; + setMessages(prev => prev.map(m => { + if (m.id !== assistantId) return m; + return { + ...m, + id: finalId, + content: evt.content ?? m.content, + process_steps: evt.process_steps || [], + used_skills: evt.used_skills || [], + used_mcp: evt.used_mcp || [], + generating: !!evt.generating, + }; + })); + setStreamingId(null); + if (evt.generating) startMediaPolling(convId, finalId); + setFreshMsgIds(prev => new Set(prev).add(finalId)); + loadConversations(); + setAttachments([]); + if (convId && evt.content) { + evolutionAPI.submitTrajectory({ + conversation_id: convId, + messages: [ + { role: 'user', content: tempUserMsg.content, tokens: 0 }, + { role: 'assistant', content: evt.content, tokens: 0 }, + ], + }).catch(() => {}); + } + } else if (evt.type === 'error') { + throw new Error(evt.message || '生成失败'); + } + } + } + if (!gotDone) { + // 流结束但未收到 done:已累积的 delta 仍可展示,但视为异常兜底 + setStreamingId(null); + throw new Error('流式响应未正常结束'); + } + }; + + // Handle send message - CALLS REAL API(Phase 0:默认 SSE 流式,失败回退非流式) // 抽出公共发送逻辑,供输入框发送与「模糊创作意图」方向选择复用 const sendText = async (content: string) => { if (!content.trim() || sending || !selectedConversation) return; @@ -956,83 +1113,44 @@ const ChatPage: React.FC = () => { }; setMessages(prev => [...prev, tempUserMsg]); + const payload = { + content, + model_id: selectedModelId || undefined, + agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined, + skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined, + knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined, + mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined, + attachments: attachments.length > 0 ? attachments.map(a => ({ + name: a.name, file_type: a.file_type, content: a.content || '', + })) : undefined, + use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0, + }; + + // 先放一个空的助手消息占位,用于 SSE 流式增量渲染 + const assistantId = `temp_assistant_${Date.now()}`; + setMessages(prev => [...prev, { + id: assistantId, + conversationId: selectedConversation!.id, + role: 'assistant', + content: '', + tokenUsed: 0, + createdAt: new Date().toISOString(), + reasoning: '', + } as Message]); + setStreamingId(assistantId); + try { - const res = await chatAPI.sendMessage(selectedConversation!.id, { - content, - model_id: selectedModelId || undefined, - agent_ids: selectedAgentIds.length > 0 ? selectedAgentIds : undefined, - skill_ids: selectedSkillIds.length > 0 ? selectedSkillIds : undefined, - knowledge_base_ids: selectedKbIds.length > 0 ? selectedKbIds : undefined, - mcp_ids: selectedMcpIds.length > 0 ? selectedMcpIds : undefined, - attachments: attachments.length > 0 ? attachments.map(a => ({ - name: a.name, file_type: a.file_type, content: a.content || '', - })) : undefined, - use_tools: selectedSkillIds.length > 0 || selectedAgentIds.length > 0 || selectedMcpIds.length > 0, - }); - - const data = unwrapData(res); - - // Replace temporary user message and add assistant response - setMessages(prev => { - const filtered = prev.filter(m => m.id !== tempUserMsg.id); - const newMsgs: Message[] = []; - if (data.user_message) { - newMsgs.push({ - id: data.user_message.id, - conversationId: selectedConversation?.id || '', - role: 'user', - content: data.user_message.content, - tokenUsed: data.user_message.token_used || 0, - createdAt: data.user_message.created_at || new Date().toISOString(), - }); - } - if (data.assistant_message) { - newMsgs.push({ - id: data.assistant_message.id, - conversationId: selectedConversation?.id || '', - role: 'assistant', - content: data.assistant_message.content, - tokenUsed: data.assistant_message.token_used || 0, - createdAt: data.assistant_message.created_at || new Date().toISOString(), - used_skills: data.assistant_message.used_skills || [], - used_mcp: data.assistant_message.used_mcp || [], - process_steps: data.assistant_message.process_steps || [], - }); - } - return [...filtered, ...newMsgs]; - }); - - // 标记本轮新产生的助手消息,触发"思考过程"首屏展开 - if (data.assistant_message) { - setFreshMsgIds(prev => new Set(prev).add(data.assistant_message.id)); - } - - // 媒体异步生成:启动轮询,待后端回写后自动刷新 - if (data.assistant_message?.generating) { - startMediaPolling(selectedConversation!.id, data.assistant_message.id); - } - - // Refresh conversations to update lastMessage - loadConversations(); - // 发送后清空本次附件(选择状态仍保留) - setAttachments([]); - - // GAP-07: 对话结束后异步提交轨迹到进化引擎(后台静默,不阻塞UI) - if (selectedConversation?.id && data.assistant_message) { - const recentMsgs = [ - ...(data.user_message ? [{ role: 'user', content: data.user_message.content, tokens: data.user_message.token_used || 0 }] : []), - { role: 'assistant', content: data.assistant_message.content, tokens: data.assistant_message.token_used || 0 }, - ]; - evolutionAPI.submitTrajectory({ - conversation_id: selectedConversation.id, - messages: recentMsgs, - }).catch(() => {}); - } + await streamSend(selectedConversation!.id, payload, tempUserMsg, assistantId); } catch (err: any) { - if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; } - // Remove optimistic message on error - setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); - alert('发送失败: ' + (err.message || err.detail || '未知错误')); + // 移除流式占位,回退非流式重新发送 + setMessages(prev => prev.filter(m => m.id !== assistantId && m.id !== tempUserMsg.id)); + setStreamingId(null); + try { + await sendLegacy(payload, tempUserMsg); + } catch (e2: any) { + setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id)); + alert('发送失败: ' + (e2?.message || e2?.detail || '未知错误')); + } } finally { if (sendingTimerRef.current) { clearTimeout(sendingTimerRef.current); sendingTimerRef.current = null; } setSending(false); @@ -1286,6 +1404,22 @@ const ChatPage: React.FC = () => { {msg.role === 'assistant' && steps.length > 0 && ( )} + {msg.role === 'assistant' && (msg as any).reasoning && ( +
+ + 🤔 + AI 思考过程(实时) + +
+
+ 💡 +
+

{(msg as any).reasoning}

+
+
+
+
+ )}
{clarify ? ( { }}>🔄 重试
) : msg.role === 'assistant' ? ( - + msg.content ? : (streamingId === msg.id ? 生成中… : null) ) : ( {msg.content} )} @@ -1370,7 +1504,7 @@ const ChatPage: React.FC = () => { ); }) )} - {sending && ( + {sending && !streamingId && (
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index aac145e..e6ee185 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -60,6 +60,7 @@ export interface Message { content: string; tokenUsed: number; createdAt: string; + reasoning?: string; used_skills?: string[]; used_mcp?: string[]; process_steps?: Array<{