From 13e22c1e05b8aa61eecdb15dfd4c4bee75bc02b0 Mon Sep 17 00:00:00 2001 From: Kurvaz Date: Wed, 7 Jan 2026 15:49:37 -0700 Subject: [PATCH] Added persistence to retry --- .../migrations/006_story_retry_state.sql | 2 + src-tauri/src/lib.rs | 6 + src/lib/components/story/ActionInput.svelte | 160 +++++++++--- src/lib/services/database.ts | 28 +++ src/lib/services/export.ts | 1 + src/lib/stores/story.svelte.ts | 107 +++++++- src/lib/stores/ui.svelte.ts | 234 ++++++++++++++++-- src/lib/types/index.ts | 22 ++ 8 files changed, 501 insertions(+), 59 deletions(-) create mode 100644 src-tauri/migrations/006_story_retry_state.sql diff --git a/src-tauri/migrations/006_story_retry_state.sql b/src-tauri/migrations/006_story_retry_state.sql new file mode 100644 index 0000000..7cba5aa --- /dev/null +++ b/src-tauri/migrations/006_story_retry_state.sql @@ -0,0 +1,2 @@ +-- Add retry_state column to stories for persistent retry functionality +ALTER TABLE stories ADD COLUMN retry_state TEXT; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f9f70a..454bd5a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,12 @@ pub fn run() { sql: include_str!("../migrations/005_story_beats_resolved_at.sql"), kind: MigrationKind::Up, }, + Migration { + version: 6, + description: "add_story_retry_state", + sql: include_str!("../migrations/006_story_retry_state.sql"), + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() diff --git a/src/lib/components/story/ActionInput.svelte b/src/lib/components/story/ActionInput.svelte index 0705f36..8d8ec2b 100644 --- a/src/lib/components/story/ActionInput.svelte +++ b/src/lib/components/story/ActionInput.svelte @@ -27,6 +27,7 @@ let isRawActionChoice = $state(false); // True when submitting an AI-generated choice (no prefix/suffix) let stopRequested = false; let activeAbortController: AbortController | null = null; + let isRetryingLastMessage = $state(false); // Hide stop button during completed-message retries // In creative writing mode, show different input style const isCreativeMode = $derived(story.storyMode === 'creative-writing'); @@ -880,6 +881,10 @@ log('Stop ignored (not generating)'); return; } + if (isRetryingLastMessage) { + log('Stop ignored (retrying completed message)'); + return; + } stopRequested = true; activeAbortController?.abort(); @@ -903,18 +908,42 @@ ui.clearGenerationError(); ui.clearSuggestions(story.currentStory?.id); ui.clearActionChoices(story.currentStory?.id); - ui.restoreActivationData(backup.activationData, backup.storyPosition); + if (backup.hasFullState) { + ui.restoreActivationData(backup.activationData, backup.storyPosition); + } ui.setLastLorebookRetrieval(null); try { - await story.restoreFromRetryBackup({ - entries: backup.entries, - characters: backup.characters, - locations: backup.locations, - items: backup.items, - storyBeats: backup.storyBeats, - lorebookEntries: backup.lorebookEntries, - }); + if (backup.hasFullState) { + await story.restoreFromRetryBackup({ + entries: backup.entries, + characters: backup.characters, + locations: backup.locations, + items: backup.items, + storyBeats: backup.storyBeats, + lorebookEntries: backup.lorebookEntries, + }); + } else { + // Persistent restore - delete entries and entities created after backup + // Clear activation data but don't save yet - let the next action rebuild it + ui.clearActivationData(); + + log('Persistent stop restore: deleting entries from position', backup.entryCountBeforeAction); + await story.deleteEntriesFromPosition(backup.entryCountBeforeAction); + + if (backup.hasEntityIds) { + log('Persistent stop restore: deleting entities created after backup'); + await story.deleteEntitiesCreatedAfterBackup({ + characterIds: backup.characterIds, + locationIds: backup.locationIds, + itemIds: backup.itemIds, + storyBeatIds: backup.storyBeatIds, + lorebookEntryIds: backup.lorebookEntryIds, + }); + } else { + log('Persistent stop restore: skipping entity cleanup (no ID snapshot)'); + } + } await tick(); actionType = backup.actionType; @@ -924,7 +953,7 @@ log('Stop restore failed', error); console.error('Stop restore failed:', error); } finally { - ui.clearRetryBackup(); + ui.clearRetryBackup(true); // Clear from DB since user explicitly stopped } } @@ -970,6 +999,7 @@ /** * Retry the last user message by restoring to the backup state * and regenerating with the same user action. + * Supports both full state restore (in-memory backup) and entry-only restore (persistent backup). */ async function handleRetryLastMessage() { log('handleRetryLastMessage called', { @@ -987,12 +1017,14 @@ // Verify backup is for current story if (backup.storyId !== story.currentStory.id) { log('Backup is for different story, clearing'); - ui.clearRetryBackup(); + ui.clearRetryBackup(false); // Just clear in-memory, don't touch DB return; } log('Restoring from backup and regenerating', { + hasFullState: backup.hasFullState, backupEntriesCount: backup.entries.length, + entryCountBeforeAction: backup.entryCountBeforeAction, currentEntriesCount: story.entries.length, userAction: backup.userActionContent.substring(0, 50), }); @@ -1004,23 +1036,46 @@ ui.clearSuggestions(story.currentStory?.id); ui.clearActionChoices(story.currentStory?.id); - // Restore activation data from backup to preserve lorebook stickiness state - // This ensures entries that were "sticky" before the user action remain sticky - ui.restoreActivationData(backup.activationData, backup.storyPosition); - // Clear lorebook retrieval debug state since it's now stale ui.setLastLorebookRetrieval(null); try { - // Restore story state from backup - await story.restoreFromRetryBackup({ - entries: backup.entries, - characters: backup.characters, - locations: backup.locations, - items: backup.items, - storyBeats: backup.storyBeats, - lorebookEntries: backup.lorebookEntries, - }); + if (backup.hasFullState) { + // Full state restore (in-memory backup with snapshots) + // Restore activation data from backup to preserve lorebook stickiness state + ui.restoreActivationData(backup.activationData, backup.storyPosition); + + // Restore story state from backup + await story.restoreFromRetryBackup({ + entries: backup.entries, + characters: backup.characters, + locations: backup.locations, + items: backup.items, + storyBeats: backup.storyBeats, + lorebookEntries: backup.lorebookEntries, + }); + } else { + // Persistent restore (backup without full snapshots, but with entity IDs) + // Clear activation data but don't save yet - generation will rebuild it + ui.clearActivationData(); + + log('Persistent restore: deleting entries from position', backup.entryCountBeforeAction); + await story.deleteEntriesFromPosition(backup.entryCountBeforeAction); + + if (backup.hasEntityIds) { + // Delete entities that were created after the backup (AI extractions) + log('Persistent restore: deleting entities created after backup'); + await story.deleteEntitiesCreatedAfterBackup({ + characterIds: backup.characterIds, + locationIds: backup.locationIds, + itemIds: backup.itemIds, + storyBeatIds: backup.storyBeatIds, + lorebookEntryIds: backup.lorebookEntryIds, + }); + } else { + log('Persistent restore: skipping entity cleanup (no ID snapshot)'); + } + } // Wait for state to sync await tick(); @@ -1037,7 +1092,12 @@ // Regenerate if (!settings.needsApiKey) { - await generateResponse(userActionEntry.id, backup.userActionContent); + isRetryingLastMessage = true; + try { + await generateResponse(userActionEntry.id, backup.userActionContent); + } finally { + isRetryingLastMessage = false; + } } } catch (error) { log('Retry last message failed', error); @@ -1049,7 +1109,7 @@ * Dismiss the retry backup (user doesn't want to retry) */ function dismissRetryBackup() { - ui.clearRetryBackup(); + ui.clearRetryBackup(true); // Clear from DB since user explicitly dismissed } function handleKeydown(event: KeyboardEvent) { @@ -1112,13 +1172,23 @@ > {#if ui.isGenerating} - + {#if !isRetryingLastMessage} + + {:else} + + {/if} {:else} + {#if !isRetryingLastMessage} + + {:else} + + {/if} {:else}