Phase 0 SSE 流式聊天 landed in the running container and captured in Git so
the three sources (container := image := Git) agree.
Phase 0 — SSE 流式聊天(Web 端,IM bot 路径保持不变、共用引擎):
- app/api/chat.py: 新增独立 SSE 端点
POST /app/api/chat/conversations/{conv_id}/messages/stream
基于 asyncio.Queue + create_task 将 _send_message_core 桥接为
text/event-stream 生成器,吐出 delta / reasoning / done / error 事件
- app/api/chat_service.py: _send_message_core 增加 stream + on_event 参数;
普通(无工具)聊天 round-0 主调用走 achat_complete(stream=True) 流式;
done 事件携带 message_id / content / process_steps / used_skills /
used_mcp / generating
- app/core/llm_client.py: 修复 _create_kwargs 硬编码 stream=False 的根因 bug
(流式调用传 stream=True 导致 TypeError: multiple values for keyword),
现 _create_kwargs(request_body, stream=True) 字段随参数变化
- frontend/src/pages/front/ChatPage.tsx + ChatPage.css + types/index.ts:
发消息改为 fetch SSE 消费,实时增量渲染 AI 回复文本与思考链(reasoning),
done 事件用 message_id 定稿并渲染过程步骤;网络/流式失败自动回退非流式
Alignment performed 2026-07-24 16:28:19. No deletions; secrets/venv/node_modules/
caches were not touched (tracked-source-only copy).
2819 lines
18 KiB
Python
2819 lines
18 KiB
Python
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"}
|