Implement file reference count cleanup with deleteIfZero support

- Update DexieMessageDataSource to delete files when count reaches zero and deleteIfZero is true
- Add deleteIfZero parameter to MessageDataSource interface and all implementations
- Modify updateFileCountV2 thunk to pass deleteIfZero parameter through DbService
This commit is contained in:
suyao 2025-09-22 23:12:08 +08:00
parent c872707791
commit 7fdae0173c
No known key found for this signature in database
8 changed files with 38 additions and 585 deletions

View File

@ -1,101 +0,0 @@
# Agent Session 消息持久化问题修复
## 问题描述
在Agent会话中发送消息后如果切换到其他会话再切回来消息会丢失。错误信息
```
[MessageThunk] persistAgentExchange: missing user or assistant message entity
```
## 问题原因
1. **原始实现问题**
- `saveMessageAndBlocksToDB` 对Agent会话直接返回不保存消息
- 消息只存在于Redux state中
2. **V2实现问题**
- `AgentMessageDataSource.appendMessage` 是空操作
- 期望通过 `persistExchange` 在响应完成后保存
3. **时序问题**
- `persistAgentExchange` 在Agent响应完成后才被调用
- 如果用户在响应过程中切换会话Redux state被清空
- `persistAgentExchange` 找不到消息实体,保存失败
## 解决方案
修改 `AgentMessageDataSource.appendMessage` 方法,让它立即保存消息到后端,而不是等待响应完成。
### 修改内容
```typescript
// src/renderer/src/services/db/AgentMessageDataSource.ts
async appendMessage(topicId: string, message: Message, blocks: MessageBlock[]): Promise<void> {
// 立即保存消息不等待persistExchange
const sessionId = extractSessionId(topicId)
const payload: AgentPersistedMessage = {
message,
blocks
}
// 通过IPC立即保存单个消息
await window.electron.ipcRenderer.invoke(IpcChannel.AgentMessage_PersistExchange, {
sessionId,
agentSessionId: '',
...(message.role === 'user'
? { user: { payload } }
: { assistant: { payload } }
)
})
}
```
## 影响分析
### 优点
1. 消息立即持久化,不会因切换会话而丢失
2. 即使Agent响应失败用户消息也已保存
3. 提高了数据安全性
### 潜在问题
1. **可能的重复保存**
- `appendMessage` 保存一次
- `persistAgentExchange` 可能再次保存
- 需要后端处理重复消息通过messageId去重
2. **性能考虑**
- 每条消息都触发IPC调用
- 可能增加延迟
## 测试验证
### 测试步骤
1. 启用V2功能
2. 创建Agent会话
3. 发送消息
4. 在Agent响应过程中立即切换到其他会话
5. 切回Agent会话
6. **期望结果**:消息应该正确显示,不会丢失
### 测试场景
- ✅ 正常发送和接收
- ✅ 响应中切换会话
- ✅ 快速连续发送多条消息
- ✅ 网络中断恢复
## 后续优化建议
1. **批量保存**
- 考虑缓存多条消息后批量保存
- 减少IPC调用次数
2. **去重机制**
- 后端通过messageId去重
- 避免重复存储
3. **错误处理**
- 添加重试机制
- 失败时的降级策略
## 回滚方案
如果修复引起新问题:
1. 恢复 `AgentMessageDataSource.appendMessage` 为原始空操作
2. 考虑其他解决方案如在切换会话前强制调用persistExchange

View File

