fix(ai): make message editing safe against data loss and stale state

- insertMessageAfter: update active_message_id when appending to tail
  so the new reply is on the active path after reload
- editAndOverwriteAll: add hasConfig + abort checks before
  deleteMessagesFrom to prevent data loss when LLM is unconfigured or
  a concurrent request starts
- editCurrentRoundOnly: defer all DB mutations until after stream
  completes; on abort/error only in-memory state is restored
- sync in-memory parentId after relinking so follow-up edits send the
  correct historyLeafMessageId

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-30 14:19:42 +08:00
1 parent ce1065e295
commit b3c2b20a9f
2 files changed
+36 -7

No files matched your search

@@ -738,10 +738,15 @@ export class AIConversationManager {
if (childRow) {
db.prepare('UPDATE ai_message SET parent_id = ? WHERE id = ?').run(id, childRow.id)
db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId)
} else {
db.prepare('UPDATE ai_conversation SET active_message_id = ?, updated_at = ? WHERE id = ?').run(
id,
now,
conversationId
)
}
db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId)
return {
id,
conversationId,
+29 -5
View File
@@ -1205,6 +1205,17 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
content: string
): Promise<SendMessageResult> {
try {
const hasConfig = await window.llmApi.hasConfig()
if (!hasConfig) {
return { success: false, reason: 'no_config' }
}
if (state.isAborted) {
return { success: false, reason: 'aborted' }
}
if (state.isAIThinking || activeTask.value) {
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
await useAIService().deleteMessagesFrom(state.currentConversationId!, originalMessage.id)
targetBuffer.messages.splice(editIndex, targetBuffer.messages.length - editIndex)
return sendMessage(chatKey, content)
@@ -1300,11 +1311,6 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
return { success: false, reason: 'busy', activeTask: activeTask.value }
}
await useAIService().updateMessageContent(originalMessage.id, content)
if (hasOldAiResponse) {
await useAIService().deleteAndRelinkMessage(state.currentConversationId!, oldAiResponse.id)
}
const context = {
sessionId: state.sessionId,
conversationId: state.currentConversationId!,
@@ -1407,6 +1413,16 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
updateAIMessage({ contentBlocks: [...blocks], isStreaming: false })
}
if (!result.success) {
restoreOriginal()
return { success: false, reason: 'error' }
}
await useAIService().updateMessageContent(originalMessage.id, content)
if (hasOldAiResponse) {
await useAIService().deleteAndRelinkMessage(state.currentConversationId!, oldAiResponse.id)
}
const serializableContentBlocks = targetBuffer.messages[aiMessageIndex].contentBlocks
? JSON.parse(JSON.stringify(targetBuffer.messages[aiMessageIndex].contentBlocks))
: undefined
@@ -1428,6 +1444,14 @@ export const useAIChatStore = defineStore('aiChatRuntime', () => {
content,
}
const nextMsgIndex = aiMessageIndex + 1
if (nextMsgIndex < targetBuffer.messages.length) {
targetBuffer.messages[nextMsgIndex] = {
...targetBuffer.messages[nextMsgIndex],
parentId: savedAiMsg.id,
}
}
targetBuffer.sessionTokenUsage = await useAIService().getConversationTokenUsage(state.currentConversationId!)
state.sessionTokenUsage = { ...targetBuffer.sessionTokenUsage }
return { success: true }