mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
Merge pull request #983 from Soulter/feat-conversation-webui-mgr
✨ 支持 WebUI 对话管理
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from astrbot.core.db.po import Stats, LLMHistory, ATRIVision, Conversation
|
||||
|
||||
|
||||
@@ -117,3 +117,45 @@ class BaseDatabase(abc.ABC):
|
||||
def update_conversation_persona_id(self, user_id: str, cid: str, persona_id: str):
|
||||
"""更新 Conversation Persona ID"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_all_conversations(
|
||||
self, page: int = 1, page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取所有对话,支持分页
|
||||
|
||||
Args:
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_filtered_conversations(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
platforms: List[str] = None,
|
||||
message_types: List[str] = None,
|
||||
search_query: str = None,
|
||||
exclude_ids: List[str] = None,
|
||||
exclude_platforms: List[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取筛选后的对话列表
|
||||
|
||||
Args:
|
||||
page: 页码
|
||||
page_size: 每页数量
|
||||
platforms: 平台筛选列表
|
||||
message_types: 消息类型筛选列表
|
||||
search_query: 搜索关键词
|
||||
exclude_ids: 排除的用户ID列表
|
||||
exclude_platforms: 排除的平台列表
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict[str, Any]], int]: 返回一个元组,包含对话列表和总对话数
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import time
|
||||
from astrbot.core.db.po import Platform, Stats, LLMHistory, ATRIVision, Conversation
|
||||
from . import BaseDatabase
|
||||
from typing import Tuple
|
||||
from typing import Tuple, List, Dict, Any
|
||||
|
||||
|
||||
class SQLiteDatabase(BaseDatabase):
|
||||
@@ -389,3 +389,177 @@ class SQLiteDatabase(BaseDatabase):
|
||||
if res:
|
||||
return ATRIVision(*res)
|
||||
return None
|
||||
|
||||
def get_all_conversations(
|
||||
self, page: int = 1, page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取所有对话,支持分页,按更新时间降序排序"""
|
||||
try:
|
||||
c = self.conn.cursor()
|
||||
except sqlite3.ProgrammingError:
|
||||
c = self._get_conn(self.db_path).cursor()
|
||||
|
||||
try:
|
||||
# 获取总记录数
|
||||
c.execute("""
|
||||
SELECT COUNT(*) FROM webchat_conversation
|
||||
""")
|
||||
total_count = c.fetchone()[0]
|
||||
|
||||
# 计算偏移量
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 获取分页数据,按更新时间降序排序
|
||||
c.execute(
|
||||
"""
|
||||
SELECT user_id, cid, created_at, updated_at, title, persona_id
|
||||
FROM webchat_conversation
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(page_size, offset),
|
||||
)
|
||||
|
||||
rows = c.fetchall()
|
||||
|
||||
conversations = []
|
||||
|
||||
for row in rows:
|
||||
user_id, cid, created_at, updated_at, title, persona_id = row
|
||||
# 确保 cid 是字符串类型且至少有8个字符,否则使用一个默认值
|
||||
safe_cid = str(cid) if cid else "unknown"
|
||||
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
|
||||
|
||||
conversations.append(
|
||||
{
|
||||
"user_id": user_id or "",
|
||||
"cid": safe_cid,
|
||||
"title": title or f"对话 {display_cid}",
|
||||
"persona_id": persona_id or "",
|
||||
"created_at": created_at or 0,
|
||||
"updated_at": updated_at or 0,
|
||||
}
|
||||
)
|
||||
|
||||
return conversations, total_count
|
||||
|
||||
except Exception as _:
|
||||
# 返回空列表和0,确保即使出错也有有效的返回值
|
||||
return [], 0
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def get_filtered_conversations(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
platforms: List[str] = None,
|
||||
message_types: List[str] = None,
|
||||
search_query: str = None,
|
||||
exclude_ids: List[str] = None,
|
||||
exclude_platforms: List[str] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""获取筛选后的对话列表"""
|
||||
try:
|
||||
c = self.conn.cursor()
|
||||
except sqlite3.ProgrammingError:
|
||||
c = self._get_conn(self.db_path).cursor()
|
||||
|
||||
try:
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
# 平台筛选
|
||||
if platforms and len(platforms) > 0:
|
||||
platform_conditions = []
|
||||
for platform in platforms:
|
||||
platform_conditions.append("user_id LIKE ?")
|
||||
params.append(f"{platform}:%")
|
||||
|
||||
if platform_conditions:
|
||||
where_clauses.append(f"({' OR '.join(platform_conditions)})")
|
||||
|
||||
# 消息类型筛选
|
||||
if message_types and len(message_types) > 0:
|
||||
message_type_conditions = []
|
||||
for msg_type in message_types:
|
||||
message_type_conditions.append("user_id LIKE ?")
|
||||
params.append(f"%:{msg_type}:%")
|
||||
|
||||
if message_type_conditions:
|
||||
where_clauses.append(f"({' OR '.join(message_type_conditions)})")
|
||||
|
||||
# 搜索关键词
|
||||
if search_query:
|
||||
where_clauses.append(
|
||||
"(title LIKE ? OR user_id LIKE ? OR cid LIKE ? OR history LIKE ?)"
|
||||
)
|
||||
search_param = f"%{search_query}%"
|
||||
params.extend([search_param, search_param, search_param, search_param])
|
||||
|
||||
# 排除特定用户ID
|
||||
if exclude_ids and len(exclude_ids) > 0:
|
||||
for exclude_id in exclude_ids:
|
||||
where_clauses.append("user_id NOT LIKE ?")
|
||||
params.append(f"{exclude_id}%")
|
||||
|
||||
# 排除特定平台
|
||||
if exclude_platforms and len(exclude_platforms) > 0:
|
||||
for exclude_platform in exclude_platforms:
|
||||
where_clauses.append("user_id NOT LIKE ?")
|
||||
params.append(f"{exclude_platform}:%")
|
||||
|
||||
# 构建完整的 WHERE 子句
|
||||
where_sql = " WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||
|
||||
# 构建计数查询
|
||||
count_sql = f"SELECT COUNT(*) FROM webchat_conversation{where_sql}"
|
||||
|
||||
# 获取总记录数
|
||||
c.execute(count_sql, params)
|
||||
total_count = c.fetchone()[0]
|
||||
|
||||
# 计算偏移量
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 构建分页数据查询
|
||||
data_sql = f"""
|
||||
SELECT user_id, cid, created_at, updated_at, title, persona_id
|
||||
FROM webchat_conversation
|
||||
{where_sql}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
query_params = params + [page_size, offset]
|
||||
|
||||
# 获取分页数据
|
||||
c.execute(data_sql, query_params)
|
||||
rows = c.fetchall()
|
||||
|
||||
conversations = []
|
||||
|
||||
for row in rows:
|
||||
user_id, cid, created_at, updated_at, title, persona_id = row
|
||||
# 确保 cid 是字符串类型,否则使用一个默认值
|
||||
safe_cid = str(cid) if cid else "unknown"
|
||||
display_cid = safe_cid[:8] if len(safe_cid) >= 8 else safe_cid
|
||||
|
||||
conversations.append(
|
||||
{
|
||||
"user_id": user_id or "",
|
||||
"cid": safe_cid,
|
||||
"title": title or f"对话 {display_cid}",
|
||||
"persona_id": persona_id or "",
|
||||
"created_at": created_at or 0,
|
||||
"updated_at": updated_at or 0,
|
||||
}
|
||||
)
|
||||
|
||||
return conversations, total_count
|
||||
|
||||
except Exception as _:
|
||||
# 返回空列表和0,确保即使出错也有有效的返回值
|
||||
return [], 0
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
@@ -7,6 +7,7 @@ from .log import LogRoute
|
||||
from .static_file import StaticFileRoute
|
||||
from .chat import ChatRoute
|
||||
from .tools import ToolsRoute # 导入新的ToolsRoute
|
||||
from .conversation import ConversationRoute
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"StaticFileRoute",
|
||||
"ChatRoute",
|
||||
"ToolsRoute", # 添加新的ToolsRoute
|
||||
"ConversationRoute",
|
||||
]
|
||||
|
||||
227
astrbot/dashboard/routes/conversation.py
Normal file
227
astrbot/dashboard/routes/conversation.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import traceback
|
||||
import json
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core import logger
|
||||
from quart import request
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
|
||||
|
||||
class ConversationRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
context: RouteContext,
|
||||
db_helper: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/conversation/list": ("GET", self.list_conversations),
|
||||
"/conversation/detail": (
|
||||
"POST",
|
||||
self.get_conv_detail,
|
||||
),
|
||||
"/conversation/update": ("POST", self.upd_conv),
|
||||
"/conversation/delete": ("POST", self.del_conv),
|
||||
"/conversation/update_history": (
|
||||
"POST",
|
||||
self.update_history,
|
||||
),
|
||||
}
|
||||
self.db_helper = db_helper
|
||||
self.register_routes()
|
||||
|
||||
async def list_conversations(self):
|
||||
"""获取对话列表,支持分页、排序和筛选"""
|
||||
try:
|
||||
# 获取分页参数
|
||||
page = request.args.get("page", 1, type=int)
|
||||
page_size = request.args.get("page_size", 20, type=int)
|
||||
|
||||
# 获取筛选参数
|
||||
platforms = request.args.get("platforms", "")
|
||||
message_types = request.args.get("message_types", "")
|
||||
search_query = request.args.get("search", "")
|
||||
exclude_ids = request.args.get("exclude_ids", "")
|
||||
exclude_platforms = request.args.get("exclude_platforms", "")
|
||||
|
||||
# 转换为列表
|
||||
platform_list = platforms.split(",") if platforms else []
|
||||
message_type_list = message_types.split(",") if message_types else []
|
||||
exclude_id_list = exclude_ids.split(",") if exclude_ids else []
|
||||
exclude_platform_list = (
|
||||
exclude_platforms.split(",") if exclude_platforms else []
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"获取对话列表: page={page}, page_size={page_size}, "
|
||||
f"platforms={platform_list}, message_types={message_type_list}, "
|
||||
f"search={search_query}, exclude_ids={exclude_id_list}, "
|
||||
f"exclude_platforms={exclude_platform_list}"
|
||||
)
|
||||
|
||||
# 限制页面大小,防止请求过大数据
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 20
|
||||
if page_size > 100:
|
||||
page_size = 100
|
||||
|
||||
# 使用数据库的分页方法获取会话列表和总数,传入筛选条件
|
||||
try:
|
||||
conversations, total_count = self.db_helper.get_filtered_conversations(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
platforms=platform_list,
|
||||
message_types=message_type_list,
|
||||
search_query=search_query,
|
||||
exclude_ids=exclude_id_list,
|
||||
exclude_platforms=exclude_platform_list,
|
||||
)
|
||||
logger.info(f"获取到 {len(conversations)} 条对话,总数: {total_count}")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库查询出错: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"数据库查询出错: {str(e)}").__dict__
|
||||
|
||||
# 计算总页数
|
||||
total_pages = (
|
||||
(total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
)
|
||||
|
||||
result = {
|
||||
"conversations": conversations,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total_count,
|
||||
"total_pages": total_pages,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"返回对话列表成功: {json.dumps(result, ensure_ascii=False)[:200]}..."
|
||||
)
|
||||
return Response().ok(result).__dict__
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"获取对话列表失败: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(error_msg)
|
||||
return Response().error(f"获取对话列表失败: {str(e)}").__dict__
|
||||
|
||||
async def get_conv_detail(self):
|
||||
"""获取指定对话详情(通过POST请求)"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"user_id": user_id,
|
||||
"cid": cid,
|
||||
"title": conversation.title,
|
||||
"persona_id": conversation.persona_id,
|
||||
"history": conversation.history,
|
||||
"created_at": conversation.created_at,
|
||||
"updated_at": conversation.updated_at,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取对话详情失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"获取对话详情失败: {str(e)}").__dict__
|
||||
|
||||
async def upd_conv(self):
|
||||
"""更新对话信息(标题和角色ID)"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
title = data.get("title")
|
||||
persona_id = data.get("persona_id", "")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
if title is not None:
|
||||
self.db_helper.update_conversation_title(user_id, cid, title)
|
||||
if persona_id is not None:
|
||||
self.db_helper.update_conversation_persona_id(user_id, cid, persona_id)
|
||||
|
||||
return Response().ok({"message": "对话信息更新成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新对话信息失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"更新对话信息失败: {str(e)}").__dict__
|
||||
|
||||
async def del_conv(self):
|
||||
"""删除对话"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
self.db_helper.delete_conversation(user_id, cid)
|
||||
|
||||
return Response().ok({"message": "对话删除成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除对话失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"删除对话失败: {str(e)}").__dict__
|
||||
|
||||
async def update_history(self):
|
||||
"""更新对话历史内容"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
history = data.get("history")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
|
||||
if history is None:
|
||||
return Response().error("缺少必要参数: history").__dict__
|
||||
|
||||
# 历史记录必须是合法的 JSON 字符串
|
||||
try:
|
||||
if isinstance(history, list):
|
||||
history = json.dumps(history)
|
||||
else:
|
||||
# 验证是否为有效的 JSON 字符串
|
||||
json.loads(history)
|
||||
except json.JSONDecodeError:
|
||||
return (
|
||||
Response().error("history 必须是有效的 JSON 字符串或数组").__dict__
|
||||
)
|
||||
|
||||
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
|
||||
if not conversation:
|
||||
return Response().error("对话不存在").__dict__
|
||||
|
||||
self.db_helper.update_conversation(user_id, cid, history)
|
||||
|
||||
return Response().ok({"message": "对话历史更新成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新对话历史失败: {str(e)}\n{traceback.format_exc()}")
|
||||
return Response().error(f"更新对话历史失败: {str(e)}").__dict__
|
||||
@@ -51,6 +51,7 @@ class AstrBotDashboard:
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||||
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title style="font-size: 15px;">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-title style="font-size: 14px;">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
|
||||
{{ item.subCaption }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
@@ -55,6 +55,11 @@ const sidebarItem: menu[] = [
|
||||
icon: 'mdi-chat',
|
||||
to: '/chat'
|
||||
},
|
||||
{
|
||||
title: '对话数据库',
|
||||
icon: 'mdi-database',
|
||||
to: '/conversation'
|
||||
},
|
||||
{
|
||||
title: '控制台',
|
||||
icon: 'mdi-console',
|
||||
|
||||
@@ -46,6 +46,11 @@ const MainRoutes = {
|
||||
path: '/dashboard/default',
|
||||
component: () => import('@/views/dashboards/default/DefaultDashboard.vue')
|
||||
},
|
||||
{
|
||||
name: 'Conversation',
|
||||
path: '/conversation',
|
||||
component: () => import('@/views/ConversationPage.vue')
|
||||
},
|
||||
{
|
||||
name: 'Console',
|
||||
path: '/console',
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
color: rgb(var(--v-theme-secondary));
|
||||
}
|
||||
}
|
||||
.v-list-item--density-default.v-list-item--one-line {
|
||||
min-height: 42px;
|
||||
}
|
||||
.leftPadding {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
1097
dashboard/src/views/ConversationPage.vue
Normal file
1097
dashboard/src/views/ConversationPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user