diff --git a/astrbot/dashboard/routes/conversation.py b/astrbot/dashboard/routes/conversation.py
index d19fdf793..513d3603f 100644
--- a/astrbot/dashboard/routes/conversation.py
+++ b/astrbot/dashboard/routes/conversation.py
@@ -1,7 +1,9 @@
import json
import traceback
+from datetime import datetime
+from io import BytesIO
-from quart import request
+from quart import request, send_file
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
"POST",
self.update_history,
),
+ "/conversation/export": ("POST", self.export_conversations),
}
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
except Exception as e:
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {e!s}").__dict__
+
+ async def export_conversations(self):
+ """批量导出对话为 JSONL 格式"""
+ try:
+ data = await request.get_json()
+ conversations_to_export = data.get("conversations", [])
+
+ if not conversations_to_export:
+ return Response().error("导出列表不能为空").__dict__
+
+ # 收集所有对话的内容
+ jsonl_lines = []
+ exported_count = 0
+ failed_items = []
+
+ for conv_info in conversations_to_export:
+ user_id = conv_info.get("user_id")
+ cid = conv_info.get("cid")
+
+ if not user_id or not cid:
+ failed_items.append(
+ f"user_id:{user_id}, cid:{cid} - 缺少必要参数",
+ )
+ continue
+
+ try:
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+
+ if not conversation:
+ failed_items.append(
+ f"user_id:{user_id}, cid:{cid} - 对话不存在"
+ )
+ continue
+
+ # 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1)
+ content = json.loads(conversation.history)
+
+ # 创建导出记录
+ export_record = {
+ "cid": cid,
+ "user_id": user_id,
+ "platform_id": conversation.platform_id,
+ "title": conversation.title,
+ "persona_id": conversation.persona_id,
+ "created_at": conversation.created_at,
+ "updated_at": conversation.updated_at,
+ "content": content,
+ }
+
+ # 将记录转换为 JSON 字符串并添加到 JSONL
+ jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
+ exported_count += 1
+
+ except Exception as e:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}")
+ logger.error(
+ f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}"
+ )
+
+ if exported_count == 0:
+ return Response().error("没有成功导出任何对话").__dict__
+
+ # 创建 JSONL 内容
+ jsonl_content = "\n".join(jsonl_lines)
+
+ # 创建一个内存文件对象
+ file_obj = BytesIO(jsonl_content.encode("utf-8"))
+ file_obj.seek(0)
+
+ # 生成文件名
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"astrbot_conversations_export_{timestamp}.jsonl"
+
+ # 返回文件流
+ return await send_file(
+ file_obj,
+ mimetype="application/jsonl",
+ as_attachment=True,
+ attachment_filename=filename,
+ )
+
+ except Exception as e:
+ logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}")
+ return Response().error(f"批量导出对话失败: {e!s}").__dict__
diff --git a/dashboard/src/i18n/locales/en-US/features/conversation.json b/dashboard/src/i18n/locales/en-US/features/conversation.json
index 2c427274c..3e8cb7128 100644
--- a/dashboard/src/i18n/locales/en-US/features/conversation.json
+++ b/dashboard/src/i18n/locales/en-US/features/conversation.json
@@ -13,7 +13,8 @@
"refresh": "Refresh"
},
"batch": {
- "deleteSelected": "Delete Selected ({count})"
+ "deleteSelected": "Delete Selected ({count})",
+ "exportSelected": "Export Selected ({count})"
},
"pagination": {
"itemsPerPage": "Items per page",
@@ -76,7 +77,8 @@
"message": "Are you sure you want to delete the selected {count} conversations? This action cannot be undone, please proceed with caution!",
"andMore": "and {count} more",
"cancel": "Cancel",
- "confirm": "Batch Delete"
+ "confirm": "Batch Delete",
+ "warning": "Warning: This action cannot be undone!"
}
},
"messages": {
@@ -92,6 +94,9 @@
"noItemSelected": "Please select conversations to delete first",
"batchDeleteSuccess": "Successfully deleted {count} conversations",
"batchDeleteError": "Batch delete failed",
- "batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed"
+ "batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed",
+ "exportSuccess": "Export successful",
+ "exportError": "Export failed",
+ "noItemSelectedForExport": "Please select conversations to export first"
}
}
\ No newline at end of file
diff --git a/dashboard/src/i18n/locales/zh-CN/features/conversation.json b/dashboard/src/i18n/locales/zh-CN/features/conversation.json
index cc20bf640..8a5ca6eb5 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/conversation.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/conversation.json
@@ -13,7 +13,8 @@
"refresh": "刷新"
},
"batch": {
- "deleteSelected": "删除选中 ({count})"
+ "deleteSelected": "删除选中 ({count})",
+ "exportSelected": "导出选中 ({count})"
},
"pagination": {
"itemsPerPage": "每页",
@@ -76,7 +77,8 @@
"message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!",
"andMore": "等 {count} 个",
"cancel": "取消",
- "confirm": "批量删除"
+ "confirm": "批量删除",
+ "warning": "警告:此操作不可撤销!"
}
},
"messages": {
@@ -92,6 +94,9 @@
"noItemSelected": "请先选择要删除的对话",
"batchDeleteSuccess": "成功删除 {count} 个对话",
"batchDeleteError": "批量删除失败",
- "batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个"
+ "batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个",
+ "exportSuccess": "导出成功",
+ "exportError": "导出失败",
+ "noItemSelectedForExport": "请先选择要导出的对话"
}
}
\ No newline at end of file
diff --git a/dashboard/src/views/ConversationPage.vue b/dashboard/src/views/ConversationPage.vue
index d6ec28100..8a4debd5e 100644
--- a/dashboard/src/views/ConversationPage.vue
+++ b/dashboard/src/views/ConversationPage.vue
@@ -40,6 +40,17 @@
:loading="loading" size="small" class="mr-2">
{{ tm('history.refresh') }}
+
+ {{ tm('batch.exportSelected', { count: selectedItems.length }) }}
+
({
+ user_id: item.user_id,
+ cid: item.cid
+ }));
+
+ const response = await axios.post('/api/conversation/export', {
+ conversations: conversations
+ }, {
+ responseType: 'blob' // 重要:告诉 axios 响应是一个 blob
+ });
+
+ // 创建一个下载链接
+ const url = window.URL.createObjectURL(response.data);
+ const link = document.createElement('a');
+ link.href = url;
+
+ // 生成文件名(使用时间戳)
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
+ const filename = `conversations_export_${timestamp}.jsonl`;
+
+ link.setAttribute('download', filename);
+ document.body.appendChild(link);
+ link.click();
+
+ // 清理
+ link.remove();
+ window.URL.revokeObjectURL(url);
+
+ this.showSuccessMessage(this.tm('messages.exportSuccess'));
+ } catch (error) {
+ console.error(this.tm('messages.exportError'), error);
+ this.showErrorMessage(error.response?.data?.message || error.message || this.tm('messages.exportError'));
+ } finally {
+ this.loading = false;
+ }
+ },
+
// 格式化时间戳
formatTimestamp(timestamp) {
if (!timestamp) return this.tm('status.unknown');