mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
Added persistence to retry
This commit is contained in:
parent
ff5eca82a1
commit
13e22c1e05
8 changed files with 501 additions and 59 deletions
2
src-tauri/migrations/006_story_retry_state.sql
Normal file
2
src-tauri/migrations/006_story_retry_state.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Add retry_state column to stories for persistent retry functionality
|
||||
ALTER TABLE stories ADD COLUMN retry_state TEXT;
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
></textarea>
|
||||
</div>
|
||||
{#if ui.isGenerating}
|
||||
<button
|
||||
onclick={handleStopGeneration}
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{#if !isRetryingLastMessage}
|
||||
<button
|
||||
onclick={handleStopGeneration}
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
disabled
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 opacity-50 cursor-not-allowed"
|
||||
title="Stop disabled during retry"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
|
|
@ -1196,13 +1266,23 @@
|
|||
></textarea>
|
||||
</div>
|
||||
{#if ui.isGenerating}
|
||||
<button
|
||||
onclick={handleStopGeneration}
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{#if !isRetryingLastMessage}
|
||||
<button
|
||||
onclick={handleStopGeneration}
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
disabled
|
||||
class="btn self-stretch px-3 sm:px-4 py-3 min-h-[44px] min-w-[44px] bg-red-500/20 text-red-400 opacity-50 cursor-not-allowed"
|
||||
title="Stop disabled during retry"
|
||||
>
|
||||
<Square class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
EntryType,
|
||||
EntryState,
|
||||
EntryPreview,
|
||||
PersistentRetryState,
|
||||
} from '$lib/types';
|
||||
|
||||
class DatabaseService {
|
||||
|
|
@ -119,6 +120,10 @@ class DatabaseService {
|
|||
setClauses.push('memory_config = ?');
|
||||
values.push(updates.memoryConfig ? JSON.stringify(updates.memoryConfig) : null);
|
||||
}
|
||||
if (updates.retryState !== undefined) {
|
||||
setClauses.push('retry_state = ?');
|
||||
values.push(updates.retryState ? JSON.stringify(updates.retryState) : null);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
await db.execute(
|
||||
|
|
@ -127,6 +132,28 @@ class DatabaseService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save retry state for a story.
|
||||
*/
|
||||
async saveRetryState(storyId: string, retryState: PersistentRetryState): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'UPDATE stories SET retry_state = ? WHERE id = ?',
|
||||
[JSON.stringify(retryState), storyId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear retry state for a story.
|
||||
*/
|
||||
async clearRetryState(storyId: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'UPDATE stories SET retry_state = NULL WHERE id = ?',
|
||||
[storyId]
|
||||
);
|
||||
}
|
||||
|
||||
async deleteStory(id: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute('DELETE FROM stories WHERE id = ?', [id]);
|
||||
|
|
@ -844,6 +871,7 @@ class DatabaseService {
|
|||
updatedAt: row.updated_at,
|
||||
settings: row.settings ? JSON.parse(row.settings) : null,
|
||||
memoryConfig: row.memory_config ? JSON.parse(row.memory_config) : null,
|
||||
retryState: row.retry_state ? JSON.parse(row.retry_state) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ class ExportService {
|
|||
mode: data.story.mode || 'adventure',
|
||||
settings: data.story.settings,
|
||||
memoryConfig: data.story.memoryConfig || null,
|
||||
retryState: null, // Clear retry state on import
|
||||
};
|
||||
|
||||
await database.createStory(importedStory);
|
||||
|
|
|
|||
|
|
@ -301,8 +301,13 @@ class StoryStore {
|
|||
// Load persisted activation data for this story (stickiness tracking)
|
||||
await ui.loadActivationData(storyId);
|
||||
|
||||
// Clear retry backup from previous story
|
||||
ui.clearRetryBackup();
|
||||
// Set current story ID for retry backup tracking
|
||||
ui.setCurrentRetryStoryId(storyId);
|
||||
|
||||
// Load retry state from DB if we don't have an in-memory backup for this story
|
||||
if (story.retryState) {
|
||||
ui.loadRetryBackupFromPersistent(storyId, story.retryState);
|
||||
}
|
||||
|
||||
// Validate and repair chapter integrity (handles orphaned references)
|
||||
await this.validateChapterIntegrity();
|
||||
|
|
@ -340,6 +345,7 @@ class StoryStore {
|
|||
mode,
|
||||
settings: null,
|
||||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
@ -370,6 +376,7 @@ class StoryStore {
|
|||
mode,
|
||||
settings: settings ?? null,
|
||||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
@ -503,6 +510,97 @@ class StoryStore {
|
|||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all entries from a given position onward.
|
||||
* Used for entry-only retry restore (persistent retry).
|
||||
*/
|
||||
async deleteEntriesFromPosition(position: number): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
// Find entries to delete (position >= the given position)
|
||||
const entriesToDelete = this.entries.filter(e => e.position >= position);
|
||||
|
||||
log('Deleting entries from position', {
|
||||
position,
|
||||
entriesToDelete: entriesToDelete.length,
|
||||
totalEntries: this.entries.length,
|
||||
});
|
||||
|
||||
// Delete from database
|
||||
for (const entry of entriesToDelete) {
|
||||
await database.deleteStoryEntry(entry.id);
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
this.entries = this.entries.filter(e => e.position < position);
|
||||
|
||||
// Update story's updatedAt
|
||||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete entities that were created after the backup.
|
||||
* Used for persistent retry restore to remove AI-extracted entities.
|
||||
* Compares current entity IDs against the saved ID lists and deletes any not in the lists.
|
||||
*/
|
||||
async deleteEntitiesCreatedAfterBackup(savedIds: {
|
||||
characterIds: string[];
|
||||
locationIds: string[];
|
||||
itemIds: string[];
|
||||
storyBeatIds: string[];
|
||||
lorebookEntryIds: string[];
|
||||
}): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const characterIdsSet = new Set(savedIds.characterIds);
|
||||
const locationIdsSet = new Set(savedIds.locationIds);
|
||||
const itemIdsSet = new Set(savedIds.itemIds);
|
||||
const storyBeatIdsSet = new Set(savedIds.storyBeatIds);
|
||||
const lorebookEntryIdsSet = new Set(savedIds.lorebookEntryIds);
|
||||
|
||||
// Find entities to delete (not in saved lists)
|
||||
const charactersToDelete = this.characters.filter(c => !characterIdsSet.has(c.id));
|
||||
const locationsToDelete = this.locations.filter(l => !locationIdsSet.has(l.id));
|
||||
const itemsToDelete = this.items.filter(i => !itemIdsSet.has(i.id));
|
||||
const storyBeatsToDelete = this.storyBeats.filter(sb => !storyBeatIdsSet.has(sb.id));
|
||||
const lorebookEntriesToDelete = this.lorebookEntries.filter(le => !lorebookEntryIdsSet.has(le.id));
|
||||
|
||||
log('Deleting entities created after backup', {
|
||||
characters: charactersToDelete.length,
|
||||
locations: locationsToDelete.length,
|
||||
items: itemsToDelete.length,
|
||||
storyBeats: storyBeatsToDelete.length,
|
||||
lorebookEntries: lorebookEntriesToDelete.length,
|
||||
});
|
||||
|
||||
// Delete from database
|
||||
for (const character of charactersToDelete) {
|
||||
await database.deleteCharacter(character.id);
|
||||
}
|
||||
for (const location of locationsToDelete) {
|
||||
await database.deleteLocation(location.id);
|
||||
}
|
||||
for (const item of itemsToDelete) {
|
||||
await database.deleteItem(item.id);
|
||||
}
|
||||
for (const storyBeat of storyBeatsToDelete) {
|
||||
await database.deleteStoryBeat(storyBeat.id);
|
||||
}
|
||||
for (const lorebookEntry of lorebookEntriesToDelete) {
|
||||
await database.deleteEntry(lorebookEntry.id);
|
||||
}
|
||||
|
||||
// Update in-memory state
|
||||
this.characters = this.characters.filter(c => characterIdsSet.has(c.id));
|
||||
this.locations = this.locations.filter(l => locationIdsSet.has(l.id));
|
||||
this.items = this.items.filter(i => itemIdsSet.has(i.id));
|
||||
this.storyBeats = this.storyBeats.filter(sb => storyBeatIdsSet.has(sb.id));
|
||||
this.lorebookEntries = this.lorebookEntries.filter(le => lorebookEntryIdsSet.has(le.id));
|
||||
|
||||
// Update story's updatedAt
|
||||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
// Add a character
|
||||
async addCharacter(name: string, description?: string, relationship?: string): Promise<Character> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
|
@ -1143,8 +1241,8 @@ class StoryStore {
|
|||
this.chapters = [];
|
||||
this.checkpoints = [];
|
||||
|
||||
// Clear retry backup since it belongs to the old story
|
||||
ui.clearRetryBackup();
|
||||
// Clear current retry story ID (backups are kept per-story)
|
||||
ui.setCurrentRetryStoryId(null);
|
||||
}
|
||||
|
||||
// Update story mode
|
||||
|
|
@ -1449,6 +1547,7 @@ class StoryStore {
|
|||
systemPromptOverride: data.systemPrompt,
|
||||
},
|
||||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry } from '$lib/types';
|
||||
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry, ActionInputType } from '$lib/types';
|
||||
import type { ActionChoice } from '$lib/services/ai/actionChoices';
|
||||
import type { StorySuggestion } from '$lib/services/ai/suggestions';
|
||||
import type { StyleReviewResult } from '$lib/services/ai/styleReviewer';
|
||||
|
|
@ -6,8 +6,7 @@ import type { EntryRetrievalResult, ActivationTracker } from '$lib/services/ai/e
|
|||
import type { SyncMode } from '$lib/types/sync';
|
||||
import { SimpleActivationTracker } from '$lib/services/ai/entryRetrieval';
|
||||
import { database } from '$lib/services/database';
|
||||
|
||||
type ActionInputType = 'do' | 'say' | 'think' | 'story' | 'free';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
// Debug log entry for request/response logging
|
||||
export interface DebugLogEntry {
|
||||
|
|
@ -25,6 +24,7 @@ export interface RetryBackup {
|
|||
storyId: string;
|
||||
timestamp: number;
|
||||
// State snapshots (captured BEFORE user action is added)
|
||||
// These may be empty if loaded from persistent storage (entry-only restore)
|
||||
entries: StoryEntry[];
|
||||
characters: Character[];
|
||||
locations: Location[];
|
||||
|
|
@ -39,6 +39,18 @@ export interface RetryBackup {
|
|||
// Lorebook activation tracking data (for stickiness preservation)
|
||||
activationData: Record<string, number>;
|
||||
storyPosition: number;
|
||||
// Next entry position at time of backup - used for entry-only restore
|
||||
entryCountBeforeAction: number;
|
||||
// Flag indicating if this has full state snapshots (in-memory) or just entry data (from DB)
|
||||
hasFullState: boolean;
|
||||
// Flag indicating if entity ID snapshots are present for safe cleanup
|
||||
hasEntityIds: boolean;
|
||||
// Entity IDs for persistent restore - delete any entities not in these lists
|
||||
characterIds: string[];
|
||||
locationIds: string[];
|
||||
itemIds: string[];
|
||||
storyBeatIds: string[];
|
||||
lorebookEntryIds: string[];
|
||||
}
|
||||
|
||||
// Error state for retry functionality
|
||||
|
|
@ -86,8 +98,25 @@ class UIStore {
|
|||
// Error state for retry
|
||||
lastGenerationError = $state<GenerationError | null>(null);
|
||||
|
||||
// Retry backup - captures state before each user message for "retry last message" feature
|
||||
retryBackup = $state<RetryBackup | null>(null);
|
||||
// Retry backups - per-story backups for "retry last message" feature
|
||||
// Stored by storyId so they persist across story switches within a session
|
||||
private retryBackups = new SvelteMap<string, RetryBackup>();
|
||||
private currentRetryStoryId = $state<string | null>(null);
|
||||
retryStateWrite = Promise.resolve();
|
||||
|
||||
// Computed getter for current story's retry backup
|
||||
get retryBackup(): RetryBackup | null {
|
||||
if (!this.currentRetryStoryId) return null;
|
||||
return this.retryBackups.get(this.currentRetryStoryId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current story ID for retry backup tracking.
|
||||
* Called when switching stories to ensure the correct backup is returned.
|
||||
*/
|
||||
setCurrentRetryStoryId(storyId: string | null) {
|
||||
this.currentRetryStoryId = storyId;
|
||||
}
|
||||
|
||||
// RPG action choices (displayed after narration)
|
||||
actionChoices = $state<ActionChoice[]>([]);
|
||||
|
|
@ -231,6 +260,7 @@ class UIStore {
|
|||
* Create a backup of the current story state before a user message.
|
||||
* This captures the state BEFORE the user action is added, so we can restore to this point.
|
||||
* Also captures lorebook activation data for stickiness preservation.
|
||||
* Persists a lightweight version to the database for cross-session retry.
|
||||
*/
|
||||
createRetryBackup(
|
||||
storyId: string,
|
||||
|
|
@ -245,10 +275,20 @@ class UIStore {
|
|||
actionType: ActionInputType,
|
||||
wasRawActionChoice: boolean
|
||||
) {
|
||||
// Clear old backup and create new one
|
||||
this.retryBackup = {
|
||||
const timestamp = Date.now();
|
||||
const nextEntryPosition = entries.reduce((max, entry) => Math.max(max, entry.position ?? -1), -1) + 1;
|
||||
|
||||
// Extract entity IDs for persistent restore
|
||||
const characterIds = characters.map(c => c.id);
|
||||
const locationIds = locations.map(l => l.id);
|
||||
const itemIds = items.map(i => i.id);
|
||||
const storyBeatIds = storyBeats.map(sb => sb.id);
|
||||
const lorebookEntryIds = lorebookEntries.map(le => le.id);
|
||||
|
||||
// Create new backup and store by story ID
|
||||
const backup: RetryBackup = {
|
||||
storyId,
|
||||
timestamp: Date.now(),
|
||||
timestamp,
|
||||
// Deep copy arrays to avoid mutation issues
|
||||
entries: [...entries],
|
||||
characters: [...characters],
|
||||
|
|
@ -263,7 +303,38 @@ class UIStore {
|
|||
// Capture activation data for lorebook stickiness preservation
|
||||
activationData: { ...this.activationData },
|
||||
storyPosition: this.currentStoryPosition,
|
||||
// New fields for persistent retry
|
||||
entryCountBeforeAction: nextEntryPosition,
|
||||
hasFullState: true,
|
||||
hasEntityIds: true,
|
||||
// Entity IDs for persistent restore
|
||||
characterIds,
|
||||
locationIds,
|
||||
itemIds,
|
||||
storyBeatIds,
|
||||
lorebookEntryIds,
|
||||
};
|
||||
this.retryBackups.set(storyId, backup);
|
||||
this.currentRetryStoryId = storyId;
|
||||
|
||||
// Persist lightweight version to database (includes entity IDs for full restore)
|
||||
this.queueRetryStateWrite(
|
||||
() => database.saveRetryState(storyId, {
|
||||
timestamp,
|
||||
entryCountBeforeAction: nextEntryPosition,
|
||||
userActionContent,
|
||||
rawInput,
|
||||
actionType,
|
||||
wasRawActionChoice,
|
||||
characterIds,
|
||||
locationIds,
|
||||
itemIds,
|
||||
storyBeatIds,
|
||||
lorebookEntryIds,
|
||||
}),
|
||||
'persist'
|
||||
);
|
||||
|
||||
console.log('[UI] Retry backup created', {
|
||||
storyId,
|
||||
entriesCount: entries.length,
|
||||
|
|
@ -273,11 +344,122 @@ class UIStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clear the retry backup (called when switching stories or if user doesn't want to retry).
|
||||
* Clear the retry backup for a story.
|
||||
* @param clearFromDb - If true, also clears from database (use for explicit dismissal/use).
|
||||
* @param storyId - Optional story ID. If not provided, clears the current story's backup.
|
||||
*/
|
||||
clearRetryBackup() {
|
||||
this.retryBackup = null;
|
||||
console.log('[UI] Retry backup cleared');
|
||||
clearRetryBackup(clearFromDb: boolean = false, storyId?: string) {
|
||||
const targetStoryId = storyId ?? this.currentRetryStoryId;
|
||||
|
||||
if (targetStoryId) {
|
||||
this.retryBackups.delete(targetStoryId);
|
||||
|
||||
// Only clear from database if explicitly requested (user dismissed or used retry)
|
||||
if (clearFromDb) {
|
||||
this.queueRetryStateWrite(
|
||||
() => database.clearRetryState(targetStoryId),
|
||||
'clear'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[UI] Retry backup cleared', { clearFromDb, storyId: targetStoryId });
|
||||
}
|
||||
|
||||
private queueRetryStateWrite(task: () => Promise<void>, label: string) {
|
||||
this.retryStateWrite = this.retryStateWrite
|
||||
.catch(() => {})
|
||||
.then(task)
|
||||
.catch(err => {
|
||||
console.warn(`[UI] Failed to ${label} retry state:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load retry backup from persistent state (called when a story is loaded).
|
||||
* Creates a partial RetryBackup with hasFullState=false for entity-aware restore.
|
||||
* Only loads if there isn't already an in-memory backup for this story.
|
||||
*/
|
||||
loadRetryBackupFromPersistent(storyId: string, retryState: {
|
||||
timestamp: number;
|
||||
entryCountBeforeAction: number;
|
||||
userActionContent: string;
|
||||
rawInput: string;
|
||||
actionType: ActionInputType;
|
||||
wasRawActionChoice: boolean;
|
||||
characterIds?: string[];
|
||||
locationIds?: string[];
|
||||
itemIds?: string[];
|
||||
storyBeatIds?: string[];
|
||||
lorebookEntryIds?: string[];
|
||||
}) {
|
||||
// Skip if we already have an in-memory backup for this story (it's more complete)
|
||||
if (this.retryBackups.has(storyId)) {
|
||||
console.log('[UI] Skipping persistent retry state load - in-memory backup exists', { storyId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields exist
|
||||
if (
|
||||
typeof retryState.timestamp !== 'number' ||
|
||||
typeof retryState.entryCountBeforeAction !== 'number' ||
|
||||
typeof retryState.userActionContent !== 'string' ||
|
||||
typeof retryState.rawInput !== 'string' ||
|
||||
typeof retryState.actionType !== 'string' ||
|
||||
typeof retryState.wasRawActionChoice !== 'boolean'
|
||||
) {
|
||||
console.warn('[UI] Invalid persistent retry state, skipping load', { storyId, retryState });
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEntityIds = Array.isArray(retryState.characterIds)
|
||||
&& Array.isArray(retryState.locationIds)
|
||||
&& Array.isArray(retryState.itemIds)
|
||||
&& Array.isArray(retryState.storyBeatIds)
|
||||
&& Array.isArray(retryState.lorebookEntryIds);
|
||||
|
||||
const backup: RetryBackup = {
|
||||
storyId,
|
||||
timestamp: retryState.timestamp,
|
||||
// Empty state arrays - will use ID-based restore
|
||||
entries: [],
|
||||
characters: [],
|
||||
locations: [],
|
||||
items: [],
|
||||
storyBeats: [],
|
||||
lorebookEntries: [],
|
||||
// User input data
|
||||
userActionContent: retryState.userActionContent,
|
||||
rawInput: retryState.rawInput,
|
||||
actionType: retryState.actionType,
|
||||
wasRawActionChoice: retryState.wasRawActionChoice,
|
||||
// Empty activation data
|
||||
activationData: {},
|
||||
storyPosition: 0,
|
||||
// Persistent retry fields
|
||||
entryCountBeforeAction: retryState.entryCountBeforeAction,
|
||||
hasFullState: false, // Indicates ID-based restore
|
||||
hasEntityIds,
|
||||
// Entity IDs for restore - delete any entities not in these lists
|
||||
characterIds: retryState.characterIds ?? [],
|
||||
locationIds: retryState.locationIds ?? [],
|
||||
itemIds: retryState.itemIds ?? [],
|
||||
storyBeatIds: retryState.storyBeatIds ?? [],
|
||||
lorebookEntryIds: retryState.lorebookEntryIds ?? [],
|
||||
};
|
||||
this.retryBackups.set(storyId, backup);
|
||||
console.log('[UI] Retry backup loaded from persistent state', {
|
||||
storyId,
|
||||
entryCountBeforeAction: retryState.entryCountBeforeAction,
|
||||
userAction: retryState.userActionContent.substring(0, 50),
|
||||
entityIds: {
|
||||
characters: backup.characterIds.length,
|
||||
locations: backup.locationIds.length,
|
||||
items: backup.itemIds.length,
|
||||
storyBeats: backup.storyBeatIds.length,
|
||||
lorebookEntries: backup.lorebookEntryIds.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -305,12 +487,34 @@ class UIStore {
|
|||
* Used when editing the last user message to retry with new content.
|
||||
*/
|
||||
updateRetryBackupContent(newContent: string) {
|
||||
if (this.retryBackup) {
|
||||
this.retryBackup = {
|
||||
...this.retryBackup,
|
||||
const backup = this.retryBackup;
|
||||
if (backup && this.currentRetryStoryId) {
|
||||
const updatedBackup: RetryBackup = {
|
||||
...backup,
|
||||
userActionContent: newContent,
|
||||
rawInput: newContent,
|
||||
};
|
||||
this.retryBackups.set(this.currentRetryStoryId, updatedBackup);
|
||||
|
||||
// Also persist the updated content to the database
|
||||
const storyId = this.currentRetryStoryId;
|
||||
this.queueRetryStateWrite(
|
||||
() => database.saveRetryState(storyId, {
|
||||
timestamp: backup.timestamp,
|
||||
entryCountBeforeAction: backup.entryCountBeforeAction,
|
||||
userActionContent: newContent,
|
||||
rawInput: newContent,
|
||||
actionType: backup.actionType,
|
||||
wasRawActionChoice: backup.wasRawActionChoice,
|
||||
characterIds: backup.characterIds,
|
||||
locationIds: backup.locationIds,
|
||||
itemIds: backup.itemIds,
|
||||
storyBeatIds: backup.storyBeatIds,
|
||||
lorebookEntryIds: backup.lorebookEntryIds,
|
||||
}),
|
||||
'update'
|
||||
);
|
||||
|
||||
console.log('[UI] Retry backup content updated', {
|
||||
newContent: newContent.substring(0, 50),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,28 @@ export interface Story {
|
|||
updatedAt: number;
|
||||
settings: StorySettings | null;
|
||||
memoryConfig: MemoryConfig | null;
|
||||
retryState: PersistentRetryState | null;
|
||||
}
|
||||
|
||||
// Persistent retry state - lightweight version saved to database
|
||||
export type ActionInputType = 'do' | 'say' | 'think' | 'story' | 'free';
|
||||
|
||||
export interface PersistentRetryState {
|
||||
timestamp: number;
|
||||
// The next entry position before user action was added (max position + 1)
|
||||
// On retry, delete entries from this position onward
|
||||
entryCountBeforeAction: number;
|
||||
// The user's input data
|
||||
userActionContent: string;
|
||||
rawInput: string;
|
||||
actionType: ActionInputType;
|
||||
wasRawActionChoice: boolean;
|
||||
// Entity IDs that existed before the action - on restore, delete any not in these lists
|
||||
characterIds: string[];
|
||||
locationIds: string[];
|
||||
itemIds: string[];
|
||||
storyBeatIds: string[];
|
||||
lorebookEntryIds: string[];
|
||||
}
|
||||
|
||||
export interface MemoryConfig {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue