mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
Add time
This commit is contained in:
parent
a89a345d08
commit
3c256cd591
18 changed files with 685 additions and 26 deletions
3
src-tauri/migrations/008_story_time_tracker.sql
Normal file
3
src-tauri/migrations/008_story_time_tracker.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- Add time_tracker column to stories for tracking story progression time
|
||||
-- Stores JSON with years, days, hours, minutes fields
|
||||
ALTER TABLE stories ADD COLUMN time_tracker TEXT;
|
||||
2
src-tauri/migrations/009_checkpoint_time_tracker.sql
Normal file
2
src-tauri/migrations/009_checkpoint_time_tracker.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- Add time_tracker_snapshot column to checkpoints for time tracking restoration
|
||||
ALTER TABLE checkpoints ADD COLUMN time_tracker_snapshot TEXT;
|
||||
4
src-tauri/migrations/010_chapter_time_fields.sql
Normal file
4
src-tauri/migrations/010_chapter_time_fields.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Add start_time and end_time columns to chapters for timeline tracking
|
||||
-- Stores JSON with years, days, hours, minutes fields
|
||||
ALTER TABLE chapters ADD COLUMN start_time TEXT;
|
||||
ALTER TABLE chapters ADD COLUMN end_time TEXT;
|
||||
|
|
@ -52,6 +52,24 @@ pub fn run() {
|
|||
sql: include_str!("../migrations/007_story_style_review_state.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 8,
|
||||
description: "add_story_time_tracker",
|
||||
sql: include_str!("../migrations/008_story_time_tracker.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 9,
|
||||
description: "add_checkpoint_time_tracker",
|
||||
sql: include_str!("../migrations/009_checkpoint_time_tracker.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 10,
|
||||
description: "add_chapter_time_fields",
|
||||
sql: include_str!("../migrations/010_chapter_time_fields.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { ui, type DebugLogEntry } from '$lib/stores/ui.svelte';
|
||||
import { X, ArrowUpCircle, ArrowDownCircle, Trash2, Copy, Check } from 'lucide-svelte';
|
||||
import { X, ArrowUpCircle, ArrowDownCircle, Trash2, Copy, Check, WrapText } from 'lucide-svelte';
|
||||
|
||||
let copiedId = $state<string | null>(null);
|
||||
let renderNewlines = $state(false);
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
|
|
@ -22,7 +23,12 @@
|
|||
|
||||
function formatJson(data: Record<string, unknown>): string {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
let json = JSON.stringify(data, null, 2);
|
||||
if (renderNewlines) {
|
||||
// Replace escaped newlines with actual newlines in string values
|
||||
json = json.replace(/\\n/g, '\n');
|
||||
}
|
||||
return json;
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
|
|
@ -96,6 +102,13 @@
|
|||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2 {renderNewlines ? 'text-blue-400' : 'text-surface-400 hover:text-surface-200'}"
|
||||
onclick={() => renderNewlines = !renderNewlines}
|
||||
title={renderNewlines ? 'Show escaped newlines (\\n)' : 'Render newlines as line breaks'}
|
||||
>
|
||||
<WrapText class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-red-400"
|
||||
onclick={handleClearLogs}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Users, MapPin, Backpack, Scroll } from 'lucide-svelte';
|
||||
import { Users, MapPin, Backpack, Scroll, Clock } from 'lucide-svelte';
|
||||
import CharacterPanel from '$lib/components/world/CharacterPanel.svelte';
|
||||
import LocationPanel from '$lib/components/world/LocationPanel.svelte';
|
||||
import InventoryPanel from '$lib/components/world/InventoryPanel.svelte';
|
||||
import QuestPanel from '$lib/components/world/QuestPanel.svelte';
|
||||
import TimePanel from '$lib/components/world/TimePanel.svelte';
|
||||
import { swipe } from '$lib/utils/swipe';
|
||||
|
||||
const tabs = [
|
||||
|
|
@ -13,6 +14,7 @@
|
|||
{ id: 'locations' as const, icon: MapPin, label: 'Locations' },
|
||||
{ id: 'inventory' as const, icon: Backpack, label: 'Inventory' },
|
||||
{ id: 'quests' as const, icon: Scroll, label: 'Quests' },
|
||||
{ id: 'time' as const, icon: Clock, label: 'Time' },
|
||||
];
|
||||
|
||||
function handleSwipeLeft() {
|
||||
|
|
@ -67,6 +69,8 @@
|
|||
<InventoryPanel />
|
||||
{:else if ui.sidebarTab === 'quests'}
|
||||
<QuestPanel />
|
||||
{:else if ui.sidebarTab === 'time'}
|
||||
<TimePanel />
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1698,6 +1698,29 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat History Truncation -->
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs font-medium text-surface-400">
|
||||
Chat History Truncation: {settings.systemServicesSettings.classifier.chatHistoryTruncation === 0 ? 'None' : `${settings.systemServicesSettings.classifier.chatHistoryTruncation} words`}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="500"
|
||||
step="25"
|
||||
bind:value={settings.systemServicesSettings.classifier.chatHistoryTruncation}
|
||||
onchange={() => settings.saveSystemServicesSettings()}
|
||||
class="w-full h-2"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-surface-500">
|
||||
<span>None</span>
|
||||
<span>500 words</span>
|
||||
</div>
|
||||
<p class="text-xs text-surface-500 mt-1">
|
||||
Max words per message in chat history sent to classifier. 0 = no truncation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Provider Only -->
|
||||
<div class="mb-3" class:opacity-50={settings.advancedRequestSettings.manualMode}>
|
||||
<ProviderOnlySelector
|
||||
|
|
|
|||
|
|
@ -243,6 +243,13 @@
|
|||
|
||||
// Create the chapter - use database method to handle deletions correctly
|
||||
const chapterNumber = await story.getNextChapterNumber();
|
||||
|
||||
// Extract time range from entries' metadata
|
||||
const firstEntry = chapterEntries[0];
|
||||
const lastEntry = chapterEntries[chapterEntries.length - 1];
|
||||
const startTime = firstEntry.metadata?.timeStart ?? null;
|
||||
const endTime = lastEntry.metadata?.timeEnd ?? null;
|
||||
|
||||
const chapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: story.currentStory.id,
|
||||
|
|
@ -252,6 +259,8 @@
|
|||
endEntryId: chapterEntries[chapterEntries.length - 1].id,
|
||||
entryCount: chapterEntries.length,
|
||||
summary: summary.summary,
|
||||
startTime,
|
||||
endTime,
|
||||
keywords: summary.keywords,
|
||||
characters: summary.characters,
|
||||
locations: summary.locations,
|
||||
|
|
@ -714,13 +723,18 @@
|
|||
emitNarrativeResponse(narrationEntry.id, fullResponse);
|
||||
|
||||
// Phase 3: Classify the response to extract world state changes
|
||||
// Pass visible entries so classifier can see full chat history with time data
|
||||
// Filter out the current narration entry to avoid sending it twice (once in chatHistory, once as narrativeResponse)
|
||||
log('Starting classification phase...');
|
||||
try {
|
||||
const chatHistoryEntries = story.visibleEntries.filter(e => e.id !== narrationEntry.id);
|
||||
const classificationResult = await aiService.classifyResponse(
|
||||
fullResponse,
|
||||
userActionContent,
|
||||
worldState,
|
||||
currentStoryRef
|
||||
currentStoryRef,
|
||||
chatHistoryEntries,
|
||||
currentStoryRef?.timeTracker
|
||||
);
|
||||
|
||||
log('Classification complete', {
|
||||
|
|
@ -746,6 +760,10 @@
|
|||
console.warn('World state classification failed:', classifyError);
|
||||
}
|
||||
|
||||
// Phase 4.1: Update narration entry with timeEnd after classification phase
|
||||
// This runs regardless of classification success - timeEnd reflects current story time
|
||||
await story.updateEntryTimeEnd(narrationEntry.id);
|
||||
|
||||
// Phase 5: Check if auto-summarization is needed (background, non-blocking)
|
||||
if (story.memoryConfig.autoSummarize) {
|
||||
checkAutoSummarize().catch(err => {
|
||||
|
|
@ -860,7 +878,8 @@
|
|||
content,
|
||||
rawInput,
|
||||
actionType,
|
||||
wasRawActionChoice
|
||||
wasRawActionChoice,
|
||||
story.currentStory.timeTracker
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -938,6 +957,7 @@
|
|||
items: backup.items,
|
||||
storyBeats: backup.storyBeats,
|
||||
lorebookEntries: backup.lorebookEntries,
|
||||
timeTracker: backup.timeTracker,
|
||||
});
|
||||
} else {
|
||||
// Persistent restore - delete entries and entities created after backup
|
||||
|
|
@ -959,6 +979,9 @@
|
|||
} else {
|
||||
log('Persistent stop restore: skipping entity cleanup (no ID snapshot)');
|
||||
}
|
||||
|
||||
// Restore time tracker snapshot after persistent cleanup
|
||||
await story.restoreTimeTrackerSnapshot(backup.timeTracker);
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
|
@ -1072,6 +1095,7 @@
|
|||
items: backup.items,
|
||||
storyBeats: backup.storyBeats,
|
||||
lorebookEntries: backup.lorebookEntries,
|
||||
timeTracker: backup.timeTracker,
|
||||
});
|
||||
} else {
|
||||
// Persistent restore (backup without full snapshots, but with entity IDs)
|
||||
|
|
@ -1094,6 +1118,9 @@
|
|||
} else {
|
||||
log('Persistent restore: skipping entity cleanup (no ID snapshot)');
|
||||
}
|
||||
|
||||
// Restore time tracker snapshot after persistent cleanup
|
||||
await story.restoreTimeTrackerSnapshot(backup.timeTracker);
|
||||
}
|
||||
|
||||
// Wait for state to sync
|
||||
|
|
|
|||
172
src/lib/components/world/TimePanel.svelte
Normal file
172
src/lib/components/world/TimePanel.svelte
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Clock, Pencil, Check, X, RotateCcw } from 'lucide-svelte';
|
||||
|
||||
let isEditing = $state(false);
|
||||
let editYears = $state(0);
|
||||
let editDays = $state(0);
|
||||
let editHours = $state(0);
|
||||
let editMinutes = $state(0);
|
||||
|
||||
// Format the time display
|
||||
function formatTime(years: number, days: number, hours: number, minutes: number): string {
|
||||
const parts: string[] = [];
|
||||
if (years > 0) parts.push(`${years} year${years !== 1 ? 's' : ''}`);
|
||||
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
|
||||
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
|
||||
if (minutes > 0) parts.push(`${minutes} min.`);
|
||||
return parts.length > 0 ? parts.join(', ') : 'No time elapsed';
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
const time = story.timeTracker;
|
||||
editYears = time.years;
|
||||
editDays = time.days;
|
||||
editHours = time.hours;
|
||||
editMinutes = time.minutes;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
await story.setTimeTracker({
|
||||
years: Math.max(0, Number(editYears) || 0),
|
||||
days: Math.max(0, Number(editDays) || 0),
|
||||
hours: Math.max(0, Number(editHours) || 0),
|
||||
minutes: Math.max(0, Number(editMinutes) || 0),
|
||||
});
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
async function resetTime() {
|
||||
const confirmed = confirm('Reset time to zero? This cannot be undone.');
|
||||
if (!confirmed) return;
|
||||
await story.setTimeTracker({ years: 0, days: 0, hours: 0, minutes: 0 });
|
||||
}
|
||||
|
||||
// Helper to pad numbers for display
|
||||
function pad(n: number, width: number = 2): string {
|
||||
return n.toString().padStart(width, '0');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-200">Time</h3>
|
||||
{#if !isEditing}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={startEdit}
|
||||
title="Edit time"
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={resetTime}
|
||||
title="Reset time"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isEditing}
|
||||
<div class="card space-y-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-surface-400">Years</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={editYears}
|
||||
min="0"
|
||||
class="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-surface-400">Days</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={editDays}
|
||||
min="0"
|
||||
max="364"
|
||||
class="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-surface-400">Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={editHours}
|
||||
min="0"
|
||||
max="23"
|
||||
class="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-surface-400">Minutes</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={editMinutes}
|
||||
min="0"
|
||||
max="59"
|
||||
class="input text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-surface-500">
|
||||
Time will be automatically normalized (60 min = 1 hour, etc.)
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={cancelEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary text-xs" onclick={saveEdit}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-full bg-surface-700 p-2">
|
||||
<Clock class="h-5 w-5 text-accent-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-surface-200">
|
||||
{formatTime(story.timeTracker.years, story.timeTracker.days, story.timeTracker.hours, story.timeTracker.minutes)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed time display -->
|
||||
<div class="mt-4 grid grid-cols-4 gap-2 text-center">
|
||||
<div class="rounded bg-surface-700/50 p-2">
|
||||
<div class="text-lg font-medium text-surface-100">{story.timeTracker.years}</div>
|
||||
<div class="text-xs text-surface-500">Years</div>
|
||||
</div>
|
||||
<div class="rounded bg-surface-700/50 p-2">
|
||||
<div class="text-lg font-medium text-surface-100">{story.timeTracker.days}</div>
|
||||
<div class="text-xs text-surface-500">Days</div>
|
||||
</div>
|
||||
<div class="rounded bg-surface-700/50 p-2">
|
||||
<div class="text-lg font-medium text-surface-100">{pad(story.timeTracker.hours)}</div>
|
||||
<div class="text-xs text-surface-500">Hours</div>
|
||||
</div>
|
||||
<div class="rounded bg-surface-700/50 p-2">
|
||||
<div class="text-lg font-medium text-surface-100">{pad(story.timeTracker.minutes)}</div>
|
||||
<div class="text-xs text-surface-500">Min.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-surface-500">
|
||||
Time is tracked automatically as the story progresses. You can also edit it manually.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { OpenAIProvider as OpenAIProvider } from './openrouter';
|
||||
import type { Character, Location, Item, StoryBeat } from '$lib/types';
|
||||
import type { Character, Location, Item, StoryBeat, StoryEntry, TimeTracker } from '$lib/types';
|
||||
import { settings, type ClassifierSettings } from '$lib/stores/settings.svelte';
|
||||
import { buildExtraBody } from './requestOverrides';
|
||||
|
||||
|
|
@ -100,6 +100,14 @@ export interface StoryBeatUpdate {
|
|||
};
|
||||
}
|
||||
|
||||
// Chat history entry with time metadata for classification
|
||||
export interface ClassificationChatEntry {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timeStart?: TimeTracker | null;
|
||||
timeEnd?: TimeTracker | null;
|
||||
}
|
||||
|
||||
// Context for classification
|
||||
export interface ClassificationContext {
|
||||
narrativeResponse: string;
|
||||
|
|
@ -110,6 +118,10 @@ export interface ClassificationContext {
|
|||
existingStoryBeats: StoryBeat[];
|
||||
genre: string | null;
|
||||
storyMode: 'adventure' | 'creative-writing';
|
||||
// Chat history with time metadata for context-aware classification
|
||||
chatHistory?: ClassificationChatEntry[];
|
||||
// Current story time for reference
|
||||
currentStoryTime?: TimeTracker | null;
|
||||
}
|
||||
|
||||
export class ClassifierService {
|
||||
|
|
@ -137,6 +149,10 @@ export class ClassifierService {
|
|||
return this.settingsOverride?.systemPrompt ?? settings.systemServicesSettings.classifier.systemPrompt;
|
||||
}
|
||||
|
||||
private get chatHistoryTruncation(): number {
|
||||
return this.settingsOverride?.chatHistoryTruncation ?? settings.systemServicesSettings.classifier.chatHistoryTruncation ?? 100;
|
||||
}
|
||||
|
||||
async classify(context: ClassificationContext): Promise<ClassificationResult> {
|
||||
log('classify called', {
|
||||
model: this.model,
|
||||
|
|
@ -146,6 +162,8 @@ export class ClassifierService {
|
|||
existingCharacters: context.existingCharacters.length,
|
||||
existingLocations: context.existingLocations.length,
|
||||
existingItems: context.existingItems.length,
|
||||
chatHistoryEntries: context.chatHistory?.length ?? 0,
|
||||
currentStoryTime: context.currentStoryTime,
|
||||
});
|
||||
|
||||
const prompt = this.buildClassificationPrompt(context);
|
||||
|
|
@ -197,6 +215,48 @@ export class ClassifierService {
|
|||
}
|
||||
}
|
||||
|
||||
private formatTime(time: TimeTracker | null | undefined): string {
|
||||
if (!time) return 'unknown';
|
||||
const parts: string[] = [];
|
||||
if (time.years > 0) parts.push(`Year ${time.years}`);
|
||||
if (time.days > 0) parts.push(`Day ${time.days}`);
|
||||
if (time.hours > 0 || time.minutes > 0) {
|
||||
const hour = time.hours.toString().padStart(2, '0');
|
||||
const minute = time.minutes.toString().padStart(2, '0');
|
||||
parts.push(`${hour}:${minute}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(', ') : 'Day 0, 00:00';
|
||||
}
|
||||
|
||||
private truncateToWords(text: string, maxWords: number): string {
|
||||
if (maxWords <= 0) return text; // 0 = no truncation
|
||||
const words = text.split(/\s+/);
|
||||
if (words.length <= maxWords) return text;
|
||||
return words.slice(0, maxWords).join(' ') + '...';
|
||||
}
|
||||
|
||||
private buildChatHistoryBlock(chatHistory: ClassificationChatEntry[] | undefined): string {
|
||||
if (!chatHistory || chatHistory.length === 0) return '';
|
||||
|
||||
const truncationLimit = this.chatHistoryTruncation;
|
||||
|
||||
const formattedEntries = chatHistory.map((entry, index) => {
|
||||
const roleLabel = entry.role === 'user' ? 'USER' : 'ASSISTANT';
|
||||
const timeInfo = entry.timeEnd ? ` [Story Time: ${this.formatTime(entry.timeEnd)}]` : '';
|
||||
// Truncate by words (0 = no truncation)
|
||||
const truncatedContent = this.truncateToWords(entry.content, truncationLimit);
|
||||
return `[${index + 1}] ${roleLabel}${timeInfo}:\n${truncatedContent}`;
|
||||
}).join('\n\n');
|
||||
|
||||
return `
|
||||
## Chat History (with story time)
|
||||
The following is the visible chat history. Use this to understand context and track when time was last advanced.
|
||||
Each ASSISTANT message shows the story time AFTER that message (timeEnd).
|
||||
|
||||
${formattedEntries}
|
||||
`;
|
||||
}
|
||||
|
||||
private buildClassificationPrompt(context: ClassificationContext): string {
|
||||
// Include traits for characters so the classifier can decide when to prune
|
||||
const existingCharacterInfo = context.existingCharacters.map(c => {
|
||||
|
|
@ -226,17 +286,26 @@ export class ClassifierService {
|
|||
? "plot_point|revelation|milestone|event"
|
||||
: "quest|revelation|milestone|event";
|
||||
|
||||
// Build chat history block if available
|
||||
const chatHistoryBlock = this.buildChatHistoryBlock(context.chatHistory);
|
||||
|
||||
// Build current time reference
|
||||
const currentTimeInfo = context.currentStoryTime
|
||||
? `Current Story Time: ${this.formatTime(context.currentStoryTime)}`
|
||||
: '';
|
||||
|
||||
return `Analyze this narrative passage and extract world state changes.
|
||||
|
||||
## Context
|
||||
${context.genre ? `Genre: ${context.genre}` : ''}
|
||||
Mode: ${isCreativeMode ? 'Creative Writing (author directing the story)' : 'Adventure (player as protagonist)'}
|
||||
Already tracking: ${existingCharacterInfo.length} characters, ${existingLocationNames.length} locations, ${existingItemNames.length} items
|
||||
|
||||
${currentTimeInfo}
|
||||
${chatHistoryBlock}
|
||||
## ${inputLabel}
|
||||
"${context.userAction}"
|
||||
|
||||
## The Narrative Response
|
||||
## The Narrative Response (to classify)
|
||||
"""
|
||||
${context.narrativeResponse}
|
||||
"""
|
||||
|
|
@ -302,6 +371,11 @@ newStoryBeats: [{"title": "Short Title", "description": "what happened or was le
|
|||
scene.currentLocationName: ${sceneLocationDesc}
|
||||
scene.presentCharacterNames: Names of characters physically present in the scene
|
||||
scene.timeProgression: How much time passed - "none", "minutes", "hours", or "days"
|
||||
- Look at the chat history timestamps to see when time was last advanced
|
||||
- If multiple messages have passed without time advancing, consider whether this narrative should advance time
|
||||
- Actions like traveling, sleeping, waiting, or scene transitions typically warrant time progression
|
||||
- Quick dialogue exchanges or immediate actions within the same scene may be "none"
|
||||
- IMPORTANT: If 3 or more messages have passed without any time advancement, you should advance time by at least "minutes" - even casual dialogue takes time in the story world
|
||||
|
||||
Return valid JSON only. Empty arrays are fine - don't invent entities that aren't clearly in the text.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { OpenAIProvider as OpenAIProvider } from './openrouter';
|
||||
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
|
||||
import { ClassifierService, type ClassificationResult, type ClassificationContext } from './classifier';
|
||||
import { ClassifierService, type ClassificationResult, type ClassificationContext, type ClassificationChatEntry } from './classifier';
|
||||
import { MemoryService, type ChapterAnalysis, type ChapterSummary, type RetrievalDecision, DEFAULT_MEMORY_CONFIG } from './memory';
|
||||
import { SuggestionsService, type StorySuggestion, type SuggestionsResult } from './suggestions';
|
||||
import { ActionChoicesService, type ActionChoice, type ActionChoicesResult } from './actionChoices';
|
||||
|
|
@ -13,7 +13,7 @@ import { ContextBuilder, type ContextResult, type ContextConfig, DEFAULT_CONTEXT
|
|||
import { EntryRetrievalService, getEntryRetrievalConfigFromSettings, type EntryRetrievalResult, type ActivationTracker } from './entryRetrieval';
|
||||
import { buildExtraBody } from './requestOverrides';
|
||||
import type { Message, GenerationResponse, StreamChunk } from './types';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, MemoryConfig, Entry, LoreManagementResult } from '$lib/types';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, MemoryConfig, Entry, LoreManagementResult, TimeTracker } from '$lib/types';
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
|
|
@ -309,17 +309,31 @@ class AIService {
|
|||
narrativeResponse: string,
|
||||
userAction: string,
|
||||
worldState: WorldState,
|
||||
story?: Story | null
|
||||
story?: Story | null,
|
||||
visibleEntries?: StoryEntry[],
|
||||
currentStoryTime?: TimeTracker | null
|
||||
): Promise<ClassificationResult> {
|
||||
log('classifyResponse called', {
|
||||
responseLength: narrativeResponse.length,
|
||||
userActionLength: userAction.length,
|
||||
genre: story?.genre,
|
||||
visibleEntriesCount: visibleEntries?.length ?? 0,
|
||||
currentStoryTime,
|
||||
});
|
||||
|
||||
const provider = this.getProviderForProfile(settings.systemServicesSettings.classifier.profileId);
|
||||
const classifier = new ClassifierService(provider);
|
||||
|
||||
// Build chat history from visible entries with time metadata
|
||||
const chatHistory: ClassificationChatEntry[] = (visibleEntries ?? [])
|
||||
.filter(e => e.type === 'user_action' || e.type === 'narration')
|
||||
.map(e => ({
|
||||
role: e.type === 'user_action' ? 'user' as const : 'assistant' as const,
|
||||
content: e.content,
|
||||
timeStart: e.metadata?.timeStart ?? null,
|
||||
timeEnd: e.metadata?.timeEnd ?? null,
|
||||
}));
|
||||
|
||||
const context: ClassificationContext = {
|
||||
narrativeResponse,
|
||||
userAction,
|
||||
|
|
@ -329,6 +343,8 @@ class AIService {
|
|||
existingStoryBeats: worldState.storyBeats,
|
||||
genre: story?.genre ?? null,
|
||||
storyMode: story?.mode ?? 'adventure',
|
||||
chatHistory,
|
||||
currentStoryTime,
|
||||
};
|
||||
|
||||
const result = await classifier.classify(context);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
EntryPreview,
|
||||
PersistentRetryState,
|
||||
PersistentStyleReviewState,
|
||||
TimeTracker,
|
||||
} from '$lib/types';
|
||||
|
||||
class DatabaseService {
|
||||
|
|
@ -85,9 +86,10 @@ class DatabaseService {
|
|||
settings,
|
||||
memory_config,
|
||||
retry_state,
|
||||
style_review_state
|
||||
style_review_state,
|
||||
time_tracker
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
story.id,
|
||||
story.title,
|
||||
|
|
@ -101,6 +103,7 @@ class DatabaseService {
|
|||
story.memoryConfig ? JSON.stringify(story.memoryConfig) : null,
|
||||
story.retryState ? JSON.stringify(story.retryState) : null,
|
||||
story.styleReviewState ? JSON.stringify(story.styleReviewState) : null,
|
||||
story.timeTracker ? JSON.stringify(story.timeTracker) : null,
|
||||
]
|
||||
);
|
||||
return { ...story, createdAt: now, updatedAt: now };
|
||||
|
|
@ -144,6 +147,10 @@ class DatabaseService {
|
|||
setClauses.push('style_review_state = ?');
|
||||
values.push(updates.styleReviewState ? JSON.stringify(updates.styleReviewState) : null);
|
||||
}
|
||||
if (updates.timeTracker !== undefined) {
|
||||
setClauses.push('time_tracker = ?');
|
||||
values.push(updates.timeTracker ? JSON.stringify(updates.timeTracker) : null);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
await db.execute(
|
||||
|
|
@ -196,6 +203,28 @@ class DatabaseService {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save time tracker for a story.
|
||||
*/
|
||||
async saveTimeTracker(storyId: string, timeTracker: TimeTracker): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'UPDATE stories SET time_tracker = ? WHERE id = ?',
|
||||
[JSON.stringify(timeTracker), storyId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear time tracker for a story.
|
||||
*/
|
||||
async clearTimeTracker(storyId: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'UPDATE stories SET time_tracker = NULL WHERE id = ?',
|
||||
[storyId]
|
||||
);
|
||||
}
|
||||
|
||||
async deleteStory(id: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute('DELETE FROM stories WHERE id = ?', [id]);
|
||||
|
|
@ -537,9 +566,9 @@ class DatabaseService {
|
|||
await db.execute(
|
||||
`INSERT INTO chapters (
|
||||
id, story_id, number, title, start_entry_id, end_entry_id, entry_count,
|
||||
summary, keywords, characters, locations, plot_threads, emotional_tone,
|
||||
summary, start_time, end_time, keywords, characters, locations, plot_threads, emotional_tone,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
chapter.id,
|
||||
chapter.storyId,
|
||||
|
|
@ -549,6 +578,8 @@ class DatabaseService {
|
|||
chapter.endEntryId,
|
||||
chapter.entryCount,
|
||||
chapter.summary,
|
||||
chapter.startTime ? JSON.stringify(chapter.startTime) : null,
|
||||
chapter.endTime ? JSON.stringify(chapter.endTime) : null,
|
||||
JSON.stringify(chapter.keywords),
|
||||
JSON.stringify(chapter.characters),
|
||||
JSON.stringify(chapter.locations),
|
||||
|
|
@ -566,6 +597,8 @@ class DatabaseService {
|
|||
|
||||
if (updates.title !== undefined) { setClauses.push('title = ?'); values.push(updates.title); }
|
||||
if (updates.summary !== undefined) { setClauses.push('summary = ?'); values.push(updates.summary); }
|
||||
if (updates.startTime !== undefined) { setClauses.push('start_time = ?'); values.push(updates.startTime ? JSON.stringify(updates.startTime) : null); }
|
||||
if (updates.endTime !== undefined) { setClauses.push('end_time = ?'); values.push(updates.endTime ? JSON.stringify(updates.endTime) : null); }
|
||||
if (updates.keywords !== undefined) { setClauses.push('keywords = ?'); values.push(JSON.stringify(updates.keywords)); }
|
||||
if (updates.characters !== undefined) { setClauses.push('characters = ?'); values.push(JSON.stringify(updates.characters)); }
|
||||
if (updates.locations !== undefined) { setClauses.push('locations = ?'); values.push(JSON.stringify(updates.locations)); }
|
||||
|
|
@ -607,8 +640,8 @@ class DatabaseService {
|
|||
`INSERT INTO checkpoints (
|
||||
id, story_id, name, last_entry_id, last_entry_preview, entry_count,
|
||||
entries_snapshot, characters_snapshot, locations_snapshot,
|
||||
items_snapshot, story_beats_snapshot, chapters_snapshot, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
items_snapshot, story_beats_snapshot, chapters_snapshot, time_tracker_snapshot, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
checkpoint.id,
|
||||
checkpoint.storyId,
|
||||
|
|
@ -622,6 +655,7 @@ class DatabaseService {
|
|||
JSON.stringify(checkpoint.itemsSnapshot),
|
||||
JSON.stringify(checkpoint.storyBeatsSnapshot),
|
||||
JSON.stringify(checkpoint.chaptersSnapshot),
|
||||
checkpoint.timeTrackerSnapshot ? JSON.stringify(checkpoint.timeTrackerSnapshot) : null,
|
||||
checkpoint.createdAt,
|
||||
]
|
||||
);
|
||||
|
|
@ -915,6 +949,7 @@ class DatabaseService {
|
|||
memoryConfig: row.memory_config ? JSON.parse(row.memory_config) : null,
|
||||
retryState: row.retry_state ? JSON.parse(row.retry_state) : null,
|
||||
styleReviewState: row.style_review_state ? JSON.parse(row.style_review_state) : null,
|
||||
timeTracker: row.time_tracker ? JSON.parse(row.time_tracker) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1007,6 +1042,8 @@ class DatabaseService {
|
|||
endEntryId: row.end_entry_id,
|
||||
entryCount: row.entry_count,
|
||||
summary: row.summary,
|
||||
startTime: row.start_time ? JSON.parse(row.start_time) : null,
|
||||
endTime: row.end_time ? JSON.parse(row.end_time) : null,
|
||||
keywords: row.keywords ? JSON.parse(row.keywords) : [],
|
||||
characters: row.characters ? JSON.parse(row.characters) : [],
|
||||
locations: row.locations ? JSON.parse(row.locations) : [],
|
||||
|
|
@ -1030,6 +1067,8 @@ class DatabaseService {
|
|||
itemsSnapshot: row.items_snapshot ? JSON.parse(row.items_snapshot) : [],
|
||||
storyBeatsSnapshot: row.story_beats_snapshot ? JSON.parse(row.story_beats_snapshot) : [],
|
||||
chaptersSnapshot: row.chapters_snapshot ? JSON.parse(row.chapters_snapshot) : [],
|
||||
// Use null when missing - old checkpoints without time tracking should reset time to null on restore
|
||||
timeTrackerSnapshot: row.time_tracker_snapshot ? JSON.parse(row.time_tracker_snapshot) : null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,49 @@ export interface AventuraExport {
|
|||
storyBeats: StoryBeat[];
|
||||
lorebookEntries?: Entry[]; // Added in v1.1.0
|
||||
styleReviewState?: PersistentStyleReviewState | null; // Added in v1.2.0
|
||||
// Note: story.timeTracker added in v1.3.0
|
||||
}
|
||||
|
||||
// Version history for import compatibility
|
||||
// v1.0.0 - Initial release
|
||||
// v1.1.0 - Added lorebookEntries
|
||||
// v1.2.0 - Added styleReviewState
|
||||
// v1.3.0 - Added timeTracker to story, entry metadata (timeStart/timeEnd)
|
||||
|
||||
class ExportService {
|
||||
private readonly VERSION = '1.2.0';
|
||||
private readonly VERSION = '1.3.0';
|
||||
|
||||
/**
|
||||
* Compare semantic versions. Returns:
|
||||
* - negative if a < b
|
||||
* - 0 if a === b
|
||||
* - positive if a > b
|
||||
*/
|
||||
private compareVersions(a: string, b: string): number {
|
||||
const partsA = a.split('.').map(Number);
|
||||
const partsB = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const partA = partsA[i] ?? 0;
|
||||
const partB = partsB[i] ?? 0;
|
||||
if (partA !== partB) return partA - partB;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warnings for imports from older versions that may be missing features.
|
||||
*/
|
||||
private logVersionCompatibilityWarnings(importVersion: string): void {
|
||||
if (this.compareVersions(importVersion, '1.1.0') < 0) {
|
||||
console.warn(`[Import] File from v${importVersion} predates lorebook entries (v1.1.0). Lorebook will be empty.`);
|
||||
}
|
||||
if (this.compareVersions(importVersion, '1.2.0') < 0) {
|
||||
console.warn(`[Import] File from v${importVersion} predates style review state (v1.2.0). Style analysis history will be empty.`);
|
||||
}
|
||||
if (this.compareVersions(importVersion, '1.3.0') < 0) {
|
||||
console.warn(`[Import] File from v${importVersion} predates time tracking (v1.3.0). Time tracker will start at zero.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export to Aventura format (.avt - JSON)
|
||||
async exportToAventura(
|
||||
|
|
@ -210,6 +249,9 @@ class ExportService {
|
|||
return { success: false, error: 'Invalid story file: The file contains no story entries.' };
|
||||
}
|
||||
|
||||
// Log warnings for older export versions that may be missing newer features
|
||||
this.logVersionCompatibilityWarnings(data.version);
|
||||
|
||||
// Generate new IDs to avoid conflicts
|
||||
const oldToNewId = new Map<string, string>();
|
||||
|
||||
|
|
@ -229,6 +271,7 @@ class ExportService {
|
|||
memoryConfig: data.story.memoryConfig || null,
|
||||
retryState: null, // Clear retry state on import
|
||||
styleReviewState: data.styleReviewState ?? null, // Restore style review state from export (v1.2.0+)
|
||||
timeTracker: data.story.timeTracker ?? null, // Restore time tracker from export
|
||||
};
|
||||
|
||||
await database.createStory(importedStory);
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ class SyncService {
|
|||
]);
|
||||
|
||||
const exportData: AventuraExport = {
|
||||
version: '1.2.0',
|
||||
version: '1.3.0',
|
||||
exportedAt: Date.now(),
|
||||
story: storyData,
|
||||
entries,
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ export interface ClassifierSettings {
|
|||
reasoningEffort: ReasoningEffort;
|
||||
providerOnly: string[];
|
||||
manualBody: string;
|
||||
chatHistoryTruncation: number; // Max words per chat history entry (0 = no truncation, up to 500)
|
||||
}
|
||||
|
||||
export function getDefaultClassifierSettings(): ClassifierSettings {
|
||||
|
|
@ -379,6 +380,7 @@ export function getDefaultClassifierSettings(): ClassifierSettings {
|
|||
reasoningEffort: 'medium',
|
||||
providerOnly: [],
|
||||
manualBody: '',
|
||||
chatHistoryTruncation: 100, // Default: truncate to 100 words per entry
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -395,6 +397,7 @@ export function getDefaultClassifierSettingsForProvider(provider: ProviderPreset
|
|||
reasoningEffort: 'medium',
|
||||
providerOnly: [],
|
||||
manualBody: '',
|
||||
chatHistoryTruncation: 100, // Default: truncate to 100 words per entry
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, Checkpoint, MemoryConfig, StoryMode, StorySettings, Entry } from '$lib/types';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Chapter, Checkpoint, MemoryConfig, StoryMode, StorySettings, Entry, TimeTracker } from '$lib/types';
|
||||
import { database } from '$lib/services/database';
|
||||
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
|
||||
import { ui } from './ui.svelte';
|
||||
|
|
@ -99,6 +99,10 @@ class StoryStore {
|
|||
return this.currentStory?.mode || 'adventure';
|
||||
}
|
||||
|
||||
get timeTracker(): TimeTracker {
|
||||
return this.currentStory?.timeTracker || { years: 0, days: 0, hours: 0, minutes: 0 };
|
||||
}
|
||||
|
||||
get lastChapterEndIndex(): number {
|
||||
if (this.chapters.length === 0) return 0;
|
||||
|
||||
|
|
@ -350,6 +354,7 @@ class StoryStore {
|
|||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
styleReviewState: null,
|
||||
timeTracker: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
@ -382,6 +387,7 @@ class StoryStore {
|
|||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
styleReviewState: null,
|
||||
timeTracker: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
@ -440,6 +446,7 @@ class StoryStore {
|
|||
// Add opening scene as first narration entry
|
||||
if (state.openingScene) {
|
||||
const tokenCount = countTokens(state.openingScene);
|
||||
const baseTime = storyData.timeTracker ?? { years: 0, days: 0, hours: 0, minutes: 0 };
|
||||
await database.addStoryEntry({
|
||||
id: crypto.randomUUID(),
|
||||
storyId: storyData.id,
|
||||
|
|
@ -447,7 +454,7 @@ class StoryStore {
|
|||
content: state.openingScene,
|
||||
parentId: null,
|
||||
position: 0,
|
||||
metadata: { source: 'template', tokenCount },
|
||||
metadata: { source: 'template', tokenCount, timeStart: { ...baseTime }, timeEnd: { ...baseTime } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -467,6 +474,13 @@ class StoryStore {
|
|||
// Count tokens for accurate auto-summarize threshold detection
|
||||
const tokenCount = countTokens(content);
|
||||
|
||||
// Capture current story time as timeStart for this entry
|
||||
// timeEnd defaults to timeStart; for narration entries, timeEnd is updated after classification
|
||||
const timeStart = this.currentStory.timeTracker
|
||||
? { ...this.currentStory.timeTracker }
|
||||
: { years: 0, days: 0, hours: 0, minutes: 0 };
|
||||
const timeEnd = { ...timeStart };
|
||||
|
||||
const position = await database.getNextEntryPosition(this.currentStory.id);
|
||||
const entry = await database.addStoryEntry({
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -475,7 +489,7 @@ class StoryStore {
|
|||
content,
|
||||
parentId: null,
|
||||
position,
|
||||
metadata: { ...metadata, tokenCount },
|
||||
metadata: { ...metadata, tokenCount, timeStart, timeEnd },
|
||||
});
|
||||
|
||||
this.entries = [...this.entries, entry];
|
||||
|
|
@ -515,6 +529,34 @@ class StoryStore {
|
|||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an entry's timeEnd metadata after classification applies time progression.
|
||||
* Called after applyClassificationResult to record the story time after the entry's events.
|
||||
*/
|
||||
async updateEntryTimeEnd(entryId: string): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const entry = this.entries.find(e => e.id === entryId);
|
||||
if (!entry) {
|
||||
log('updateEntryTimeEnd: Entry not found', entryId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture current story time as timeEnd
|
||||
const timeEnd = this.currentStory.timeTracker
|
||||
? { ...this.currentStory.timeTracker }
|
||||
: { years: 0, days: 0, hours: 0, minutes: 0 };
|
||||
|
||||
const updatedMetadata = { ...entry.metadata, timeEnd };
|
||||
|
||||
await database.updateStoryEntry(entryId, { metadata: updatedMetadata });
|
||||
this.entries = this.entries.map(e =>
|
||||
e.id === entryId ? { ...e, metadata: updatedMetadata } : e
|
||||
);
|
||||
|
||||
log('Entry timeEnd updated', { entryId, timeEnd });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all entries from a given position onward.
|
||||
* Used for entry-only retry restore (persistent retry).
|
||||
|
|
@ -1214,6 +1256,11 @@ class StoryStore {
|
|||
}
|
||||
}
|
||||
|
||||
// Apply time progression from scene data
|
||||
if (result.scene.timeProgression && result.scene.timeProgression !== 'none') {
|
||||
await this.applyTimeProgression(result.scene.timeProgression);
|
||||
}
|
||||
|
||||
log('applyClassificationResult complete', {
|
||||
characters: this.characters.length,
|
||||
locations: this.locations.length,
|
||||
|
|
@ -1349,6 +1396,128 @@ class StoryStore {
|
|||
log('Memory config updated via updateMemoryConfig:', updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize time values, converting overflow/underflow between units.
|
||||
* Handles both positive overflow (60 min → 1 hour) and negative underflow (borrowing).
|
||||
* 60 minutes → 1 hour, 24 hours → 1 day, 365 days → 1 year
|
||||
*/
|
||||
private normalizeTime(time: TimeTracker): TimeTracker {
|
||||
let { years, days, hours, minutes } = time;
|
||||
|
||||
// Handle negative minutes by borrowing from hours
|
||||
while (minutes < 0 && hours > 0) {
|
||||
hours -= 1;
|
||||
minutes += 60;
|
||||
}
|
||||
|
||||
// Handle negative hours by borrowing from days
|
||||
while (hours < 0 && days > 0) {
|
||||
days -= 1;
|
||||
hours += 24;
|
||||
}
|
||||
|
||||
// Handle negative days by borrowing from years
|
||||
while (days < 0 && years > 0) {
|
||||
years -= 1;
|
||||
days += 365;
|
||||
}
|
||||
|
||||
// Clamp any remaining negatives to 0 (can't have negative time)
|
||||
years = Math.max(0, years);
|
||||
days = Math.max(0, days);
|
||||
hours = Math.max(0, hours);
|
||||
minutes = Math.max(0, minutes);
|
||||
|
||||
// Normalize overflow: minutes to hours
|
||||
if (minutes >= 60) {
|
||||
hours += Math.floor(minutes / 60);
|
||||
minutes = minutes % 60;
|
||||
}
|
||||
|
||||
// Normalize overflow: hours to days
|
||||
if (hours >= 24) {
|
||||
days += Math.floor(hours / 24);
|
||||
hours = hours % 24;
|
||||
}
|
||||
|
||||
// Normalize overflow: days to years
|
||||
if (days >= 365) {
|
||||
years += Math.floor(days / 365);
|
||||
days = days % 365;
|
||||
}
|
||||
|
||||
return { years, days, hours, minutes };
|
||||
}
|
||||
|
||||
// Set time tracker directly
|
||||
async setTimeTracker(time: TimeTracker): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const normalized = this.normalizeTime(time);
|
||||
await database.saveTimeTracker(this.currentStory.id, normalized);
|
||||
this.currentStory = { ...this.currentStory, timeTracker: normalized };
|
||||
log('Time tracker set:', normalized);
|
||||
}
|
||||
|
||||
// Update time tracker with partial values (adds to current time)
|
||||
async addTime(updates: Partial<TimeTracker>): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const current = this.timeTracker;
|
||||
const newTime: TimeTracker = {
|
||||
years: current.years + (updates.years ?? 0),
|
||||
days: current.days + (updates.days ?? 0),
|
||||
hours: current.hours + (updates.hours ?? 0),
|
||||
minutes: current.minutes + (updates.minutes ?? 0),
|
||||
};
|
||||
|
||||
const normalized = this.normalizeTime(newTime);
|
||||
await database.saveTimeTracker(this.currentStory.id, normalized);
|
||||
this.currentStory = { ...this.currentStory, timeTracker: normalized };
|
||||
log('Time added:', updates, '→', normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply time progression from classifier result.
|
||||
* Adds a default amount based on the progression type.
|
||||
*/
|
||||
async applyTimeProgression(progression: 'none' | 'minutes' | 'hours' | 'days'): Promise<void> {
|
||||
if (progression === 'none') return;
|
||||
|
||||
// Default increments for each progression type
|
||||
const increments: Record<string, Partial<TimeTracker>> = {
|
||||
minutes: { minutes: 15 }, // ~15 minutes for minor actions
|
||||
hours: { hours: 2 }, // ~2 hours for moderate time passage
|
||||
days: { days: 1 }, // 1 day for significant time jumps
|
||||
};
|
||||
|
||||
const increment = increments[progression];
|
||||
if (increment) {
|
||||
await this.addTime(increment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore or clear the story time tracker from a snapshot.
|
||||
* Undefined means "skip", null means "clear".
|
||||
*/
|
||||
async restoreTimeTrackerSnapshot(snapshot: TimeTracker | null | undefined): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
if (snapshot === undefined) return;
|
||||
|
||||
if (snapshot === null) {
|
||||
await database.clearTimeTracker(this.currentStory.id);
|
||||
this.currentStory = { ...this.currentStory, timeTracker: null };
|
||||
log('Time tracker cleared from snapshot');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = this.normalizeTime(snapshot);
|
||||
await database.saveTimeTracker(this.currentStory.id, normalized);
|
||||
this.currentStory = { ...this.currentStory, timeTracker: normalized };
|
||||
log('Time tracker restored from snapshot:', normalized);
|
||||
}
|
||||
|
||||
// Create a manual chapter at a specific entry index
|
||||
async createManualChapter(endEntryIndex: number): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
|
@ -1379,6 +1548,12 @@ class StoryStore {
|
|||
// Get the next chapter number
|
||||
const chapterNumber = await this.getNextChapterNumber();
|
||||
|
||||
// Extract time range from entries' metadata
|
||||
const firstEntry = chapterEntries[0];
|
||||
const lastEntry = chapterEntries[chapterEntries.length - 1];
|
||||
const startTime = firstEntry.metadata?.timeStart ?? null;
|
||||
const endTime = lastEntry.metadata?.timeEnd ?? null;
|
||||
|
||||
// Create the chapter
|
||||
const chapter: Chapter = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -1389,6 +1564,8 @@ class StoryStore {
|
|||
endEntryId: chapterEntries[chapterEntries.length - 1].id,
|
||||
entryCount: chapterEntries.length,
|
||||
summary: chapterData.summary,
|
||||
startTime,
|
||||
endTime,
|
||||
keywords: chapterData.keywords,
|
||||
characters: chapterData.characters,
|
||||
locations: chapterData.locations,
|
||||
|
|
@ -1421,6 +1598,7 @@ class StoryStore {
|
|||
itemsSnapshot: [...this.items],
|
||||
storyBeatsSnapshot: [...this.storyBeats],
|
||||
chaptersSnapshot: [...this.chapters],
|
||||
timeTrackerSnapshot: this.currentStory.timeTracker ? { ...this.currentStory.timeTracker } : null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
|
|
@ -1455,6 +1633,9 @@ class StoryStore {
|
|||
// Sort chapters by number to ensure correct ordering
|
||||
this.chapters = [...checkpoint.chaptersSnapshot].sort((a, b) => a.number - b.number);
|
||||
|
||||
// Restore time tracker (null clears)
|
||||
await this.restoreTimeTrackerSnapshot(checkpoint.timeTrackerSnapshot);
|
||||
|
||||
log('Checkpoint restored');
|
||||
|
||||
// Emit event
|
||||
|
|
@ -1480,6 +1661,7 @@ class StoryStore {
|
|||
items: Item[];
|
||||
storyBeats: StoryBeat[];
|
||||
lorebookEntries: Entry[];
|
||||
timeTracker?: TimeTracker | null;
|
||||
}): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
|
|
@ -1507,6 +1689,9 @@ class StoryStore {
|
|||
this.storyBeats = [...backup.storyBeats];
|
||||
this.lorebookEntries = [...backup.lorebookEntries];
|
||||
|
||||
// Restore time tracker if provided (null clears)
|
||||
await this.restoreTimeTrackerSnapshot(backup.timeTracker);
|
||||
|
||||
log('Retry backup restored', {
|
||||
entries: this.entries.length,
|
||||
characters: this.characters.length,
|
||||
|
|
@ -1565,6 +1750,7 @@ class StoryStore {
|
|||
memoryConfig: DEFAULT_MEMORY_CONFIG,
|
||||
retryState: null,
|
||||
styleReviewState: null,
|
||||
timeTracker: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
|
@ -1638,6 +1824,7 @@ class StoryStore {
|
|||
// Add opening scene as first narration entry
|
||||
if (data.openingScene) {
|
||||
const tokenCount = countTokens(data.openingScene);
|
||||
const baseTime = storyData.timeTracker ?? { years: 0, days: 0, hours: 0, minutes: 0 };
|
||||
await database.addStoryEntry({
|
||||
id: crypto.randomUUID(),
|
||||
storyId,
|
||||
|
|
@ -1645,7 +1832,7 @@ class StoryStore {
|
|||
content: data.openingScene,
|
||||
parentId: null,
|
||||
position: 0,
|
||||
metadata: { source: 'wizard', tokenCount },
|
||||
metadata: { source: 'wizard', tokenCount, timeStart: { ...baseTime }, timeEnd: { ...baseTime } },
|
||||
});
|
||||
log('Added opening scene');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry, ActionInputType, PersistentStyleReviewState, PersistentStyleReviewResult } from '$lib/types';
|
||||
import type { ActivePanel, SidebarTab, UIState, EntryType, StoryEntry, Character, Location, Item, StoryBeat, Entry, ActionInputType, PersistentStyleReviewState, PersistentStyleReviewResult, TimeTracker } 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';
|
||||
|
|
@ -51,6 +51,8 @@ export interface RetryBackup {
|
|||
itemIds: string[];
|
||||
storyBeatIds: string[];
|
||||
lorebookEntryIds: string[];
|
||||
// Time tracker snapshot (undefined means "don't restore", null means "clear it")
|
||||
timeTracker: TimeTracker | null | undefined;
|
||||
}
|
||||
|
||||
// Error state for retry functionality
|
||||
|
|
@ -275,7 +277,8 @@ class UIStore {
|
|||
userActionContent: string,
|
||||
rawInput: string,
|
||||
actionType: ActionInputType,
|
||||
wasRawActionChoice: boolean
|
||||
wasRawActionChoice: boolean,
|
||||
timeTracker: TimeTracker | null
|
||||
) {
|
||||
const timestamp = Date.now();
|
||||
const nextEntryPosition = entries.reduce((max, entry) => Math.max(max, entry.position ?? -1), -1) + 1;
|
||||
|
|
@ -315,6 +318,8 @@ class UIStore {
|
|||
itemIds,
|
||||
storyBeatIds,
|
||||
lorebookEntryIds,
|
||||
// Time tracker snapshot
|
||||
timeTracker: timeTracker ? { ...timeTracker } : null,
|
||||
};
|
||||
this.retryBackups.set(storyId, backup);
|
||||
this.currentRetryStoryId = storyId;
|
||||
|
|
@ -333,6 +338,7 @@ class UIStore {
|
|||
itemIds,
|
||||
storyBeatIds,
|
||||
lorebookEntryIds,
|
||||
timeTracker: timeTracker ? { ...timeTracker } : null,
|
||||
}),
|
||||
'persist'
|
||||
);
|
||||
|
|
@ -394,6 +400,7 @@ class UIStore {
|
|||
itemIds?: string[];
|
||||
storyBeatIds?: string[];
|
||||
lorebookEntryIds?: string[];
|
||||
timeTracker?: TimeTracker | null;
|
||||
}) {
|
||||
// Skip if we already have an in-memory backup for this story (it's more complete)
|
||||
if (this.retryBackups.has(storyId)) {
|
||||
|
|
@ -448,6 +455,10 @@ class UIStore {
|
|||
itemIds: retryState.itemIds ?? [],
|
||||
storyBeatIds: retryState.storyBeatIds ?? [],
|
||||
lorebookEntryIds: retryState.lorebookEntryIds ?? [],
|
||||
// Time tracker snapshot (undefined means "skip restore", null means "clear")
|
||||
timeTracker: Object.prototype.hasOwnProperty.call(retryState, 'timeTracker')
|
||||
? retryState.timeTracker ?? null
|
||||
: undefined,
|
||||
};
|
||||
this.retryBackups.set(storyId, backup);
|
||||
console.log('[UI] Retry backup loaded from persistent state', {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ export type StoryMode = 'adventure' | 'creative-writing';
|
|||
export type POV = 'first' | 'second' | 'third';
|
||||
export type Tense = 'past' | 'present';
|
||||
|
||||
// Time tracking for story progression
|
||||
export interface TimeTracker {
|
||||
years: number;
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -17,6 +25,7 @@ export interface Story {
|
|||
memoryConfig: MemoryConfig | null;
|
||||
retryState: PersistentRetryState | null;
|
||||
styleReviewState: PersistentStyleReviewState | null;
|
||||
timeTracker: TimeTracker | null;
|
||||
}
|
||||
|
||||
// Persistent retry state - lightweight version saved to database
|
||||
|
|
@ -38,6 +47,8 @@ export interface PersistentRetryState {
|
|||
itemIds: string[];
|
||||
storyBeatIds: string[];
|
||||
lorebookEntryIds: string[];
|
||||
// Story time snapshot captured before the user action (optional for backwards compatibility)
|
||||
timeTracker?: TimeTracker | null;
|
||||
}
|
||||
|
||||
// Persistent style review state - saved per-story for style analysis tracking
|
||||
|
|
@ -94,6 +105,9 @@ export interface EntryMetadata {
|
|||
model?: string;
|
||||
generationTime?: number;
|
||||
source?: string;
|
||||
// Story time tracking - captures in-story time at entry creation and after classification
|
||||
timeStart?: TimeTracker; // Story time when this entry began
|
||||
timeEnd?: TimeTracker; // Story time after classification applied time progression
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
|
|
@ -174,6 +188,10 @@ export interface Chapter {
|
|||
// Content
|
||||
summary: string;
|
||||
|
||||
// Story time span covered by this chapter
|
||||
startTime: TimeTracker | null;
|
||||
endTime: TimeTracker | null;
|
||||
|
||||
// Retrieval optimization metadata
|
||||
keywords: string[];
|
||||
characters: string[]; // Character names mentioned
|
||||
|
|
@ -202,6 +220,8 @@ export interface Checkpoint {
|
|||
itemsSnapshot: Item[];
|
||||
storyBeatsSnapshot: StoryBeat[];
|
||||
chaptersSnapshot: Chapter[];
|
||||
// Optional: undefined means "preserve current time" on restore (for backward compatibility)
|
||||
timeTrackerSnapshot?: TimeTracker | null;
|
||||
|
||||
createdAt: number;
|
||||
}
|
||||
|
|
@ -395,7 +415,7 @@ export interface AgenticSession {
|
|||
|
||||
// UI State types
|
||||
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory';
|
||||
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests';
|
||||
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests' | 'time';
|
||||
|
||||
export interface UIState {
|
||||
activePanel: ActivePanel;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue