feat: 添加消息历史持久化和可视化查看功能
- 新增 messageStore.js 消息存储模块,支持自动保存所有收发消息 - 修改 main.js,在消息转发时自动记录到本地存储 - 修改 preload.js,暴露消息管理 IPC API - 修改 App.js,添加消息历史查看界面 - 统计信息面板(总数/接收/发送/会话数) - 会话列表和消息详情 - 搜索、过滤、分页功能 - 导出 JSON 和清空历史 - 新增完整文档(MESSAGE_HISTORY.md 等) - 新增测试脚本 test-message-history.js 版本:v1.0.1
This commit is contained in:
@@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const { WSClient } = require('@wecom/aibot-node-sdk');
|
||||
const WebSocket = require('ws');
|
||||
const crypto = require('crypto');
|
||||
const messageStore = require('./messageStore');
|
||||
|
||||
// 初始化配置存储
|
||||
const store = new Store({
|
||||
@@ -221,6 +222,24 @@ class WeComConnection {
|
||||
console.log('[OpenClaw] Sending via chat.send:', sessionKey);
|
||||
openclawConnection.send(JSON.stringify(chatSendMessage));
|
||||
console.log(`[Forward] WeCom -> OpenClaw: ${messageId}`);
|
||||
|
||||
// 记录消息到存储
|
||||
messageStore.saveMessage({
|
||||
direction: 'inbound',
|
||||
source: 'wecom',
|
||||
sessionId: sessionKey,
|
||||
chatId: chatId,
|
||||
senderId: body.from?.userid || '',
|
||||
senderName: body.from?.name || body.from?.userid || '',
|
||||
messageType: body.image ? 'image' : body.file ? 'file' : body.voice ? 'voice' : 'text',
|
||||
content: text,
|
||||
attachments: attachments,
|
||||
metadata: {
|
||||
wecomMsgId: messageId,
|
||||
chatType: chatType,
|
||||
reqId: reqId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
@@ -467,6 +486,24 @@ class OpenClawConnection {
|
||||
try {
|
||||
await wecomConn.sendMessage(chatId, text, finish, streamId);
|
||||
console.log(`[Forward] OpenClaw -> WeCom: ${chatId}`);
|
||||
|
||||
// 记录消息到存储
|
||||
const sessionKey = `${botId}:${chatId}`;
|
||||
messageStore.saveMessage({
|
||||
direction: 'outbound',
|
||||
source: 'openclaw',
|
||||
sessionId: sessionKey,
|
||||
chatId: chatId,
|
||||
senderId: 'openclaw',
|
||||
senderName: 'OpenClaw AI',
|
||||
messageType: 'text',
|
||||
content: text,
|
||||
metadata: {
|
||||
streamId: streamId,
|
||||
finish: finish,
|
||||
botId: botId
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Forward] Error:', error);
|
||||
}
|
||||
@@ -646,6 +683,44 @@ function setupIpcHandlers() {
|
||||
return { success: false, message: `发送失败:${error.message}` };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ 消息存储相关 IPC 处理器 ============
|
||||
|
||||
// 获取消息列表
|
||||
ipcMain.handle('get-messages', (event, options = {}) => {
|
||||
return messageStore.getMessages(options);
|
||||
});
|
||||
|
||||
// 获取会话列表
|
||||
ipcMain.handle('get-sessions', () => {
|
||||
return messageStore.getSessions();
|
||||
});
|
||||
|
||||
// 搜索消息
|
||||
ipcMain.handle('search-messages', (event, query, options = {}) => {
|
||||
return messageStore.searchMessages(query, options);
|
||||
});
|
||||
|
||||
// 获取统计数据
|
||||
ipcMain.handle('get-message-stats', () => {
|
||||
return messageStore.getStats();
|
||||
});
|
||||
|
||||
// 标记消息为已读
|
||||
ipcMain.handle('mark-messages-read', (event, messageIds) => {
|
||||
return messageStore.markAsRead(messageIds);
|
||||
});
|
||||
|
||||
// 导出消息
|
||||
ipcMain.handle('export-messages', (event, options = {}) => {
|
||||
return messageStore.exportMessages(options);
|
||||
});
|
||||
|
||||
// 清空消息
|
||||
ipcMain.handle('clear-messages', (event, options = {}) => {
|
||||
messageStore.clear(options);
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
||||
264
electron/messageStore.js
Normal file
264
electron/messageStore.js
Normal file
@@ -0,0 +1,264 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { app } = require('electron');
|
||||
|
||||
class MessageStore {
|
||||
constructor() {
|
||||
this.dataDir = path.join(app.getPath('userData'), 'messages');
|
||||
this.messagesFile = path.join(this.dataDir, 'messages.json');
|
||||
this.messages = [];
|
||||
this.maxMessages = 10000; // 最多保留 10000 条消息
|
||||
|
||||
// 初始化存储
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 创建目录
|
||||
if (!fs.existsSync(this.dataDir)) {
|
||||
fs.mkdirSync(this.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 加载现有消息
|
||||
if (fs.existsSync(this.messagesFile)) {
|
||||
try {
|
||||
const data = fs.readFileSync(this.messagesFile, 'utf-8');
|
||||
this.messages = JSON.parse(data);
|
||||
console.log(`[MessageStore] 加载了 ${this.messages.length} 条历史消息`);
|
||||
} catch (error) {
|
||||
console.error('[MessageStore] 加载消息失败:', error.message);
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存消息
|
||||
saveMessage(message) {
|
||||
const msg = {
|
||||
id: message.id || `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: message.timestamp || new Date().toISOString(),
|
||||
direction: message.direction, // 'inbound' | 'outbound'
|
||||
source: message.source, // 'wecom' | 'openclaw'
|
||||
sessionId: message.sessionId || '',
|
||||
chatId: message.chatId || '',
|
||||
senderId: message.senderId || '',
|
||||
senderName: message.senderName || '',
|
||||
messageType: message.messageType || 'text', // 'text' | 'image' | 'file' | 'voice'
|
||||
content: message.content || '',
|
||||
attachments: message.attachments || [],
|
||||
metadata: message.metadata || {},
|
||||
delivered: message.delivered !== false,
|
||||
read: message.read || false
|
||||
};
|
||||
|
||||
this.messages.push(msg);
|
||||
|
||||
// 限制消息数量
|
||||
if (this.messages.length > this.maxMessages) {
|
||||
this.messages = this.messages.slice(-this.maxMessages);
|
||||
}
|
||||
|
||||
// 异步保存到文件
|
||||
this.persist();
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
// 批量保存消息
|
||||
saveMessages(messages) {
|
||||
messages.forEach(msg => this.saveMessage(msg));
|
||||
}
|
||||
|
||||
// 持久化到文件
|
||||
persist() {
|
||||
// 使用异步写入,避免阻塞
|
||||
setImmediate(() => {
|
||||
try {
|
||||
const tempFile = this.messagesFile + '.tmp';
|
||||
fs.writeFileSync(tempFile, JSON.stringify(this.messages, null, 2), 'utf-8');
|
||||
fs.renameSync(tempFile, this.messagesFile);
|
||||
console.log(`[MessageStore] 已保存 ${this.messages.length} 条消息`);
|
||||
} catch (error) {
|
||||
console.error('[MessageStore] 保存消息失败:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取消息列表
|
||||
getMessages(options = {}) {
|
||||
let filtered = [...this.messages];
|
||||
|
||||
// 按会话过滤
|
||||
if (options.sessionId) {
|
||||
filtered = filtered.filter(m => m.sessionId === options.sessionId);
|
||||
}
|
||||
|
||||
// 按方向过滤
|
||||
if (options.direction) {
|
||||
filtered = filtered.filter(m => m.direction === options.direction);
|
||||
}
|
||||
|
||||
// 按来源过滤
|
||||
if (options.source) {
|
||||
filtered = filtered.filter(m => m.source === options.source);
|
||||
}
|
||||
|
||||
// 按时间范围过滤
|
||||
if (options.startTime) {
|
||||
filtered = filtered.filter(m => new Date(m.timestamp) >= new Date(options.startTime));
|
||||
}
|
||||
if (options.endTime) {
|
||||
filtered = filtered.filter(m => new Date(m.timestamp) <= new Date(options.endTime));
|
||||
}
|
||||
|
||||
// 分页
|
||||
const limit = options.limit || 100;
|
||||
const offset = options.offset || 0;
|
||||
const paginated = filtered.slice(offset, offset + limit);
|
||||
|
||||
// 按时间倒序(最新的在前)
|
||||
paginated.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
return {
|
||||
messages: paginated,
|
||||
total: filtered.length,
|
||||
limit,
|
||||
offset
|
||||
};
|
||||
}
|
||||
|
||||
// 获取会话列表
|
||||
getSessions() {
|
||||
const sessionMap = new Map();
|
||||
|
||||
this.messages.forEach(msg => {
|
||||
if (!msg.sessionId) return;
|
||||
|
||||
if (!sessionMap.has(msg.sessionId)) {
|
||||
sessionMap.set(msg.sessionId, {
|
||||
sessionId: msg.sessionId,
|
||||
chatId: msg.chatId,
|
||||
lastMessageTime: msg.timestamp,
|
||||
messageCount: 0,
|
||||
unreadCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionMap.get(msg.sessionId);
|
||||
session.messageCount++;
|
||||
|
||||
if (!msg.read) {
|
||||
session.unreadCount++;
|
||||
}
|
||||
|
||||
if (new Date(msg.timestamp) > new Date(session.lastMessageTime)) {
|
||||
session.lastMessageTime = msg.timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
// 按最后消息时间排序
|
||||
return Array.from(sessionMap.values())
|
||||
.sort((a, b) => new Date(b.lastMessageTime) - new Date(a.lastMessageTime));
|
||||
}
|
||||
|
||||
// 标记消息为已读
|
||||
markAsRead(messageIds) {
|
||||
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
|
||||
let updated = 0;
|
||||
|
||||
this.messages.forEach(msg => {
|
||||
if (ids.includes(msg.id) && !msg.read) {
|
||||
msg.read = true;
|
||||
updated++;
|
||||
}
|
||||
});
|
||||
|
||||
if (updated > 0) {
|
||||
this.persist();
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// 搜索消息
|
||||
searchMessages(query, options = {}) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const filtered = this.messages.filter(msg => {
|
||||
// 搜索内容
|
||||
if (msg.content && msg.content.toLowerCase().includes(lowerQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 搜索发送者
|
||||
if (msg.senderName && msg.senderName.toLowerCase().includes(lowerQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 搜索会话 ID
|
||||
if (msg.sessionId && msg.sessionId.toLowerCase().includes(lowerQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// 按相关性排序(内容匹配优先)
|
||||
filtered.sort((a, b) => {
|
||||
const aScore = (a.content.toLowerCase().includes(lowerQuery) ? 2 : 0) +
|
||||
(a.senderName.toLowerCase().includes(lowerQuery) ? 1 : 0);
|
||||
const bScore = (b.content.toLowerCase().includes(lowerQuery) ? 2 : 0) +
|
||||
(b.senderName.toLowerCase().includes(lowerQuery) ? 1 : 0);
|
||||
return bScore - aScore;
|
||||
});
|
||||
|
||||
const limit = options.limit || 50;
|
||||
const offset = options.offset || 0;
|
||||
|
||||
return {
|
||||
messages: filtered.slice(offset, offset + limit),
|
||||
total: filtered.length,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
// 导出消息
|
||||
exportMessages(options = {}) {
|
||||
const result = this.getMessages(options);
|
||||
return JSON.stringify(result.messages, null, 2);
|
||||
}
|
||||
|
||||
// 清空消息
|
||||
clear(options = {}) {
|
||||
if (options.sessionId) {
|
||||
this.messages = this.messages.filter(m => m.sessionId !== options.sessionId);
|
||||
} else {
|
||||
this.messages = [];
|
||||
}
|
||||
this.persist();
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
getStats() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
return {
|
||||
total: this.messages.length,
|
||||
today: this.messages.filter(m => new Date(m.timestamp) >= today).length,
|
||||
yesterday: this.messages.filter(m =>
|
||||
new Date(m.timestamp) >= yesterday && new Date(m.timestamp) < today
|
||||
).length,
|
||||
inbound: this.messages.filter(m => m.direction === 'inbound').length,
|
||||
outbound: this.messages.filter(m => m.direction === 'outbound').length,
|
||||
wecom: this.messages.filter(m => m.source === 'wecom').length,
|
||||
openclaw: this.messages.filter(m => m.source === 'openclaw').length,
|
||||
sessions: this.getSessions().length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
module.exports = new MessageStore();
|
||||
@@ -44,5 +44,28 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 移除事件监听
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
},
|
||||
|
||||
// ============ 消息存储相关 API ============
|
||||
|
||||
// 获取消息列表
|
||||
getMessages: (options) => ipcRenderer.invoke('get-messages', options),
|
||||
|
||||
// 获取会话列表
|
||||
getSessions: () => ipcRenderer.invoke('get-sessions'),
|
||||
|
||||
// 搜索消息
|
||||
searchMessages: (query, options) => ipcRenderer.invoke('search-messages', query, options),
|
||||
|
||||
// 获取统计数据
|
||||
getMessageStats: () => ipcRenderer.invoke('get-message-stats'),
|
||||
|
||||
// 标记消息为已读
|
||||
markMessagesRead: (messageIds) => ipcRenderer.invoke('mark-messages-read', messageIds),
|
||||
|
||||
// 导出消息
|
||||
exportMessages: (options) => ipcRenderer.invoke('export-messages', options),
|
||||
|
||||
// 清空消息
|
||||
clearMessages: (options) => ipcRenderer.invoke('clear-messages', options)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user