@ -1,247 +0,0 @@
# Agent Session 消息状态持久化方案
## 问题分析
### 当前流程
1. **发送消息时**
- 创建助手消息,状态为 `PENDING`
- 通过 `appendMessage` 立即保存到后端包含pending状态
2. **切换会话后重新加载**
- 从后端加载消息
- 但状态可能丢失或被覆盖
### 根本问题
后端可能没有正确保存或返回消息的 `status` 字段。
## 解决方案:确保状态正确持久化
### 方案A修改 AgentMessageDataSource前端方案
```typescript
// src/renderer/src/services/db/AgentMessageDataSource.ts
// 1. 保存消息时确保状态被保存
async appendMessage(topicId: string, message: Message, blocks: MessageBlock[]): Promise<void> {
const sessionId = extractSessionId(topicId)
const payload: AgentPersistedMessage = {
message: {
...message,
// 明确保存状态
status: message.status || AssistantMessageStatus.PENDING
},
blocks
}
await window.electron.ipcRenderer.invoke(IpcChannel.AgentMessage_PersistExchange, {
sessionId,
agentSessionId: '',
...(message.role === 'user'
? { user: { payload } }
: { assistant: { payload } }
)
})
}
// 2. 加载消息时恢复流式状态
async fetchMessages(topicId: string): Promise<{ messages: Message[], blocks: MessageBlock[] }> {
const sessionId = extractSessionId(topicId)
const historicalMessages = await window.electron.ipcRenderer.invoke(
IpcChannel.AgentMessage_GetHistory,
{ sessionId }
)
const messages: Message[] = []
const blocks: MessageBlock[] = []
let hasStreamingMessage = false
for (const persistedMsg of historicalMessages) {
if (persistedMsg?.message) {
const message = persistedMsg.message
// 检查是否有未完成的消息
if (message.status === 'pending' || message.status === 'processing') {
hasStreamingMessage = true
// 如果消息创建时间超过5分钟标记为错误
const messageAge = Date.now() - new Date(message.createdAt).getTime()
if (messageAge > 5 * 60 * 1000) {
message.status = 'error'
}
}
messages.push(message)
if (persistedMsg.blocks) {
blocks.push(...persistedMsg.blocks)
}
}
}
// 如果有流式消息恢复loading状态
if (hasStreamingMessage) {
// 这里需要dispatch action可能需要通过回调或其他方式
store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true }))
}
return { messages, blocks }
}
```
### 方案B后端修改更彻底的方案
需要确保后端:
1. **sessionMessageRepository.ts** 正确保存消息状态
```typescript
// src/main/services/agents/database/sessionMessageRepository.ts
async persistExchange(params: PersistExchangeParams): Promise<void> {
// 保存时确保状态字段被正确存储
if (params.user) {
await this.saveMessage({
...params.user.payload.message,
status: params.user.payload.message.status // 确保状态被保存
})
}
if (params.assistant) {
await this.saveMessage({
...params.assistant.payload.message,
status: params.assistant.payload.message.status // 确保状态被保存
})
}
}
async getHistory(sessionId: string): Promise<AgentPersistedMessage[]> {
// 返回时确保状态字段被包含
const messages = await this.db.getMessages(sessionId)
return messages.map(msg => ({
message: {
...msg,
status: msg.status // 确保状态被返回
},
blocks: msg.blocks
}))
}
```
2. **添加会话级别的流式状态**
```typescript
interface AgentSession {
id: string
// ... 其他字段
streamingMessageId?: string // 当前正在流式的消息ID
streamingStartTime?: number // 流式开始时间
}
// 开始流式时更新
async startStreaming(sessionId: string, messageId: string) {
await this.updateSession(sessionId, {
streamingMessageId: messageId,
streamingStartTime: Date.now()
})
}
// 结束流式时清除
async stopStreaming(sessionId: string) {
await this.updateSession(sessionId, {
streamingMessageId: null,
streamingStartTime: null
})
}
```
### 方案C混合方案推荐
1. **前端立即保存状态**(已实现)
2. **后端确保状态持久化**
3. **加载时智能恢复状态**
```typescript
// AgentMessageDataSource.ts
async fetchMessages(topicId: string): Promise<{ messages: Message[], blocks: MessageBlock[] }> {
const sessionId = extractSessionId(topicId)
const historicalMessages = await window.electron.ipcRenderer.invoke(
IpcChannel.AgentMessage_GetHistory,
{ sessionId }
)
const messages: Message[] = []
const blocks: MessageBlock[] = []
for (const persistedMsg of historicalMessages) {
if (persistedMsg?.message) {
const message = { ...persistedMsg.message }
// 智能恢复状态
if (message.status === 'pending' || message.status === 'processing') {
// 检查消息年龄
const age = Date.now() - new Date(message.createdAt).getTime()
if (age > 5 * 60 * 1000) {
// 超过5分钟标记为错误
message.status = 'error'
} else if (age > 30 * 1000 && message.blocks?.length > 0) {
// 超过30秒且有内容可能已完成
message.status = 'success'
}
// 否则保持原状态让UI显示暂停按钮
}
messages.push(message)
if (persistedMsg.blocks) {
blocks.push(...persistedMsg.blocks)
}
}
}
return { messages, blocks }
}
```
## 实施步骤
### 步骤1验证后端是否保存状态
1. 在 `appendMessage` 中添加日志,确认状态被发送
2. 检查后端数据库,确认状态被保存
3. 在 `fetchMessages` 中添加日志,确认状态被返回
### 步骤2修复状态持久化
1. 如果后端没有保存状态,修改后端代码
2. 如果后端保存了但没返回,修改返回逻辑
### 步骤3添加状态恢复逻辑
1. 在 `fetchMessages` 中智能恢复状态
2. 对于未完成的消息,根据时间判断是否需要标记为错误
### 步骤4恢复loading状态
1. 如果有pending/processing消息设置loading为true
2. 让UI正确显示暂停按钮
## 测试验证
1. **正常流程**
- 发送消息
- 观察pending状态
- 响应完成后状态变为success
2. **切换会话**
- 发送消息开始响应
- 立即切换会话
- 切回来pending状态应该保持
- 暂停按钮应该显示
3. **页面刷新**
- 响应过程中刷新
- 重新加载后状态应该合理pending或error
4. **超时处理**
- 模拟长时间pending
- 验证超时后自动标记为error
## 优势
- 符合现有架构,数据统一持久化
- 状态与消息一起保存,数据一致性好
- 页面刷新也能恢复
- 不需要额外的状态管理器

