feat! (memory)!: 重新实现了对话管理的存储与管理,不再依赖于 MemorySaver
BREAKING CHANGE: 使用了新的存储结构,因此之前存储的历史记录将不再支持迁移
This commit is contained in:
parent
af4bb9f48e
commit
c46dd7b073
4
.gitignore
vendored
4
.gitignore
vendored
@ -31,6 +31,10 @@ cache
|
||||
.idea
|
||||
.vibe
|
||||
.qoder
|
||||
.claude
|
||||
.cursor
|
||||
.trae
|
||||
.pytest_cache
|
||||
*.nogit*
|
||||
*.private*
|
||||
*.local*
|
||||
|
||||
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from server.routers.auth_router import auth
|
||||
from server.routers.chat_router import chat
|
||||
from server.routers.dashboard_router import dashboard
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_router import knowledge
|
||||
from server.routers.system_router import system
|
||||
@ -12,5 +13,6 @@ router = APIRouter()
|
||||
router.include_router(system) # /api/system/*
|
||||
router.include_router(auth) # /api/auth/*
|
||||
router.include_router(chat) # /api/chat/*
|
||||
router.include_router(dashboard) # /api/dashboard/*
|
||||
router.include_router(knowledge) # /api/knowledge/*
|
||||
router.include_router(graph) # /api/graph/*
|
||||
|
||||
@ -7,11 +7,12 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langchain_core.messages import AIMessageChunk, HumanMessage
|
||||
from langchain_core.messages import AIMessageChunk, HumanMessage, ToolMessage
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.storage.db.models import Thread, User
|
||||
from src.storage.db.models import User
|
||||
from src.storage.conversation import ConversationManager
|
||||
from server.routers.auth_router import get_admin_user
|
||||
from server.utils.auth_middleware import get_db, get_required_user
|
||||
from src import executor
|
||||
@ -114,6 +115,7 @@ async def chat_agent(
|
||||
config: dict = Body({}),
|
||||
meta: dict = Body({}),
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""使用特定智能体进行对话(需要登录)"""
|
||||
|
||||
@ -138,6 +140,100 @@ async def chat_agent(
|
||||
+ b"\n"
|
||||
)
|
||||
|
||||
async def save_messages_from_langgraph_state(
|
||||
agent_instance,
|
||||
conversation,
|
||||
conv_mgr,
|
||||
config_dict,
|
||||
):
|
||||
"""
|
||||
从 LangGraph state 中读取完整消息并保存到数据库
|
||||
这样可以获得完整的 tool_calls 参数
|
||||
"""
|
||||
try:
|
||||
graph = await agent_instance.get_graph()
|
||||
state = await graph.aget_state(config_dict)
|
||||
|
||||
if not state or not state.values:
|
||||
logger.warning("No state found in LangGraph")
|
||||
return
|
||||
|
||||
messages = state.values.get("messages", [])
|
||||
logger.debug(f"Retrieved {len(messages)} messages from LangGraph state")
|
||||
|
||||
# 获取已保存的消息数量,避免重复保存
|
||||
existing_messages = conv_mgr.get_messages(conversation.id)
|
||||
existing_count = len(existing_messages)
|
||||
|
||||
# 只保存新增的消息
|
||||
new_messages = messages[existing_count:]
|
||||
|
||||
for msg in new_messages:
|
||||
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
|
||||
msg_type = msg_dict.get("type", "unknown")
|
||||
|
||||
if msg_type == "human":
|
||||
# 用户消息(理论上已经保存过了,跳过)
|
||||
continue
|
||||
|
||||
elif msg_type == "ai":
|
||||
# AI 消息
|
||||
content = msg_dict.get("content", "")
|
||||
tool_calls_data = msg_dict.get("tool_calls", [])
|
||||
|
||||
# 保存 AI 消息
|
||||
ai_msg = conv_mgr.add_message(
|
||||
conversation_id=conversation.id,
|
||||
role="assistant",
|
||||
content=content,
|
||||
message_type="text",
|
||||
extra_metadata=msg_dict, # 保存原始 model_dump
|
||||
)
|
||||
|
||||
# 保存 tool_calls(如果有)
|
||||
if tool_calls_data:
|
||||
logger.debug(f"Saving {len(tool_calls_data)} tool calls from AI message")
|
||||
for tc in tool_calls_data:
|
||||
conv_mgr.add_tool_call(
|
||||
message_id=ai_msg.id,
|
||||
tool_name=tc.get("name", "unknown"),
|
||||
tool_input=tc.get("args", {}), # 完整的参数
|
||||
status="pending", # 工具还未执行
|
||||
)
|
||||
|
||||
logger.debug(f"Saved AI message {ai_msg.id} with {len(tool_calls_data)} tool calls")
|
||||
|
||||
elif msg_type == "tool":
|
||||
# 工具执行结果消息
|
||||
# 需要找到对应的 tool_call 记录并更新
|
||||
tool_call_id = msg_dict.get("tool_call_id")
|
||||
content = msg_dict.get("content", "")
|
||||
|
||||
if tool_call_id:
|
||||
# 通过 tool_call_id 找到对应的 tool_call 记录
|
||||
# 注意:LangGraph 的 tool_call_id 和我们数据库的 id 不同
|
||||
# 我们需要通过最近的 AI 消息找到对应的 tool_call
|
||||
recent_ai_msgs = conv_mgr.get_messages(conversation.id, limit=10)
|
||||
for recent_msg in reversed(recent_ai_msgs):
|
||||
if recent_msg.role == "assistant" and recent_msg.tool_calls:
|
||||
for tc in recent_msg.tool_calls:
|
||||
# 这里简化处理:按顺序更新 pending 状态的 tool_call
|
||||
if tc.status == "pending" and not tc.tool_output:
|
||||
tc.tool_output = content
|
||||
tc.status = "success"
|
||||
db.commit()
|
||||
logger.debug(f"Updated tool_call {tc.id} with output")
|
||||
break
|
||||
break
|
||||
|
||||
logger.debug(f"Processed message type={msg_type}")
|
||||
|
||||
logger.info(f"Saved {len(new_messages)} new messages from LangGraph state")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving messages from LangGraph state: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def stream_messages():
|
||||
# 代表服务端已经收到了请求
|
||||
yield make_chunk(status="init", meta=meta, msg=HumanMessage(content=query).model_dump())
|
||||
@ -162,30 +258,68 @@ async def chat_agent(
|
||||
|
||||
input_context = {"user_id": user_id, "thread_id": thread_id}
|
||||
|
||||
# Initialize conversation manager
|
||||
conv_manager = ConversationManager(db)
|
||||
|
||||
# Get or create conversation
|
||||
conversation = None
|
||||
if thread_id:
|
||||
conversation = conv_manager.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
try:
|
||||
# Auto-create conversation for existing thread
|
||||
conversation = conv_manager.create_conversation(
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
title=(query[:50] + "..." if len(query) > 50 else query) if query else "新的对话",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
logger.info(f"Auto-created conversation for thread_id {thread_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-create conversation: {e}")
|
||||
conversation = None
|
||||
|
||||
# Save user message
|
||||
if conversation:
|
||||
try:
|
||||
conv_manager.add_message(
|
||||
conversation_id=conversation.id,
|
||||
role="user",
|
||||
content=query,
|
||||
message_type="text",
|
||||
extra_metadata={"raw_message": HumanMessage(content=query).model_dump()},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving user message: {e}")
|
||||
|
||||
try:
|
||||
# Output guard for streaming
|
||||
accumulated_content = ""
|
||||
# Stream messages (only for display, don't save yet)
|
||||
async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
|
||||
# logger.debug(f"msg: {msg.model_dump()}, metadata: {metadata}")
|
||||
if isinstance(msg, AIMessageChunk):
|
||||
accumulated_content += msg.content
|
||||
if conf.enable_content_guard and content_guard.check(accumulated_content):
|
||||
logger.warning(f"Sensitive content detected in stream: {accumulated_content}")
|
||||
# Content guard
|
||||
if conf.enable_content_guard and content_guard.check(msg.content):
|
||||
logger.warning("Sensitive content detected in stream")
|
||||
yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
|
||||
return
|
||||
|
||||
yield make_chunk(content=msg.content, msg=msg.model_dump(), metadata=metadata, status="loading")
|
||||
|
||||
elif isinstance(msg, ToolMessage):
|
||||
yield make_chunk(msg=msg.model_dump(), metadata=metadata, status="loading")
|
||||
else:
|
||||
yield make_chunk(msg=msg.model_dump(), metadata=metadata, status="loading")
|
||||
|
||||
# Additional content guard with llm
|
||||
if conf.enable_content_guard and content_guard.check_with_llm(accumulated_content):
|
||||
logger.warning(f"Content guard triggered: {accumulated_content=}")
|
||||
yield make_chunk(message="检测到敏感内容,已中断输出", status="error")
|
||||
return
|
||||
|
||||
yield make_chunk(status="finished", meta=meta)
|
||||
|
||||
# After streaming finished, save all messages from LangGraph state
|
||||
if conversation:
|
||||
langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
|
||||
await save_messages_from_langgraph_state(
|
||||
agent_instance=agent,
|
||||
conversation=conversation,
|
||||
conv_mgr=conv_manager,
|
||||
config_dict=langgraph_config,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error streaming messages: {e}, {traceback.format_exc()}")
|
||||
yield make_chunk(message=f"Error streaming messages: {e}", status="error")
|
||||
@ -251,15 +385,53 @@ async def save_agent_config(agent_id: str, config: dict = Body(...), current_use
|
||||
|
||||
|
||||
@chat.get("/agent/{agent_id}/history")
|
||||
async def get_agent_history(agent_id: str, thread_id: str, current_user: User = Depends(get_required_user)):
|
||||
"""获取智能体历史消息(需要登录)"""
|
||||
async def get_agent_history(
|
||||
agent_id: str, thread_id: str, current_user: User = Depends(get_required_user), db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取智能体历史消息(需要登录)- NEW STORAGE ONLY"""
|
||||
try:
|
||||
# 获取Agent实例和配置类
|
||||
if not (agent := agent_manager.get_agent(agent_id)):
|
||||
# 获取Agent实例验证
|
||||
if not agent_manager.get_agent(agent_id):
|
||||
raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在")
|
||||
|
||||
# 获取历史消息
|
||||
history = await agent.get_history(user_id=str(current_user.id), thread_id=thread_id)
|
||||
# Use new storage system ONLY
|
||||
conv_manager = ConversationManager(db)
|
||||
messages = conv_manager.get_messages_by_thread_id(thread_id)
|
||||
|
||||
# Convert to frontend-compatible format
|
||||
history = []
|
||||
for msg in messages:
|
||||
# Map role to type that frontend expects
|
||||
role_type_map = {
|
||||
"user": "human",
|
||||
"assistant": "ai",
|
||||
"tool": "tool",
|
||||
"system": "system"
|
||||
}
|
||||
|
||||
msg_dict = {
|
||||
"type": role_type_map.get(msg.role, msg.role), # human/ai/tool/system
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
}
|
||||
|
||||
# Add tool calls if present (for AI messages)
|
||||
if msg.tool_calls and len(msg.tool_calls) > 0:
|
||||
msg_dict["tool_calls"] = [
|
||||
{
|
||||
"id": str(tc.id),
|
||||
"name": tc.tool_name,
|
||||
"function": {"name": tc.tool_name}, # Frontend compatibility
|
||||
"args": tc.tool_input or {},
|
||||
"tool_call_result": {"content": tc.tool_output} if tc.tool_output else None,
|
||||
"status": tc.status,
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
|
||||
history.append(msg_dict)
|
||||
|
||||
logger.info(f"Loaded {len(history)} messages from new storage for thread {thread_id}")
|
||||
return {"history": history}
|
||||
|
||||
except Exception as e:
|
||||
@ -290,7 +462,6 @@ async def get_agent_config(agent_id: str, current_user: User = Depends(get_requi
|
||||
class ThreadCreate(BaseModel):
|
||||
title: str | None = None
|
||||
agent_id: str
|
||||
description: str | None = None
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
@ -299,7 +470,6 @@ class ThreadResponse(BaseModel):
|
||||
user_id: str
|
||||
agent_id: str
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
create_at: str
|
||||
update_at: str
|
||||
|
||||
@ -313,79 +483,81 @@ class ThreadResponse(BaseModel):
|
||||
async def create_thread(
|
||||
thread: ThreadCreate, db: Session = Depends(get_db), current_user: User = Depends(get_required_user)
|
||||
):
|
||||
"""创建新对话线程"""
|
||||
"""创建新对话线程 (使用新存储系统)"""
|
||||
thread_id = str(uuid.uuid4())
|
||||
logger.debug(f"thread.agent_id: {thread.agent_id}")
|
||||
|
||||
new_thread = Thread(
|
||||
id=thread_id,
|
||||
# Create conversation using new storage system
|
||||
conv_manager = ConversationManager(db)
|
||||
conversation = conv_manager.create_conversation(
|
||||
user_id=str(current_user.id),
|
||||
agent_id=thread.agent_id,
|
||||
title=thread.title or "新的对话",
|
||||
description=thread.description,
|
||||
thread_id=thread_id,
|
||||
metadata=thread.metadata,
|
||||
)
|
||||
|
||||
db.add(new_thread)
|
||||
db.commit()
|
||||
db.refresh(new_thread)
|
||||
logger.info(f"Created conversation with thread_id: {thread_id}")
|
||||
|
||||
return {
|
||||
"id": new_thread.id,
|
||||
"user_id": new_thread.user_id,
|
||||
"agent_id": new_thread.agent_id,
|
||||
"title": new_thread.title,
|
||||
"description": new_thread.description,
|
||||
"create_at": new_thread.create_at.isoformat(),
|
||||
"update_at": new_thread.update_at.isoformat(),
|
||||
"id": conversation.thread_id,
|
||||
"user_id": conversation.user_id,
|
||||
"agent_id": conversation.agent_id,
|
||||
"title": conversation.title,
|
||||
"create_at": conversation.created_at.isoformat(),
|
||||
"update_at": conversation.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@chat.get("/threads", response_model=list[ThreadResponse])
|
||||
async def list_threads(agent_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_required_user)):
|
||||
"""获取用户的所有对话线程"""
|
||||
"""获取用户的所有对话线程 (使用新存储系统)"""
|
||||
assert agent_id, "agent_id 不能为空"
|
||||
query = db.query(Thread).filter(
|
||||
Thread.user_id == str(current_user.id),
|
||||
Thread.status == 1,
|
||||
Thread.agent_id == agent_id,
|
||||
)
|
||||
|
||||
logger.debug(f"agent_id: {agent_id}")
|
||||
|
||||
threads = query.order_by(Thread.update_at.desc()).all()
|
||||
# Use new storage system
|
||||
conv_manager = ConversationManager(db)
|
||||
conversations = conv_manager.list_conversations(
|
||||
user_id=str(current_user.id),
|
||||
agent_id=agent_id,
|
||||
status="active",
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": thread.id,
|
||||
"user_id": thread.user_id,
|
||||
"agent_id": thread.agent_id,
|
||||
"title": thread.title,
|
||||
"description": thread.description,
|
||||
"create_at": thread.create_at.isoformat(),
|
||||
"update_at": thread.update_at.isoformat(),
|
||||
"id": conv.thread_id,
|
||||
"user_id": conv.user_id,
|
||||
"agent_id": conv.agent_id,
|
||||
"title": conv.title,
|
||||
"create_at": conv.created_at.isoformat(),
|
||||
"update_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
for thread in threads
|
||||
for conv in conversations
|
||||
]
|
||||
|
||||
|
||||
@chat.delete("/thread/{thread_id}")
|
||||
async def delete_thread(thread_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_required_user)):
|
||||
"""删除对话线程"""
|
||||
thread = db.query(Thread).filter(Thread.id == thread_id, Thread.user_id == str(current_user.id)).first()
|
||||
"""删除对话线程 (使用新存储系统)"""
|
||||
# Use new storage system
|
||||
conv_manager = ConversationManager(db)
|
||||
conversation = conv_manager.get_conversation_by_thread_id(thread_id)
|
||||
|
||||
if not thread:
|
||||
if not conversation or conversation.user_id != str(current_user.id):
|
||||
raise HTTPException(status_code=404, detail="对话线程不存在")
|
||||
|
||||
# 软删除
|
||||
thread.status = 0
|
||||
db.commit()
|
||||
# Soft delete
|
||||
success = conv_manager.delete_conversation(thread_id, soft_delete=True)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
class ThreadUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@chat.put("/thread/{thread_id}", response_model=ThreadResponse)
|
||||
@ -395,31 +567,28 @@ async def update_thread(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_required_user),
|
||||
):
|
||||
"""更新对话线程信息"""
|
||||
thread = (
|
||||
db.query(Thread)
|
||||
.filter(Thread.id == thread_id, Thread.user_id == str(current_user.id), Thread.status == 1)
|
||||
.first()
|
||||
)
|
||||
"""更新对话线程信息 (使用新存储系统)"""
|
||||
# Use new storage system
|
||||
conv_manager = ConversationManager(db)
|
||||
conversation = conv_manager.get_conversation_by_thread_id(thread_id)
|
||||
|
||||
if not thread:
|
||||
if not conversation or conversation.user_id != str(current_user.id) or conversation.status == "deleted":
|
||||
raise HTTPException(status_code=404, detail="对话线程不存在")
|
||||
|
||||
if thread_update.title is not None:
|
||||
thread.title = thread_update.title
|
||||
# Update conversation
|
||||
updated_conv = conv_manager.update_conversation(
|
||||
thread_id=thread_id,
|
||||
title=thread_update.title,
|
||||
)
|
||||
|
||||
if thread_update.description is not None:
|
||||
thread.description = thread_update.description
|
||||
|
||||
db.commit()
|
||||
db.refresh(thread)
|
||||
if not updated_conv:
|
||||
raise HTTPException(status_code=500, detail="更新失败")
|
||||
|
||||
return {
|
||||
"id": thread.id,
|
||||
"user_id": thread.user_id,
|
||||
"agent_id": thread.agent_id,
|
||||
"title": thread.title,
|
||||
"description": thread.description,
|
||||
"create_at": thread.create_at.isoformat(),
|
||||
"update_at": thread.update_at.isoformat(),
|
||||
"id": updated_conv.thread_id,
|
||||
"user_id": updated_conv.user_id,
|
||||
"agent_id": updated_conv.agent_id,
|
||||
"title": updated_conv.title,
|
||||
"create_at": updated_conv.created_at.isoformat(),
|
||||
"update_at": updated_conv.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
231
server/routers/dashboard_router.py
Normal file
231
server/routers/dashboard_router.py
Normal file
@ -0,0 +1,231 @@
|
||||
"""
|
||||
Dashboard Router - Statistics and monitoring endpoints
|
||||
|
||||
Provides centralized dashboard APIs for monitoring system-wide statistics.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from server.routers.auth_router import get_admin_user
|
||||
from server.utils.auth_middleware import get_db
|
||||
from src.storage.conversation import ConversationManager
|
||||
from src.storage.db.models import User
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
|
||||
dashboard = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ConversationListItem(BaseModel):
|
||||
"""Conversation list item"""
|
||||
|
||||
thread_id: str
|
||||
user_id: str
|
||||
agent_id: str
|
||||
title: str
|
||||
status: str
|
||||
message_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConversationDetailResponse(BaseModel):
|
||||
"""Conversation detail"""
|
||||
|
||||
thread_id: str
|
||||
user_id: str
|
||||
agent_id: str
|
||||
title: str
|
||||
status: str
|
||||
message_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
total_tokens: int
|
||||
messages: list[dict]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Conversation Management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/conversations", response_model=list[ConversationListItem])
|
||||
async def get_all_conversations(
|
||||
user_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
status: str = "active",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""Get all conversations (Admin only)"""
|
||||
from src.storage.db.models import Conversation, ConversationStats
|
||||
|
||||
try:
|
||||
# Build query
|
||||
query = db.query(Conversation, ConversationStats).outerjoin(
|
||||
ConversationStats, Conversation.id == ConversationStats.conversation_id
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if user_id:
|
||||
query = query.filter(Conversation.user_id == user_id)
|
||||
if agent_id:
|
||||
query = query.filter(Conversation.agent_id == agent_id)
|
||||
if status != "all":
|
||||
query = query.filter(Conversation.status == status)
|
||||
|
||||
# Order and paginate
|
||||
query = query.order_by(Conversation.updated_at.desc()).limit(limit).offset(offset)
|
||||
|
||||
results = query.all()
|
||||
|
||||
return [
|
||||
{
|
||||
"thread_id": conv.thread_id,
|
||||
"user_id": conv.user_id,
|
||||
"agent_id": conv.agent_id,
|
||||
"title": conv.title,
|
||||
"status": conv.status,
|
||||
"message_count": stats.message_count if stats else 0,
|
||||
"created_at": conv.created_at.isoformat(),
|
||||
"updated_at": conv.updated_at.isoformat(),
|
||||
}
|
||||
for conv, stats in results
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting conversations: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get conversations: {str(e)}")
|
||||
|
||||
|
||||
@dashboard.get("/conversations/{thread_id}", response_model=ConversationDetailResponse)
|
||||
async def get_conversation_detail(
|
||||
thread_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""Get conversation detail (Admin only)"""
|
||||
try:
|
||||
conv_manager = ConversationManager(db)
|
||||
conversation = conv_manager.get_conversation_by_thread_id(thread_id)
|
||||
|
||||
if not conversation:
|
||||
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||
|
||||
# Get messages and stats
|
||||
messages = conv_manager.get_messages(conversation.id)
|
||||
stats = conv_manager.get_stats(conversation.id)
|
||||
|
||||
# Format messages
|
||||
message_list = []
|
||||
for msg in messages:
|
||||
msg_dict = {
|
||||
"id": msg.id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"message_type": msg.message_type,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
}
|
||||
|
||||
# Include tool calls if present
|
||||
if msg.tool_calls:
|
||||
msg_dict["tool_calls"] = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"tool_name": tc.tool_name,
|
||||
"tool_input": tc.tool_input,
|
||||
"tool_output": tc.tool_output,
|
||||
"status": tc.status,
|
||||
}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
|
||||
message_list.append(msg_dict)
|
||||
|
||||
return {
|
||||
"thread_id": conversation.thread_id,
|
||||
"user_id": conversation.user_id,
|
||||
"agent_id": conversation.agent_id,
|
||||
"title": conversation.title,
|
||||
"status": conversation.status,
|
||||
"message_count": stats.message_count if stats else len(message_list),
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
"total_tokens": stats.total_tokens if stats else 0,
|
||||
"messages": message_list,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting conversation detail: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get conversation detail: {str(e)}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Statistics
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dashboard.get("/stats")
|
||||
async def get_dashboard_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
):
|
||||
"""Get dashboard statistics (Admin only)"""
|
||||
from src.storage.db.models import Conversation, Message
|
||||
|
||||
try:
|
||||
# Basic counts
|
||||
total_conversations = db.query(func.count(Conversation.id)).scalar() or 0
|
||||
active_conversations = (
|
||||
db.query(func.count(Conversation.id)).filter(Conversation.status == "active").scalar() or 0
|
||||
)
|
||||
total_messages = db.query(func.count(Message.id)).scalar() or 0
|
||||
total_users = db.query(func.count(distinct(Conversation.user_id))).scalar() or 0
|
||||
|
||||
# Conversations by agent
|
||||
conversations_by_agent = (
|
||||
db.query(Conversation.agent_id, func.count(Conversation.id).label("count"))
|
||||
.group_by(Conversation.agent_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Recent activity (last 24 hours)
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
recent_conversations = (
|
||||
db.query(func.count(Conversation.id)).filter(Conversation.created_at >= yesterday).scalar() or 0
|
||||
)
|
||||
recent_messages = db.query(func.count(Message.id)).filter(Message.created_at >= yesterday).scalar() or 0
|
||||
|
||||
return {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"total_conversations": total_conversations,
|
||||
"active_conversations": active_conversations,
|
||||
"total_messages": total_messages,
|
||||
"total_users": total_users,
|
||||
"conversations_by_agent": [
|
||||
{"agent_id": agent_id or "unknown", "count": count} for agent_id, count in conversations_by_agent
|
||||
],
|
||||
"recent_activity": {
|
||||
"conversations_24h": recent_conversations,
|
||||
"messages_24h": recent_messages,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting dashboard stats: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get dashboard stats: {str(e)}")
|
||||
4
src/storage/conversation/__init__.py
Normal file
4
src/storage/conversation/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .manager import ConversationManager
|
||||
|
||||
__all__ = ["ConversationManager"]
|
||||
|
||||
356
src/storage/conversation/manager.py
Normal file
356
src/storage/conversation/manager.py
Normal file
@ -0,0 +1,356 @@
|
||||
"""
|
||||
Conversation Storage Manager
|
||||
|
||||
Manages conversation data storage including messages, tool calls, and statistics.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from src.storage.db.models import Conversation, ConversationStats, Message, ToolCall
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class ConversationManager:
|
||||
"""Manager for conversation storage operations"""
|
||||
|
||||
def __init__(self, db_session: Session):
|
||||
self.db = db_session
|
||||
|
||||
def create_conversation(
|
||||
self,
|
||||
user_id: str,
|
||||
agent_id: str,
|
||||
title: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Conversation:
|
||||
"""
|
||||
Create a new conversation
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
agent_id: Agent ID
|
||||
title: Conversation title
|
||||
thread_id: Optional thread ID (will generate UUID if not provided)
|
||||
metadata: Optional additional metadata
|
||||
|
||||
Returns:
|
||||
Created Conversation object
|
||||
"""
|
||||
if not thread_id:
|
||||
thread_id = str(uuid.uuid4())
|
||||
|
||||
conversation = Conversation(
|
||||
thread_id=thread_id,
|
||||
user_id=str(user_id),
|
||||
agent_id=agent_id,
|
||||
title=title or "New Conversation",
|
||||
status="active",
|
||||
extra_metadata=metadata if metadata else None,
|
||||
)
|
||||
|
||||
self.db.add(conversation)
|
||||
# Flush to assign primary key without committing
|
||||
self.db.flush()
|
||||
|
||||
# Create associated stats record and commit once
|
||||
stats = ConversationStats(conversation_id=conversation.id)
|
||||
self.db.add(stats)
|
||||
self.db.commit()
|
||||
self.db.refresh(conversation)
|
||||
|
||||
logger.info(f"Created conversation: {conversation.thread_id} for user {user_id}")
|
||||
return conversation
|
||||
|
||||
def get_conversation_by_thread_id(self, thread_id: str) -> Conversation | None:
|
||||
"""
|
||||
Get conversation by thread ID
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID
|
||||
|
||||
Returns:
|
||||
Conversation object or None if not found
|
||||
"""
|
||||
return self.db.query(Conversation).filter(Conversation.thread_id == thread_id).first()
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
message_type: str = "text",
|
||||
extra_metadata: dict | None = None,
|
||||
) -> Message:
|
||||
"""
|
||||
Add a message to a conversation
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
role: Message role (user/assistant/system/tool)
|
||||
content: Message content
|
||||
message_type: Message type (text/tool_call/tool_result)
|
||||
extra_metadata: Additional metadata (complete message dump)
|
||||
|
||||
Returns:
|
||||
Created Message object
|
||||
"""
|
||||
message = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
extra_metadata=extra_metadata or {},
|
||||
)
|
||||
|
||||
self.db.add(message)
|
||||
self.db.commit()
|
||||
self.db.refresh(message)
|
||||
|
||||
# Update conversation stats
|
||||
self._update_message_count(conversation_id)
|
||||
|
||||
logger.debug(f"Added {role} message to conversation {conversation_id}")
|
||||
return message
|
||||
|
||||
def add_tool_call(
|
||||
self,
|
||||
message_id: int,
|
||||
tool_name: str,
|
||||
tool_input: dict | None = None,
|
||||
tool_output: str | None = None,
|
||||
status: str = "pending",
|
||||
error_message: str | None = None,
|
||||
) -> ToolCall:
|
||||
"""
|
||||
Add a tool call record
|
||||
|
||||
Args:
|
||||
message_id: Message ID
|
||||
tool_name: Tool name
|
||||
tool_input: Tool input parameters
|
||||
tool_output: Tool execution result
|
||||
status: Status (pending/success/error)
|
||||
error_message: Error message if failed
|
||||
|
||||
Returns:
|
||||
Created ToolCall object
|
||||
"""
|
||||
tool_call = ToolCall(
|
||||
message_id=message_id,
|
||||
tool_name=tool_name,
|
||||
tool_input=tool_input or {},
|
||||
tool_output=tool_output,
|
||||
status=status,
|
||||
error_message=error_message,
|
||||
)
|
||||
|
||||
self.db.add(tool_call)
|
||||
self.db.commit()
|
||||
self.db.refresh(tool_call)
|
||||
|
||||
logger.debug(f"Added tool call {tool_name} to message {message_id}")
|
||||
return tool_call
|
||||
|
||||
def get_messages(
|
||||
self, conversation_id: int, limit: int | None = None, offset: int = 0
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Get messages for a conversation
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
limit: Maximum number of messages to return
|
||||
offset: Number of messages to skip
|
||||
|
||||
Returns:
|
||||
List of Message objects
|
||||
"""
|
||||
query = (
|
||||
self.db.query(Message)
|
||||
.filter(Message.conversation_id == conversation_id)
|
||||
.order_by(Message.created_at.asc())
|
||||
.options(joinedload(Message.tool_calls))
|
||||
)
|
||||
|
||||
if limit:
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
return query.all()
|
||||
|
||||
def get_messages_by_thread_id(
|
||||
self, thread_id: str, limit: int | None = None, offset: int = 0
|
||||
) -> list[Message]:
|
||||
"""
|
||||
Get messages for a conversation by thread ID
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID
|
||||
limit: Maximum number of messages to return
|
||||
offset: Number of messages to skip
|
||||
|
||||
Returns:
|
||||
List of Message objects
|
||||
"""
|
||||
conversation = self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
return []
|
||||
|
||||
return self.get_messages(conversation.id, limit, offset)
|
||||
|
||||
def list_conversations(
|
||||
self, user_id: str | None = None, agent_id: str | None = None, status: str = "active"
|
||||
) -> list[Conversation]:
|
||||
"""
|
||||
List conversations for a user or all users
|
||||
|
||||
Args:
|
||||
user_id: User ID (optional, if None or empty string, returns all users' conversations)
|
||||
agent_id: Optional agent ID filter
|
||||
status: Conversation status filter
|
||||
|
||||
Returns:
|
||||
List of Conversation objects
|
||||
"""
|
||||
query = self.db.query(Conversation).filter(Conversation.status == status)
|
||||
|
||||
# Only filter by user_id if it's provided and not empty
|
||||
if user_id:
|
||||
query = query.filter(Conversation.user_id == str(user_id))
|
||||
|
||||
if agent_id:
|
||||
query = query.filter(Conversation.agent_id == agent_id)
|
||||
|
||||
return query.order_by(Conversation.updated_at.desc()).all()
|
||||
|
||||
def update_conversation(
|
||||
self,
|
||||
thread_id: str,
|
||||
title: str | None = None,
|
||||
status: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Update conversation information
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID
|
||||
title: New title
|
||||
status: New status
|
||||
metadata: Additional metadata to merge
|
||||
|
||||
Returns:
|
||||
Updated Conversation object or None if not found
|
||||
"""
|
||||
conversation = self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
return None
|
||||
|
||||
if title is not None:
|
||||
conversation.title = title
|
||||
if status is not None:
|
||||
conversation.status = status
|
||||
|
||||
# Handle metadata updates
|
||||
if metadata is not None:
|
||||
current_metadata = conversation.extra_metadata or {}
|
||||
current_metadata.update(metadata)
|
||||
conversation.extra_metadata = current_metadata
|
||||
|
||||
conversation.updated_at = datetime.now()
|
||||
self.db.commit()
|
||||
self.db.refresh(conversation)
|
||||
|
||||
logger.info(f"Updated conversation {thread_id}")
|
||||
return conversation
|
||||
|
||||
def delete_conversation(self, thread_id: str, soft_delete: bool = True) -> bool:
|
||||
"""
|
||||
Delete a conversation
|
||||
|
||||
Args:
|
||||
thread_id: Thread ID
|
||||
soft_delete: If True, mark as deleted; if False, permanently delete
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
conversation = self.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation:
|
||||
return False
|
||||
|
||||
if soft_delete:
|
||||
conversation.status = "deleted"
|
||||
self.db.commit()
|
||||
logger.info(f"Soft deleted conversation {thread_id}")
|
||||
else:
|
||||
self.db.delete(conversation)
|
||||
self.db.commit()
|
||||
logger.info(f"Permanently deleted conversation {thread_id}")
|
||||
|
||||
return True
|
||||
|
||||
def get_stats(self, conversation_id: int) -> ConversationStats | None:
|
||||
"""
|
||||
Get conversation statistics
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
|
||||
Returns:
|
||||
ConversationStats object or None if not found
|
||||
"""
|
||||
return self.db.query(ConversationStats).filter(ConversationStats.conversation_id == conversation_id).first()
|
||||
|
||||
def update_stats(
|
||||
self,
|
||||
conversation_id: int,
|
||||
tokens_used: int | None = None,
|
||||
model_used: str | None = None,
|
||||
user_feedback: dict | None = None,
|
||||
) -> ConversationStats | None:
|
||||
"""
|
||||
Update conversation statistics
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
tokens_used: Number of tokens to add
|
||||
model_used: Model name
|
||||
user_feedback: User feedback data
|
||||
|
||||
Returns:
|
||||
Updated ConversationStats object or None if not found
|
||||
"""
|
||||
stats = self.get_stats(conversation_id)
|
||||
if not stats:
|
||||
return None
|
||||
|
||||
if tokens_used is not None:
|
||||
stats.total_tokens += tokens_used
|
||||
if model_used is not None:
|
||||
stats.model_used = model_used
|
||||
if user_feedback is not None:
|
||||
stats.user_feedback = user_feedback
|
||||
|
||||
stats.updated_at = datetime.now()
|
||||
self.db.commit()
|
||||
self.db.refresh(stats)
|
||||
|
||||
return stats
|
||||
|
||||
def _update_message_count(self, conversation_id: int) -> None:
|
||||
"""
|
||||
Update message count in conversation stats
|
||||
|
||||
Args:
|
||||
conversation_id: Conversation ID
|
||||
"""
|
||||
stats = self.get_stats(conversation_id)
|
||||
if stats:
|
||||
message_count = self.db.query(Message).filter(Message.conversation_id == conversation_id).count()
|
||||
stats.message_count = message_count
|
||||
self.db.commit()
|
||||
|
||||
@ -10,7 +10,6 @@ from src.storage.db.models import ( # noqa: E402, F401
|
||||
KnowledgeFile,
|
||||
KnowledgeNode,
|
||||
OperationLog,
|
||||
Thread,
|
||||
User,
|
||||
) # noqa: E402
|
||||
|
||||
@ -18,7 +17,6 @@ __all__ = [
|
||||
"Base",
|
||||
"User",
|
||||
"OperationLog",
|
||||
"Thread",
|
||||
"KnowledgeDatabase",
|
||||
"KnowledgeFile",
|
||||
"KnowledgeNode",
|
||||
|
||||
@ -113,20 +113,135 @@ class KnowledgeNode(Base):
|
||||
}
|
||||
|
||||
|
||||
class Thread(Base):
|
||||
"""对话线程表"""
|
||||
class Conversation(Base):
|
||||
"""Conversation table - new storage system"""
|
||||
|
||||
__tablename__ = "thread"
|
||||
__tablename__ = "conversations"
|
||||
|
||||
id = Column(String(64), primary_key=True, index=True, comment="线程ID")
|
||||
user_id = Column(String(64), index=True, nullable=False, comment="用户ID")
|
||||
agent_id = Column(String(64), index=True, nullable=False, comment="智能体ID")
|
||||
title = Column(String(255), nullable=True, comment="标题")
|
||||
create_at = Column(DateTime, default=func.now(), comment="创建时间")
|
||||
update_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
|
||||
thread_id = Column(String(64), unique=True, index=True, nullable=False, comment="Thread ID (UUID)")
|
||||
user_id = Column(String(64), index=True, nullable=False, comment="User ID")
|
||||
agent_id = Column(String(64), index=True, nullable=False, comment="Agent ID")
|
||||
title = Column(String(255), nullable=True, comment="Conversation title")
|
||||
status = Column(String(20), default="active", comment="Status: active/archived/deleted")
|
||||
created_at = Column(DateTime, default=func.now(), comment="Creation time")
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="Update time")
|
||||
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata")
|
||||
|
||||
description = Column(String(255), nullable=True, comment="描述")
|
||||
status = Column(Integer, default=1, comment="状态")
|
||||
# Relationships
|
||||
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan")
|
||||
stats = relationship(
|
||||
"ConversationStats", back_populates="conversation", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"thread_id": self.thread_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_id": self.agent_id,
|
||||
"title": self.title,
|
||||
"status": self.status,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"metadata": self.extra_metadata or {},
|
||||
}
|
||||
|
||||
|
||||
class Message(Base):
|
||||
"""Message table - stores conversation messages"""
|
||||
|
||||
__tablename__ = "messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
|
||||
conversation_id = Column(
|
||||
Integer, ForeignKey("conversations.id"), nullable=False, index=True, comment="Conversation ID"
|
||||
)
|
||||
role = Column(String(20), nullable=False, comment="Message role: user/assistant/system/tool")
|
||||
content = Column(Text, nullable=False, comment="Message content")
|
||||
message_type = Column(String(30), default="text", comment="Message type: text/tool_call/tool_result")
|
||||
created_at = Column(DateTime, default=func.now(), comment="Creation time")
|
||||
token_count = Column(Integer, nullable=True, comment="Token count (optional)")
|
||||
extra_metadata = Column(JSON, nullable=True, comment="Additional metadata (complete message dump)")
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="messages")
|
||||
tool_calls = relationship("ToolCall", back_populates="message", cascade="all, delete-orphan")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"conversation_id": self.conversation_id,
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"message_type": self.message_type,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"token_count": self.token_count,
|
||||
"metadata": self.extra_metadata or {},
|
||||
"tool_calls": [tc.to_dict() for tc in self.tool_calls] if self.tool_calls else [],
|
||||
}
|
||||
|
||||
|
||||
class ToolCall(Base):
|
||||
"""ToolCall table - stores tool invocations"""
|
||||
|
||||
__tablename__ = "tool_calls"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
|
||||
message_id = Column(Integer, ForeignKey("messages.id"), nullable=False, index=True, comment="Message ID")
|
||||
tool_name = Column(String(100), nullable=False, comment="Tool name")
|
||||
tool_input = Column(JSON, nullable=True, comment="Tool input parameters")
|
||||
tool_output = Column(Text, nullable=True, comment="Tool execution result")
|
||||
status = Column(String(20), default="pending", comment="Status: pending/success/error")
|
||||
error_message = Column(Text, nullable=True, comment="Error message if failed")
|
||||
created_at = Column(DateTime, default=func.now(), comment="Creation time")
|
||||
|
||||
# Relationships
|
||||
message = relationship("Message", back_populates="tool_calls")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"message_id": self.message_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_input": self.tool_input or {},
|
||||
"tool_output": self.tool_output,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ConversationStats(Base):
|
||||
"""ConversationStats table - stores conversation statistics"""
|
||||
|
||||
__tablename__ = "conversation_stats"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment="Primary key")
|
||||
conversation_id = Column(
|
||||
Integer, ForeignKey("conversations.id"), unique=True, nullable=False, comment="Conversation ID"
|
||||
)
|
||||
message_count = Column(Integer, default=0, comment="Total message count")
|
||||
total_tokens = Column(Integer, default=0, comment="Total tokens used")
|
||||
model_used = Column(String(100), nullable=True, comment="Model used")
|
||||
user_feedback = Column(JSON, nullable=True, comment="User feedback")
|
||||
created_at = Column(DateTime, default=func.now(), comment="Creation time")
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), comment="Update time")
|
||||
|
||||
# Relationships
|
||||
conversation = relationship("Conversation", back_populates="stats")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"conversation_id": self.conversation_id,
|
||||
"message_count": self.message_count,
|
||||
"total_tokens": self.total_tokens,
|
||||
"model_used": self.model_used,
|
||||
"user_feedback": self.user_feedback or {},
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
||||
48
web/src/apis/dashboard_api.js
Normal file
48
web/src/apis/dashboard_api.js
Normal file
@ -0,0 +1,48 @@
|
||||
import { apiAdminGet } from './base'
|
||||
|
||||
/**
|
||||
* Dashboard API模块
|
||||
* 用于管理员查看所有用户的对话记录
|
||||
*/
|
||||
|
||||
export const dashboardApi = {
|
||||
/**
|
||||
* 获取所有对话记录
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {string} params.user_id - 用户ID过滤
|
||||
* @param {string} params.agent_id - 智能体ID过滤
|
||||
* @param {string} params.status - 状态过滤 (active/deleted/all)
|
||||
* @param {number} params.limit - 每页数量
|
||||
* @param {number} params.offset - 偏移量
|
||||
* @returns {Promise<Array>} - 对话列表
|
||||
*/
|
||||
getConversations: (params = {}) => {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params.user_id) queryParams.append('user_id', params.user_id)
|
||||
if (params.agent_id) queryParams.append('agent_id', params.agent_id)
|
||||
if (params.status) queryParams.append('status', params.status)
|
||||
if (params.limit) queryParams.append('limit', params.limit)
|
||||
if (params.offset) queryParams.append('offset', params.offset)
|
||||
|
||||
return apiAdminGet(`/api/dashboard/conversations?${queryParams.toString()}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取对话详情
|
||||
* @param {string} threadId - 对话线程ID
|
||||
* @returns {Promise<Object>} - 对话详情
|
||||
*/
|
||||
getConversationDetail: (threadId) => {
|
||||
return apiAdminGet(`/api/dashboard/conversations/${threadId}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取Dashboard统计信息
|
||||
* @returns {Promise<Object>} - 统计信息
|
||||
*/
|
||||
getStats: () => {
|
||||
return apiAdminGet('/api/dashboard/stats')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
GithubOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Bot, Waypoints, LibraryBig, Settings } from 'lucide-vue-next';
|
||||
import { Bot, Waypoints, LibraryBig, Settings, BarChart3 } from 'lucide-vue-next';
|
||||
import { onLongPress } from '@vueuse/core'
|
||||
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
@ -103,6 +103,11 @@ const mainList = [{
|
||||
path: '/database',
|
||||
icon: LibraryBig,
|
||||
activeIcon: LibraryBig,
|
||||
}, {
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
icon: BarChart3,
|
||||
activeIcon: BarChart3,
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
@ -90,6 +90,19 @@ const router = createRouter({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'DashboardComp',
|
||||
component: () => import('../views/DashboardView.vue'),
|
||||
meta: { keepAlive: false, requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
@ -126,7 +139,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (!agentStore.isInitialized) {
|
||||
await agentStore.initialize();
|
||||
}
|
||||
|
||||
|
||||
const defaultAgent = agentStore.defaultAgent;
|
||||
if (defaultAgent && defaultAgent.id) {
|
||||
next(`/agent/${defaultAgent.id}`);
|
||||
|
||||
@ -9,7 +9,7 @@ export class MessageProcessor {
|
||||
*/
|
||||
static convertToolResultToMessages(msgs) {
|
||||
const toolResponseMap = new Map();
|
||||
|
||||
|
||||
// 构建工具响应映射
|
||||
for (const item of msgs) {
|
||||
if (item.type === 'tool' && item.tool_call_id) {
|
||||
@ -43,15 +43,27 @@ export class MessageProcessor {
|
||||
* @returns {Array} 对话数组
|
||||
*/
|
||||
static convertServerHistoryToMessages(serverHistory) {
|
||||
// 第一步:将所有tool消息与对应的tool call合并
|
||||
const mergedHistory = this.convertToolResultToMessages(serverHistory);
|
||||
// Filter out standalone 'tool' messages since tool results are already in AI messages' tool_calls
|
||||
// Backend new storage: tool results are embedded in AI messages' tool_calls array with tool_call_result field
|
||||
const filteredHistory = serverHistory.filter(item => item.type !== 'tool');
|
||||
|
||||
// 第二步:按照对话分组
|
||||
// 按照对话分组
|
||||
const conversations = [];
|
||||
let currentConv = null;
|
||||
|
||||
for (const item of mergedHistory) {
|
||||
for (const item of filteredHistory) {
|
||||
if (item.type === 'human') {
|
||||
// Start new conversation, finalize previous one
|
||||
if (currentConv) {
|
||||
// Find the last AI message and mark it as final
|
||||
for (let i = currentConv.messages.length - 1; i >= 0; i--) {
|
||||
if (currentConv.messages[i].type === 'ai') {
|
||||
currentConv.messages[i].isLast = true;
|
||||
currentConv.status = 'finished';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
currentConv = {
|
||||
messages: [item],
|
||||
status: 'loading'
|
||||
@ -59,11 +71,17 @@ export class MessageProcessor {
|
||||
conversations.push(currentConv);
|
||||
} else if (item.type === 'ai' && currentConv) {
|
||||
currentConv.messages.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.response_metadata?.finish_reason === 'stop') {
|
||||
item.isLast = true;
|
||||
// Mark the last conversation as finished
|
||||
if (currentConv && currentConv.messages.length > 0) {
|
||||
// Find the last AI message and mark it as final
|
||||
for (let i = currentConv.messages.length - 1; i >= 0; i--) {
|
||||
if (currentConv.messages[i].type === 'ai') {
|
||||
currentConv.messages[i].isLast = true;
|
||||
currentConv.status = 'finished';
|
||||
currentConv = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,7 +104,7 @@ export class MessageProcessor {
|
||||
// 合并后续chunks
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
|
||||
|
||||
// 合并内容
|
||||
if (chunk.content) {
|
||||
result.content += chunk.content;
|
||||
|
||||
324
web/src/views/DashboardView.vue
Normal file
324
web/src/views/DashboardView.vue
Normal file
@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-section">
|
||||
<a-card class="stat-card" :loading="loading">
|
||||
<a-statistic title="总对话数" :value="stats.total_conversations" />
|
||||
</a-card>
|
||||
<a-card class="stat-card" :loading="loading">
|
||||
<a-statistic title="活跃对话" :value="stats.active_conversations" />
|
||||
</a-card>
|
||||
<a-card class="stat-card" :loading="loading">
|
||||
<a-statistic title="总消息数" :value="stats.total_messages" />
|
||||
</a-card>
|
||||
<a-card class="stat-card" :loading="loading">
|
||||
<a-statistic title="用户数" :value="stats.total_users" />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 过滤器区域 -->
|
||||
<a-card class="filter-section" title="筛选条件">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-input
|
||||
v-model:value="filters.user_id"
|
||||
placeholder="用户ID"
|
||||
allow-clear
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input
|
||||
v-model:value="filters.agent_id"
|
||||
placeholder="智能体ID"
|
||||
allow-clear
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
placeholder="状态"
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="deleted">已删除</a-select-option>
|
||||
<a-select-option value="all">全部</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button type="primary" @click="loadConversations" :loading="loading">
|
||||
刷新
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 对话列表区域 -->
|
||||
<a-card class="conversations-section" title="对话记录">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="conversations"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@change="handleTableChange"
|
||||
row-key="thread_id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<a @click="handleViewDetail(record)">{{ record.title }}</a>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'updated_at'">
|
||||
{{ formatDate(record.updated_at) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'actions'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
查看详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { dashboardApi } from '@/apis/dashboard_api'
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
total_conversations: 0,
|
||||
active_conversations: 0,
|
||||
total_messages: 0,
|
||||
total_users: 0,
|
||||
})
|
||||
|
||||
// 过滤器
|
||||
const filters = reactive({
|
||||
user_id: '',
|
||||
agent_id: '',
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// 对话列表
|
||||
const conversations = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: '20%',
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
title: '智能体ID',
|
||||
dataIndex: 'agent_id',
|
||||
key: 'agent_id',
|
||||
width: '12%',
|
||||
},
|
||||
{
|
||||
title: '消息数',
|
||||
dataIndex: 'message_count',
|
||||
key: 'message_count',
|
||||
width: '8%',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: '8%',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: '10%',
|
||||
},
|
||||
]
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await dashboardApi.getStats()
|
||||
Object.assign(stats, response)
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
message.error('加载统计数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载对话列表
|
||||
const loadConversations = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
user_id: filters.user_id || undefined,
|
||||
agent_id: filters.agent_id || undefined,
|
||||
status: filters.status,
|
||||
limit: pagination.pageSize,
|
||||
offset: (pagination.current - 1) * pagination.pageSize,
|
||||
}
|
||||
|
||||
const response = await dashboardApi.getConversations(params)
|
||||
conversations.value = response
|
||||
// Note: 由于后端没有返回总数,这里暂时设置为当前数据长度
|
||||
// 如果需要精确分页,需要后端返回 total count
|
||||
} catch (error) {
|
||||
console.error('加载对话列表失败:', error)
|
||||
message.error('加载对话列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查看对话详情
|
||||
const handleViewDetail = async (record) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const detail = await dashboardApi.getConversationDetail(record.thread_id)
|
||||
|
||||
console.log('========================================')
|
||||
console.log('=== 数据库原始数据 ===')
|
||||
console.log('========================================')
|
||||
console.log('完整对话数据(JSON):')
|
||||
console.log(JSON.stringify(detail, null, 2))
|
||||
console.log('\n')
|
||||
console.log('完整对话数据(对象):')
|
||||
console.log(detail)
|
||||
console.log('========================================')
|
||||
|
||||
message.success('数据库原始数据已输出到控制台')
|
||||
} catch (error) {
|
||||
console.error('获取对话详情失败:', error)
|
||||
message.error('获取对话详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理过滤器变化
|
||||
const handleFilterChange = () => {
|
||||
pagination.current = 1
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
// 处理表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadConversations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dashboard-container {
|
||||
padding: 24px;
|
||||
background-color: var(--bg-color);
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 24px;
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.conversations-section {
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background-color: var(--hover-bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: var(--hover-bg-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user