View File

@ -1,212 +0,0 @@
# V2 Database Service 手动测试用例
## 准备工作
```javascript
// 1. 打开浏览器控制台启用V2功能
localStorage.setItem('featureFlags', JSON.stringify({ USE_UNIFIED_DB_SERVICE: true }))
location.reload()
// 2. 确认功能已启用
JSON.parse(localStorage.getItem('featureFlags') || '{}')
// 应该看到: { USE_UNIFIED_DB_SERVICE: true }
```
## 测试场景一:基础聊天功能 ✅
### 1.1 消息发送与保存
**测试功能**: `saveMessageAndBlocksToDBV2`, `updateBlocksV2`
1. 创建新的聊天会话
2. 发送消息:"你好请介绍一下React Hooks的使用"
3. 等待助手回复完成
4. 刷新页面
5. **验证**: 消息应该被正确保存并重新加载
### 1.2 消息加载(已测试稳定)
**测试功能**: `loadTopicMessagesThunkV2`
1. 切换到其他会话
2. 再切换回刚才的会话
3. **验证**: 消息应该立即加载,无需等待
### 1.3 实时流式更新
**测试功能**: `updateSingleBlockV2` (throttled updates)
1. 发送一个需要较长回复的问题:"请详细解释JavaScript的事件循环机制"
2. 观察助手回复时的流式更新
3. **验证**: 文字应该平滑流式显示,没有卡顿或丢失
## 测试场景二:消息编辑与删除 🗑️
### 2.1 删除单条消息
**测试功能**: `deleteMessageFromDBV2`
1. 在现有会话中,右键点击任意一条消息
2. 选择"删除"
3. 刷新页面
4. **验证**: 被删除的消息不应再出现
### 2.2 删除消息组(用户问题+助手回答)
**测试功能**: `deleteMessagesFromDBV2`
1. 找到一组问答(用户提问+助手回答)
2. 删除整组对话
3. **验证**: 用户消息和对应的助手回答都被删除
### 2.3 清空会话
**测试功能**: `clearMessagesFromDBV2`
1. 在一个有多条消息的会话中
2. 使用"清空会话"功能
3. 刷新页面
4. **验证**: 会话应该为空,但会话本身还存在
## 测试场景三:文件和图片处理 📎
### 3.1 上传图片
**测试功能**: `saveMessageAndBlocksToDBV2`, `updateFileCountV2`
1. 在输入框中上传一张图片
2. 添加文字:"这张图片是什么内容?"
3. 发送消息
4. 刷新页面
5. **验证**: 图片应该正确显示,文件引用计数正确
### 3.2 上传文件
**测试功能**: `bulkAddBlocksV2`
1. 上传一个文本文件或PDF
2. 发送消息询问文件内容
3. **验证**: 文件应该被正确处理和显示
### 3.3 复制带图片的消息到新会话
**测试功能**: `bulkAddBlocksV2`, `updateFileCountV2`
1. 选择包含图片的消息
2. 复制到新的会话
3. **验证**: 图片在新会话中正确显示,文件引用计数增加
## 测试场景四Agent Session 功能 🤖
### 4.1 Agent会话消息加载
**测试功能**: `loadTopicMessagesThunkV2` (agent-session分支)
1. 创建或打开一个Agent会话
2. 发送消息给Agent
3. 切换到其他会话再切回
4. **验证**: Agent会话消息正确加载
### 4.2 Agent会话消息持久化 🔥 (已修复)
**测试功能**: `saveMessageAndBlocksToDBV2``AgentMessageDataSource.appendMessage`
1. 在Agent会话中发送消息
2. **立即切换到其他会话**(不等待响应完成)
3. 切回Agent会话
4. **验证**: 用户消息应该已保存并显示
5. 等待Agent响应完成
6. 刷新页面
7. **验证**: 完整对话正确保存
### 4.3 Agent会话清空应该无操作
**测试功能**: `clearMessagesFromDBV2` (agent no-op)
1. 尝试清空Agent会话
2. **验证**: 操作应该被正确处理(可能显示不支持或静默处理)
## 测试场景五:高级功能 🚀
### 5.1 消息重新生成
**测试功能**: `updateMessageV2`, `updateBlocksV2`
1. 选择一条助手回复
2. 点击"重新生成"
3. **验证**: 原消息被重置,新回复正常生成
### 5.2 消息分支
**测试功能**: `saveMessageAndBlocksToDBV2`
1. 选择一条用户消息
2. 创建分支并输入不同的问题
3. **验证**: 分支正确创建,两个分支独立存在
### 5.3 翻译功能
**测试功能**: `updateSingleBlockV2`
1. 选择一条消息
2. 点击翻译按钮
3. **验证**: 翻译块正确创建和更新
### 5.4 多模型响应
**测试功能**: `saveMessageAndBlocksToDBV2`, `updateBlocksV2`
1. 启用多模型功能
2. 发送一个问题
3. **验证**: 多个模型的响应都正确保存
## 测试场景六:并发和性能 ⚡
### 6.1 快速切换会话
**测试功能**: `loadTopicMessagesThunkV2`
1. 快速在多个会话间切换
2. **验证**: 消息加载无错误,无内存泄漏
### 6.2 大量消息处理
**测试功能**: 所有V2函数
1. 在一个会话中累积50+条消息
2. 执行各种操作(删除、编辑、刷新)
3. **验证**: 性能无明显下降
### 6.3 同时操作
1. 在流式回复过程中切换会话
2. 在文件上传过程中发送新消息
3. **验证**: 操作不冲突,数据一致
## 测试场景七:错误处理 ⚠️
### 7.1 网络中断恢复
1. 发送消息
2. 在回复过程中断网
3. 恢复网络
4. **验证**: 消息状态正确,可以重试
### 7.2 异常数据处理
1. 尝试删除不存在的消息(通过控制台)
2. **验证**: 错误被优雅处理,不崩溃
## 测试检查清单
### 功能验证
- [x] 普通聊天消息发送/接收
- [ ] Agent会话消息发送/接收
- [x] 消息删除(单个/批量/清空)
- [x] 文件/图片上传和显示
- [x] 消息编辑和更新
- [x] 流式响应更新
- [x] 消息重新生成
- [x] 分支创建
- [x] 翻译功能
### 数据一致性
- [x] 刷新后数据保持一致
- [x] 切换会话数据正确
- [x] 文件引用计数正确
- [ ] Agent会话数据隔离
### 性能表现
- [x] 消息加载速度正常
- [x] 流式更新流畅
- [x] 大量数据处理正常
- [x] 内存使用合理
### 错误处理
- [x] 网络错误处理正确
- [x] 异常操作不崩溃
- [x] 错误信息清晰
## 回滚测试
完成所有测试后,验证回滚功能:
```javascript
// 禁用V2功能
localStorage.setItem('featureFlags', JSON.stringify({ USE_UNIFIED_DB_SERVICE: false }))
location.reload()
// 验证切换回原实现后一切正常
```
## 问题记录
如果发现问题,请记录:
1. 测试场景编号
2. 具体操作步骤
3. 预期结果
4. 实际结果
5. 浏览器控制台错误信息(如有)
---
**提示**: 建议按顺序执行测试,每个大场景可以单独测试。重点关注数据一致性和错误处理。

View File

@ -466,12 +466,12 @@ export class AgentMessageDataSource implements MessageDataSource {
logger.warn(`bulkAddBlocks called for agent session, operation not supported individually`)
}
async updateFileCount(fileId: string, _delta: number): Promise<void> {
async updateFileCount(fileId: string, _delta: number, _deleteIfZero?: boolean): Promise<void> {
// Agent sessions don't manage file reference counts locally
logger.warn(`updateFileCount called for agent session file ${fileId}, operation not supported`)
}
async updateFileCounts(_files: Array<{ id: string; delta: number }>): Promise<void> {
async updateFileCounts(_files: Array<{ id: string; delta: number; deleteIfZero?: boolean }>): Promise<void> {
// Agent sessions don't manage file reference counts locally
logger.warn(`updateFileCounts called for agent session, operation not supported`)
}

View File

@ -150,16 +150,16 @@ class DbService implements MessageDataSource {
return this.dexieSource.updateBlocks(blocks)
}
async updateFileCount(fileId: string, delta: number): Promise<void> {
async updateFileCount(fileId: string, delta: number, deleteIfZero: boolean = false): Promise<void> {
// File operations only apply to Dexie source
if (this.dexieSource.updateFileCount) {
return this.dexieSource.updateFileCount(fileId, delta)
return this.dexieSource.updateFileCount(fileId, delta, deleteIfZero)
}
// No-op if not supported
logger.warn(`updateFileCount not supported for file ${fileId}`)
}
async updateFileCounts(files: Array<{ id: string; delta: number }>): Promise<void> {
async updateFileCounts(files: Array<{ id: string; delta: number; deleteIfZero?: boolean }>): Promise<void> {
// File operations only apply to Dexie source
if (this.dexieSource.updateFileCounts) {
return this.dexieSource.updateFileCounts(files)

View File

@ -364,29 +364,40 @@ export class DexieMessageDataSource implements MessageDataSource {
// ============ File Operations ============
async updateFileCount(fileId: string, delta: number): Promise<void> {
async updateFileCount(fileId: string, delta: number, deleteIfZero: boolean = false): Promise<void> {
try {
await db.files
.where('id')
.equals(fileId)
.modify((f) => {
if (f) {
f.count = (f.count || 0) + delta
}
})
await db.transaction('rw', db.files, async () => {
const file = await db.files.get(fileId)
if (!file) {
logger.warn(`File ${fileId} not found for count update`)
return
}
const newCount = (file.count || 0) + delta
if (newCount <= 0 && deleteIfZero) {
// Delete the file when count reaches 0 or below
await FileManager.deleteFile(fileId, false)
await db.files.delete(fileId)
logger.info(`Deleted file ${fileId} as reference count reached ${newCount}`)
} else {
// Update the count
await db.files.update(fileId, { count: Math.max(0, newCount) })
logger.debug(`Updated file ${fileId} count to ${Math.max(0, newCount)}`)
}
})
} catch (error) {
logger.error(`Failed to update file count for ${fileId}:`, error as Error)
throw error
}
}
async updateFileCounts(files: Array<{ id: string; delta: number }>): Promise<void> {
async updateFileCounts(files: Array<{ id: string; delta: number; deleteIfZero?: boolean }>): Promise<void> {
try {
await db.transaction('rw', db.files, async () => {
for (const file of files) {
await this.updateFileCount(file.id, file.delta)
}
})
for (const file of files) {
await this.updateFileCount(file.id, file.delta, file.deleteIfZero || false)
}
} catch (error) {
logger.error('Failed to update file counts:', error as Error)
throw error

View File

@ -109,13 +109,16 @@ export interface MessageDataSource {
/**
* Update file reference count
* @param fileId - The file ID to update
* @param delta - The change in reference count (positive or negative)
* @param deleteIfZero - Whether to delete the file when count reaches 0
*/
updateFileCount?(fileId: string, delta: number): Promise<void>
updateFileCount?(fileId: string, delta: number, deleteIfZero?: boolean): Promise<void>
/**
* Update multiple file reference counts
*/
updateFileCounts?(files: Array<{ id: string; delta: number }>): Promise<void>
updateFileCounts?(files: Array<{ id: string; delta: number; deleteIfZero?: boolean }>): Promise<void>
}
/**

View File

@ -87,9 +87,8 @@ export const updateFileCountV2 = async (
deleteIfZero: boolean = false
): Promise<void> => {
try {
// DbService.updateFileCount only accepts fileId and delta
// deleteIfZero parameter is not currently supported in DbService
await dbService.updateFileCount(fileId, delta)
// Pass all parameters to dbService, including deleteIfZero
await dbService.updateFileCount(fileId, delta, deleteIfZero)
logger.info('Updated file count', { fileId, delta, deleteIfZero })
} catch (error) {
logger.error('Failed to update file count:', { fileId, delta, error })