added lorebook editing/saving to the vault

This commit is contained in:
munimunigamer 2026-01-15 17:47:43 -06:00
parent b4a9c5daad
commit 28271736e7
14 changed files with 1710 additions and 337 deletions

View file

@ -0,0 +1,31 @@
-- Lorebook Vault: Global lorebook library for reusable lorebook templates
-- Lorebooks contain processed entries and are copied to stories
CREATE TABLE IF NOT EXISTS lorebook_vault (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
-- Processed entries stored as JSON array
entries TEXT NOT NULL DEFAULT '[]',
-- Organization
tags TEXT NOT NULL DEFAULT '[]',
favorite INTEGER NOT NULL DEFAULT 0,
-- Provenance
source TEXT NOT NULL DEFAULT 'import',
original_filename TEXT,
original_story_id TEXT,
-- Metadata (format, counts, etc.)
metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_lorebook_vault_name ON lorebook_vault(name);
CREATE INDEX IF NOT EXISTS idx_lorebook_vault_favorite ON lorebook_vault(favorite);
CREATE INDEX IF NOT EXISTS idx_lorebook_vault_source ON lorebook_vault(source);

View file

@ -106,6 +106,12 @@ pub fn run() {
sql: include_str!("../migrations/016_character_vault.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 17,
description: "lorebook_vault",
sql: include_str!("../migrations/017_lorebook_vault.sql"),
kind: MigrationKind::Up,
},
];
tauri::Builder::default()

View file

@ -8,7 +8,7 @@
import LibraryView from '$lib/components/story/LibraryView.svelte';
import LorebookView from '$lib/components/lorebook/LorebookView.svelte';
import MemoryView from '$lib/components/memory/MemoryView.svelte';
import CharacterVaultPanel from '$lib/components/vault/CharacterVaultPanel.svelte';
import VaultPanel from '$lib/components/vault/VaultPanel.svelte';
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
import LorebookDebugPanel from '$lib/components/debug/LorebookDebugPanel.svelte';
import DebugLogModal from '$lib/components/debug/DebugLogModal.svelte';
@ -72,8 +72,8 @@
<LorebookView />
{:else if ui.activePanel === 'memory' && story.currentStory}
<MemoryView />
{:else if ui.activePanel === 'character-vault'}
<CharacterVaultPanel />
{:else if ui.activePanel === 'vault'}
<VaultPanel />
{:else if ui.activePanel === 'library' || !story.currentStory}
<LibraryView />
{:else if children}

View file

@ -184,8 +184,8 @@
</button>
<button
class="btn btn-secondary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
onclick={() => ui.setActivePanel('character-vault')}
title="Character Vault - Manage reusable character templates"
onclick={() => ui.setActivePanel('vault')}
title="Vault - Manage reusable characters and lorebooks"
>
<Archive class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="hidden xs:inline">Vault</span>

View file

@ -1,295 +0,0 @@
<script lang="ts">
import { characterVault } from '$lib/stores/characterVault.svelte';
import { ui } from '$lib/stores/ui.svelte';
import type { VaultCharacter, VaultCharacterType } from '$lib/types';
import {
Plus, Search, Star, User, Users, ChevronLeft, Upload, Loader2
} from 'lucide-svelte';
import VaultCharacterCard from './VaultCharacterCard.svelte';
import VaultCharacterForm from './VaultCharacterForm.svelte';
import { readCharacterCardFile, parseCharacterCard } from '$lib/services/characterCardImporter';
// State
let searchQuery = $state('');
let filterType = $state<VaultCharacterType | 'all'>('all');
let showFavoritesOnly = $state(false);
let showForm = $state(false);
let editingCharacter = $state<VaultCharacter | null>(null);
let defaultFormType = $state<VaultCharacterType>('protagonist');
let importing = $state(false);
let importError = $state<string | null>(null);
// Filtered characters
const filteredCharacters = $derived.by(() => {
let chars = characterVault.characters;
// Filter by type
if (filterType !== 'all') {
chars = chars.filter(c => c.characterType === filterType);
}
// Filter favorites
if (showFavoritesOnly) {
chars = chars.filter(c => c.favorite);
}
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
chars = chars.filter(c =>
c.name.toLowerCase().includes(query) ||
c.description?.toLowerCase().includes(query) ||
c.tags.some(t => t.toLowerCase().includes(query)) ||
c.traits.some(t => t.toLowerCase().includes(query))
);
}
return chars;
});
// Load on mount
$effect(() => {
if (!characterVault.isLoaded) {
characterVault.load();
}
});
function openCreateForm(type: VaultCharacterType = 'protagonist') {
editingCharacter = null;
defaultFormType = type;
showForm = true;
}
function openEditForm(character: VaultCharacter) {
editingCharacter = character;
showForm = true;
}
async function handleDelete(id: string) {
await characterVault.delete(id);
}
async function handleToggleFavorite(id: string) {
await characterVault.toggleFavorite(id);
}
function closeForm() {
showForm = false;
editingCharacter = null;
}
async function handleImportCard(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
importing = true;
importError = null;
try {
const jsonString = await readCharacterCardFile(file);
const card = parseCharacterCard(jsonString);
if (!card) {
throw new Error('Invalid character card format');
}
// Extract personality traits
const traits = card.personality
? card.personality.split(/[,;]/).map(t => t.trim()).filter(Boolean).slice(0, 10)
: [];
// Create vault character from card
await characterVault.add({
name: card.name,
description: card.description || null,
characterType: 'supporting', // Default to supporting, user can change
background: null,
motivation: null,
role: null,
relationshipTemplate: null,
traits,
visualDescriptors: [],
portrait: null, // Could extract from PNG if present
tags: ['imported'],
favorite: false,
source: 'import',
originalStoryId: null,
metadata: { cardVersion: card.version },
});
} catch (error) {
console.error('[CharacterVault] Import failed:', error);
importError = error instanceof Error ? error.message : 'Failed to import character card';
} finally {
importing = false;
input.value = '';
}
}
</script>
<div class="flex h-full flex-col bg-surface-900">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 px-4 py-3 sm:px-6 sm:py-4">
<div class="flex items-center gap-3">
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={() => ui.setActivePanel('library')}
title="Back to Library"
>
<ChevronLeft class="h-5 w-5 text-surface-400" />
</button>
<h2 class="text-lg font-semibold text-surface-100">Character Vault</h2>
<span class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
{characterVault.characters.length}
</span>
</div>
<div class="flex items-center gap-2">
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500">
{#if importing}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Upload class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">Import</span>
<input
type="file"
accept=".json,.png"
class="hidden"
onchange={handleImportCard}
disabled={importing}
/>
</label>
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateForm()}
>
<Plus class="h-4 w-4" />
<span class="hidden sm:inline">New Character</span>
</button>
</div>
</div>
<!-- Search and Filters -->
<div class="border-b border-surface-700 px-4 py-3 space-y-3 sm:px-6">
{#if importError}
<div class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between">
<span>{importError}</span>
<button onclick={() => importError = null} class="text-red-400 hover:text-red-300">
<span class="sr-only">Dismiss</span>
&times;
</button>
</div>
{/if}
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search characters..."
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-10 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-1 rounded-lg bg-surface-800 p-1">
<button
class="rounded-md px-3 py-1 text-xs transition-colors {filterType === 'all' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => filterType = 'all'}
>
All
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs transition-colors {filterType === 'protagonist' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => filterType = 'protagonist'}
>
<User class="h-3 w-3" />
Protagonists
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-xs transition-colors {filterType === 'supporting' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => filterType = 'supporting'}
>
<Users class="h-3 w-3" />
Supporting
</button>
</div>
<button
class="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors {showFavoritesOnly ? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400' : 'border-surface-600 text-surface-400 hover:border-surface-500'}"
onclick={() => showFavoritesOnly = !showFavoritesOnly}
>
<Star class="h-3 w-3 {showFavoritesOnly ? 'fill-yellow-400' : ''}" />
Favorites
</button>
</div>
</div>
<!-- Character Grid -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6">
{#if !characterVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
</div>
{:else if filteredCharacters.length === 0}
<div class="flex h-full items-center justify-center">
<div class="text-center">
<Users class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
{#if searchQuery || showFavoritesOnly || filterType !== 'all'}
No characters match your filters
{:else}
No characters in vault yet
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
{#if searchQuery || showFavoritesOnly || filterType !== 'all'}
Try adjusting your search or filters
{:else}
Create reusable character templates for your stories
{/if}
</p>
{#if !searchQuery && !showFavoritesOnly && filterType === 'all'}
<div class="mt-4 flex justify-center gap-2">
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateForm('protagonist')}
>
<User class="h-4 w-4" />
Create Protagonist
</button>
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-4 py-2 text-sm text-surface-300 hover:border-surface-500"
onclick={() => openCreateForm('supporting')}
>
<Users class="h-4 w-4" />
Create Supporting
</button>
</div>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredCharacters as character (character.id)}
<VaultCharacterCard
{character}
onEdit={() => openEditForm(character)}
onDelete={() => handleDelete(character.id)}
onToggleFavorite={() => handleToggleFavorite(character.id)}
/>
{/each}
</div>
{/if}
</div>
</div>
<!-- Form Modal -->
{#if showForm}
<VaultCharacterForm
character={editingCharacter}
defaultType={defaultFormType}
onClose={closeForm}
/>
{/if}

View file

@ -0,0 +1,162 @@
<script lang="ts">
import type { VaultLorebook, EntryType } from '$lib/types';
import { Star, Pencil, Trash2, Archive, Users, MapPin, Box, Flag, Brain, Calendar } from 'lucide-svelte';
interface Props {
lorebook: VaultLorebook;
onEdit?: () => void;
onDelete?: () => void;
onToggleFavorite?: () => void;
selectable?: boolean;
onSelect?: () => void;
}
let { lorebook, onEdit, onDelete, onToggleFavorite, selectable = false, onSelect }: Props = $props();
let confirmingDelete = $state(false);
function handleCardClick() {
if (selectable && onSelect) {
onSelect();
}
}
function handleDelete(e: Event) {
e.stopPropagation();
if (onDelete) {
onDelete();
}
confirmingDelete = false;
}
function handleCancelDelete(e: Event) {
e.stopPropagation();
confirmingDelete = false;
}
function handleConfirmDelete(e: Event) {
e.stopPropagation();
confirmingDelete = true;
}
// Icons for entry types
const typeIcons: Record<EntryType, any> = {
character: Users,
location: MapPin,
item: Box,
faction: Flag,
concept: Brain,
event: Calendar,
};
// Helper to get non-zero entry counts
const entryCounts = $derived.by(() => {
if (!lorebook.metadata?.entryBreakdown) return [];
return Object.entries(lorebook.metadata.entryBreakdown)
.filter(([_, count]) => count > 0)
.map(([type, count]) => ({ type: type as EntryType, count }));
});
</script>
<div
class="rounded-lg border border-surface-700 bg-surface-800 p-4 {selectable ? 'cursor-pointer hover:ring-2 hover:ring-accent-500 transition-all' : ''}"
onclick={handleCardClick}
role={selectable ? "button" : undefined}
tabindex={selectable ? 0 : undefined}
onkeydown={selectable ? (e) => { if (e.key === 'Enter' || e.key === ' ') handleCardClick(); } : undefined}
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-surface-700 flex-shrink-0">
<Archive class="h-6 w-6 text-accent-400" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-surface-100 truncate">{lorebook.name}</h3>
{#if lorebook.favorite}
<Star class="h-4 w-4 text-yellow-400 fill-yellow-400 flex-shrink-0" />
{/if}
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-surface-400">
{lorebook.entries.length} entries
</span>
{#if lorebook.source === 'story'}
<span class="text-xs text-surface-500">• From Story</span>
{:else if lorebook.source === 'import'}
<span class="text-xs text-surface-500">• Imported</span>
{/if}
</div>
{#if lorebook.description}
<p class="mt-2 text-sm text-surface-400 line-clamp-2">{lorebook.description}</p>
{/if}
<!-- Entry Type Breakdown -->
{#if entryCounts.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each entryCounts.slice(0, 4) as { type, count }}
{@const Icon = typeIcons[type]}
<div class="flex items-center gap-1 rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400" title="{type}">
<Icon class="h-3 w-3" />
<span>{count}</span>
</div>
{/each}
{#if entryCounts.length > 4}
<span class="text-xs text-surface-500 self-center">+{entryCounts.length - 4}</span>
{/if}
</div>
{/if}
</div>
</div>
<!-- Actions -->
{#if !selectable && (onEdit || onDelete || onToggleFavorite)}
<div class="mt-3 flex items-center justify-end gap-1 border-t border-surface-700 pt-3">
{#if confirmingDelete}
<button
class="rounded px-2 py-1 text-xs bg-surface-700 text-surface-300 hover:bg-surface-600"
onclick={handleCancelDelete}
>
Cancel
</button>
<button
class="rounded px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30"
onclick={handleDelete}
>
Confirm Delete
</button>
{:else}
{#if onToggleFavorite}
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={(e) => { e.stopPropagation(); onToggleFavorite?.(); }}
title={lorebook.favorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star class="h-4 w-4 {lorebook.favorite ? 'text-yellow-400 fill-yellow-400' : 'text-surface-500'}" />
</button>
{/if}
{#if onEdit}
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={(e) => { e.stopPropagation(); onEdit?.(); }}
title="Edit"
>
<Pencil class="h-4 w-4 text-surface-500 hover:text-surface-200" />
</button>
{/if}
{#if onDelete}
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={handleConfirmDelete}
title="Delete"
>
<Trash2 class="h-4 w-4 text-surface-500 hover:text-red-400" />
</button>
{/if}
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,446 @@
<script lang="ts">
import type { VaultLorebook, VaultLorebookEntry, EntryType, EntryInjectionMode } from '$lib/types';
import { lorebookVault } from '$lib/stores/lorebookVault.svelte';
import {
X, Plus, Search, Trash2, Save, ArrowLeft,
Users, MapPin, Box, Flag, Brain, Calendar,
MoreVertical, AlertCircle, Eye, EyeOff
} from 'lucide-svelte';
import { fade, slide } from 'svelte/transition';
interface Props {
lorebook: VaultLorebook;
onClose: () => void;
}
let { lorebook, onClose }: Props = $props();
// Local state for editing
let name = $state(lorebook.name);
let description = $state(lorebook.description ?? '');
let entries = $state<VaultLorebookEntry[]>(JSON.parse(JSON.stringify(lorebook.entries))); // Deep copy
// UI State
let searchQuery = $state('');
let selectedIndex = $state<number | null>(null);
let showDeleteConfirm = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
let activeTab = $state<'editor' | 'settings'>('editor');
// Filtered entries
const filteredEntries = $derived.by(() => {
if (!searchQuery.trim()) return entries.map((e, i) => ({ entry: e, index: i }));
const q = searchQuery.toLowerCase();
return entries
.map((e, i) => ({ entry: e, index: i }))
.filter(({ entry }) =>
entry.name.toLowerCase().includes(q) ||
entry.keywords.some(k => k.toLowerCase().includes(q))
);
});
const selectedEntry = $derived(selectedIndex !== null ? entries[selectedIndex] : null);
// Type options
const entryTypes: EntryType[] = ['character', 'location', 'item', 'faction', 'concept', 'event'];
const injectionModes: EntryInjectionMode[] = ['always', 'keyword', 'relevant', 'never'];
const typeIcons: Record<EntryType, any> = {
character: Users,
location: MapPin,
item: Box,
faction: Flag,
concept: Brain,
event: Calendar,
};
function handleSave() {
if (!name.trim()) {
error = 'Lorebook name is required';
return;
}
saving = true;
error = null;
// Update metadata entry breakdown
const breakdown: Record<EntryType, number> = {
character: 0, location: 0, item: 0, faction: 0, concept: 0, event: 0
};
entries.forEach(e => {
if (breakdown[e.type] !== undefined) breakdown[e.type]++;
});
lorebookVault.update(lorebook.id, {
name,
description: description || null,
entries,
metadata: {
...lorebook.metadata,
format: lorebook.metadata?.format ?? 'aventura',
totalEntries: entries.length,
entryBreakdown: breakdown
}
}).then(() => {
onClose();
}).catch(e => {
error = e instanceof Error ? e.message : 'Failed to save lorebook';
saving = false;
});
}
function handleAddEntry() {
const newEntry: VaultLorebookEntry = {
name: 'New Entry',
type: 'character',
description: '',
keywords: [],
injectionMode: 'keyword',
priority: 10,
disabled: false,
group: null
};
entries.push(newEntry);
entries = entries; // Trigger update
selectedIndex = entries.length - 1;
activeTab = 'editor';
}
function handleDeleteEntry(index: number) {
entries.splice(index, 1);
entries = entries; // Trigger update
if (selectedIndex === index) {
selectedIndex = null;
} else if (selectedIndex !== null && selectedIndex > index) {
selectedIndex--;
}
}
function handleDuplicateEntry(index: number) {
const entry = entries[index];
const newEntry = JSON.parse(JSON.stringify(entry));
newEntry.name = `${newEntry.name} (Copy)`;
entries.push(newEntry);
entries = entries;
selectedIndex = entries.length - 1;
}
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
>
<div class="flex h-[90vh] w-full max-w-6xl flex-col rounded-lg bg-surface-900 shadow-xl overflow-hidden ring-1 ring-surface-700">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 bg-surface-800 px-6 py-4">
<div class="flex items-center gap-4">
<div class="flex flex-col">
<input
type="text"
bind:value={name}
class="bg-transparent text-lg font-bold text-surface-100 placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-accent-500/50 rounded px-2 -ml-2 hover:bg-surface-700/50 transition-colors"
placeholder="Lorebook Name"
/>
<div class="text-xs text-surface-400 px-2 -ml-2">
{entries.length} entries
</div>
</div>
</div>
<div class="flex items-center gap-3">
{#if error}
<div class="flex items-center gap-2 text-red-400 text-sm mr-4 bg-red-500/10 px-3 py-1.5 rounded-full">
<AlertCircle class="h-4 w-4" />
{error}
</div>
{/if}
<button
class="flex items-center gap-2 rounded-lg bg-surface-700 px-4 py-2 text-sm font-medium text-surface-200 hover:bg-surface-600 hover:text-white"
onclick={() => activeTab = activeTab === 'settings' ? 'editor' : 'settings'}
>
{activeTab === 'settings' ? 'Close Settings' : 'Settings'}
</button>
<button
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white hover:bg-accent-500 disabled:opacity-50"
onclick={handleSave}
disabled={saving}
>
{#if saving}
<div class="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white"></div>
{:else}
<Save class="h-4 w-4" />
{/if}
Save Changes
</button>
<div class="h-6 w-px bg-surface-700 mx-1"></div>
<button
class="rounded-lg p-2 text-surface-400 hover:bg-surface-700 hover:text-surface-200"
onclick={onClose}
title="Close"
>
<X class="h-5 w-5" />
</button>
</div>
</div>
<!-- Main Content -->
<div class="flex flex-1 overflow-hidden">
{#if activeTab === 'settings'}
<!-- Global Settings Panel -->
<div class="w-full p-8 overflow-y-auto" in:fade={{ duration: 150 }}>
<div class="max-w-2xl mx-auto space-y-6">
<h3 class="text-xl font-medium text-surface-100 mb-6">Lorebook Settings</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Description</label>
<textarea
bind:value={description}
rows="4"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
placeholder="Describe what this lorebook contains..."
></textarea>
</div>
<div class="rounded-lg bg-surface-800 p-4 border border-surface-700">
<h4 class="text-sm font-medium text-surface-200 mb-2">Statistics</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between p-2 rounded bg-surface-700/50">
<span class="text-surface-400">Total Entries</span>
<span class="text-surface-100">{entries.length}</span>
</div>
<div class="flex justify-between p-2 rounded bg-surface-700/50">
<span class="text-surface-400">Active Entries</span>
<span class="text-surface-100">{entries.filter(e => !e.disabled).length}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{:else}
<!-- Split View: List + Editor -->
<!-- Sidebar (List) -->
<div class="w-80 flex flex-col border-r border-surface-700 bg-surface-800/50">
<!-- Search -->
<div class="p-4 border-b border-surface-700 space-y-3">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search entries..."
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-9 pr-3 py-2 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<button
class="w-full flex items-center justify-center gap-2 rounded-lg bg-surface-700 py-2 text-sm font-medium text-surface-200 hover:bg-surface-600 hover:text-white transition-colors"
onclick={handleAddEntry}
>
<Plus class="h-4 w-4" />
Add New Entry
</button>
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
{#if filteredEntries.length === 0}
<div class="p-4 text-center text-sm text-surface-500">
{#if searchQuery}
No matches found
{:else}
No entries yet
{/if}
</div>
{:else}
{#each filteredEntries as { entry, index }}
{@const Icon = typeIcons[entry.type]}
<button
class="w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors {selectedIndex === index ? 'bg-accent-500/20 text-accent-100 ring-1 ring-accent-500/50' : 'text-surface-300 hover:bg-surface-700 hover:text-surface-100'}"
onclick={() => selectedIndex = index}
>
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-800 {selectedIndex === index ? 'bg-accent-500/30' : ''}">
<Icon class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<div class="truncate font-medium">{entry.name}</div>
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="capitalize">{entry.type}</span>
{#if entry.disabled}
<span class="flex items-center gap-0.5 text-surface-500">
<EyeOff class="h-3 w-3" /> Disabled
</span>
{/if}
</div>
</div>
</button>
{/each}
{/if}
</div>
</div>
<!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden bg-surface-900">
{#if selectedEntry !== null && selectedIndex !== null}
<!-- Entry Editor Header -->
<div class="flex items-center justify-between border-b border-surface-700 px-6 py-4 bg-surface-800/30">
<div class="flex items-center gap-3">
<input
type="text"
bind:value={selectedEntry.name}
class="bg-transparent text-xl font-bold text-surface-100 placeholder-surface-500 focus:outline-none focus:ring-2 focus:ring-accent-500/50 rounded px-2 -ml-2"
placeholder="Entry Name"
/>
</div>
<div class="flex items-center gap-2">
<button
class="p-2 text-surface-400 hover:text-surface-100 rounded-lg hover:bg-surface-700"
onclick={() => handleDuplicateEntry(selectedIndex!)}
title="Duplicate Entry"
>
<span class="text-xs font-medium">Duplicate</span>
</button>
<button
class="p-2 text-red-400 hover:text-red-300 rounded-lg hover:bg-red-500/10"
onclick={() => handleDeleteEntry(selectedIndex!)}
title="Delete Entry"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
<!-- Entry Editor Form -->
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-3xl mx-auto space-y-6">
<!-- Basic Info Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- Type -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Entry Type</label>
<div class="relative">
<select
bind:value={selectedEntry.type}
class="w-full appearance-none rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 focus:border-accent-500 focus:outline-none"
>
{#each entryTypes as type}
<option value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
{/each}
</select>
<div class="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-surface-500">
<MoreVertical class="h-4 w-4" />
</div>
</div>
</div>
<!-- Group -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Group (Optional)</label>
<input
type="text"
bind:value={selectedEntry.group}
placeholder="e.g. Main Cast, Kingdom A"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
</div>
<!-- Keywords -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-1">Keywords</label>
<input
type="text"
value={selectedEntry.keywords.join(', ')}
oninput={(e) => selectedEntry!.keywords = e.currentTarget.value.split(',').map(k => k.trim()).filter(Boolean)}
placeholder="Comma-separated keywords for activation"
class="w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-2.5 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
<p class="mt-1.5 text-xs text-surface-500">
Terms that trigger this entry when using 'Keyword' injection mode.
</p>
</div>
<!-- Description -->
<div class="flex-1 flex flex-col min-h-[200px]">
<label class="block text-sm font-medium text-surface-300 mb-1">Description / Content</label>
<textarea
bind:value={selectedEntry.description}
class="flex-1 w-full rounded-lg border border-surface-600 bg-surface-800 px-4 py-3 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none font-mono text-sm leading-relaxed"
placeholder="Enter the lore content here..."
></textarea>
</div>
<!-- Advanced Settings -->
<div class="rounded-lg border border-surface-700 bg-surface-800/50 p-4 space-y-4">
<h4 class="text-sm font-medium text-surface-200">Injection Settings</h4>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Mode -->
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Injection Mode</label>
<select
bind:value={selectedEntry.injectionMode}
class="w-full rounded bg-surface-700 border-transparent px-2 py-1.5 text-sm text-surface-100 focus:ring-1 focus:ring-accent-500"
>
{#each injectionModes as mode}
<option value={mode}>{mode.charAt(0).toUpperCase() + mode.slice(1)}</option>
{/each}
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-xs font-medium text-surface-400 mb-1">Priority</label>
<input
type="number"
bind:value={selectedEntry.priority}
class="w-full rounded bg-surface-700 border-transparent px-2 py-1.5 text-sm text-surface-100 focus:ring-1 focus:ring-accent-500"
/>
</div>
<!-- Enabled Toggle -->
<div class="flex items-end pb-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={!selectedEntry.disabled}
onchange={() => selectedEntry!.disabled = !selectedEntry!.disabled}
class="h-4 w-4 rounded border-surface-600 bg-surface-700 text-accent-500 focus:ring-offset-surface-800"
/>
<span class="text-sm text-surface-200">
{selectedEntry.disabled ? 'Disabled' : 'Enabled'}
</span>
</label>
</div>
</div>
</div>
</div>
</div>
{:else}
<!-- Empty State -->
<div class="flex-1 flex flex-col items-center justify-center text-surface-500">
<div class="bg-surface-800/50 p-6 rounded-full mb-4">
<Search class="h-8 w-8 opacity-50" />
</div>
<p class="text-lg font-medium text-surface-400">Select an entry to edit</p>
<p class="text-sm text-surface-600 mt-2">Or click "Add New Entry" to create one</p>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { lorebookVault } from '$lib/stores/lorebookVault.svelte';
import type { VaultLorebook } from '$lib/types';
import { X, Search, Archive, Loader2 } from 'lucide-svelte';
import VaultLorebookCard from './VaultLorebookCard.svelte';
interface Props {
onSelect: (lorebook: VaultLorebook) => void;
onClose: () => void;
}
let { onSelect, onClose }: Props = $props();
let searchQuery = $state('');
const filteredLorebooks = $derived.by(() => {
let books = lorebookVault.lorebooks;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
books = books.filter(b =>
b.name.toLowerCase().includes(query) ||
b.description?.toLowerCase().includes(query) ||
b.tags.some(t => t.toLowerCase().includes(query))
);
}
// Sort favorites first, then updated
return books.sort((a, b) => {
if (a.favorite && !b.favorite) return -1;
if (!a.favorite && b.favorite) return 1;
return b.updatedAt - a.updatedAt;
});
});
$effect(() => {
if (!lorebookVault.isLoaded) {
lorebookVault.load();
}
});
function handleSelect(lorebook: VaultLorebook) {
onSelect(lorebook);
}
</script>
<!-- Modal backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-3xl max-h-[80vh] flex flex-col rounded-lg bg-surface-800 shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 p-4">
<div class="flex items-center gap-2">
<Archive class="h-5 w-5 text-accent-400" />
<h2 class="text-lg font-semibold text-surface-100">Select Lorebook from Vault</h2>
</div>
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={onClose}
>
<X class="h-5 w-5 text-surface-400" />
</button>
</div>
<!-- Search -->
<div class="border-b border-surface-700 p-4">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
type="text"
bind:value={searchQuery}
placeholder="Search lorebooks..."
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-10 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
</div>
<!-- Lorebook List -->
<div class="flex-1 overflow-y-auto p-4">
{#if !lorebookVault.isLoaded}
<div class="flex h-40 items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
</div>
{:else if filteredLorebooks.length === 0}
<div class="flex h-40 items-center justify-center">
<div class="text-center">
<Archive class="mx-auto h-10 w-10 text-surface-600" />
<p class="mt-2 text-surface-400">
{#if searchQuery}
No lorebooks match your search
{:else}
No lorebooks in vault
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
Save processed lorebooks from the Import Wizard or Stories
</p>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each filteredLorebooks as lorebook (lorebook.id)}
<VaultLorebookCard
{lorebook}
selectable
onSelect={() => handleSelect(lorebook)}
/>
{/each}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-t border-surface-700 p-4 flex justify-end">
<button
class="rounded-lg px-4 py-2 text-sm text-surface-400 hover:text-surface-200"
onclick={onClose}
>
Cancel
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,501 @@
<script lang="ts">
import { characterVault } from '$lib/stores/characterVault.svelte';
import { lorebookVault } from '$lib/stores/lorebookVault.svelte';
import { ui } from '$lib/stores/ui.svelte';
import type { VaultCharacter, VaultCharacterType, VaultLorebook } from '$lib/types';
import {
Plus, Search, Star, User, Users, ChevronLeft, Upload, Loader2, Archive, Book
} from 'lucide-svelte';
import VaultCharacterCard from './VaultCharacterCard.svelte';
import VaultCharacterForm from './VaultCharacterForm.svelte';
import VaultLorebookCard from './VaultLorebookCard.svelte';
import VaultLorebookEditor from './VaultLorebookEditor.svelte';
import { readCharacterCardFile, parseCharacterCard } from '$lib/services/characterCardImporter';
import { parseLorebook, classifyEntriesWithLLM, type ImportedEntry } from '$lib/services/lorebookImporter';
import { fade } from 'svelte/transition';
// Types
type VaultTab = 'characters' | 'lorebooks';
// State
let activeTab = $state<VaultTab>('characters');
let searchQuery = $state('');
let showFavoritesOnly = $state(false);
// Character State
let charFilterType = $state<VaultCharacterType | 'all'>('all');
let showCharForm = $state(false);
let editingCharacter = $state<VaultCharacter | null>(null);
let defaultCharFormType = $state<VaultCharacterType>('protagonist');
let importingChar = $state(false);
let importCharError = $state<string | null>(null);
// Lorebook State
let importingLorebook = $state(false);
let importLorebookError = $state<string | null>(null);
let importProgress = $state({ current: 0, total: 0 });
let editingLorebook = $state<VaultLorebook | null>(null);
// Derived: Filtered Characters
const filteredCharacters = $derived.by(() => {
let chars = characterVault.characters;
if (charFilterType !== 'all') {
chars = chars.filter(c => c.characterType === charFilterType);
}
if (showFavoritesOnly) {
chars = chars.filter(c => c.favorite);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
chars = chars.filter(c =>
c.name.toLowerCase().includes(query) ||
c.description?.toLowerCase().includes(query) ||
c.tags.some(t => t.toLowerCase().includes(query)) ||
c.traits.some(t => t.toLowerCase().includes(query))
);
}
return chars;
});
// Derived: Filtered Lorebooks
const filteredLorebooks = $derived.by(() => {
let books = lorebookVault.lorebooks;
if (showFavoritesOnly) {
books = books.filter(b => b.favorite);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
books = books.filter(b =>
b.name.toLowerCase().includes(query) ||
b.description?.toLowerCase().includes(query) ||
b.tags.some(t => t.toLowerCase().includes(query))
);
}
return books;
});
// Load on mount
$effect(() => {
if (!characterVault.isLoaded) characterVault.load();
if (!lorebookVault.isLoaded) lorebookVault.load();
});
// Character Handlers
function openCreateCharForm(type: VaultCharacterType = 'protagonist') {
editingCharacter = null;
defaultCharFormType = type;
showCharForm = true;
}
function openEditCharForm(character: VaultCharacter) {
editingCharacter = character;
showCharForm = true;
}
async function handleDeleteChar(id: string) {
await characterVault.delete(id);
}
async function handleToggleFavoriteChar(id: string) {
await characterVault.toggleFavorite(id);
}
async function handleImportCard(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
importingChar = true;
importCharError = null;
try {
const jsonString = await readCharacterCardFile(file);
const card = parseCharacterCard(jsonString);
if (!card) throw new Error('Invalid character card format');
const traits = card.personality
? card.personality.split(/[,;]/).map(t => t.trim()).filter(Boolean).slice(0, 10)
: [];
await characterVault.add({
name: card.name,
description: card.description || null,
characterType: 'supporting',
background: null,
motivation: null,
role: null,
relationshipTemplate: null,
traits,
visualDescriptors: [],
portrait: null,
tags: ['imported'],
favorite: false,
source: 'import',
originalStoryId: null,
metadata: { cardVersion: card.version },
});
} catch (error) {
console.error('[CharacterVault] Import failed:', error);
importCharError = error instanceof Error ? error.message : 'Failed to import character card';
} finally {
importingChar = false;
input.value = '';
}
}
// Lorebook Handlers
async function handleImportLorebook(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
importingLorebook = true;
importLorebookError = null;
importProgress = { current: 0, total: 0 };
try {
const content = await file.text();
const result = parseLorebook(content);
if (!result.success || result.entries.length === 0) {
throw new Error(result.errors.join('; ') || 'No entries found in lorebook');
}
// Classify entries
const entries = await classifyEntriesWithLLM(
result.entries,
(current, total) => {
importProgress = { current, total };
},
'adventure' // Default mode for classification context
);
// Convert ImportedEntry[] to VaultLorebookEntry[]
// We can cast or map. Since structure matches except optional/extra fields:
const vaultEntries = entries.map(e => {
const { originalData, ...rest } = e;
return rest;
});
// Save to vault
await lorebookVault.saveFromImport(
file.name.replace(/\.json$/i, ''),
vaultEntries,
{ ...result, entries }, // Update result with classified entries
file.name
);
} catch (error) {
console.error('[VaultPanel] Lorebook import failed:', error);
importLorebookError = error instanceof Error ? error.message : 'Failed to import lorebook';
} finally {
importingLorebook = false;
input.value = '';
}
}
async function handleDeleteLorebook(id: string) {
await lorebookVault.delete(id);
}
async function handleToggleFavoriteLorebook(id: string) {
await lorebookVault.toggleFavorite(id);
}
function openEditLorebook(lorebook: VaultLorebook) {
editingLorebook = lorebook;
}
</script>
<div class="flex h-full flex-col bg-surface-900">
<!-- Header -->
<div class="flex flex-col border-b border-surface-700 bg-surface-800">
<!-- Top Bar -->
<div class="flex items-center justify-between px-4 py-3 sm:px-6">
<div class="flex items-center gap-3">
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={() => ui.setActivePanel('library')}
title="Back to Library"
>
<ChevronLeft class="h-5 w-5 text-surface-400" />
</button>
<div class="flex items-center gap-2">
<Archive class="h-5 w-5 text-surface-400" />
<h2 class="text-lg font-semibold text-surface-100">Vault</h2>
</div>
</div>
<!-- Right Side Actions (Context Sensitive) -->
<div class="flex items-center gap-2">
{#if activeTab === 'characters'}
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500">
{#if importingChar}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Upload class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">Import Card</span>
<input
type="file"
accept=".json,.png"
class="hidden"
onchange={handleImportCard}
disabled={importingChar}
/>
</label>
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateCharForm()}
>
<Plus class="h-4 w-4" />
<span class="hidden sm:inline">New Character</span>
</button>
{:else}
<!-- Lorebook Actions -->
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 {importingLorebook ? 'opacity-50 cursor-not-allowed' : ''}">
{#if importingLorebook}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Upload class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">Import Lorebook</span>
<input
type="file"
accept=".json,application/json"
class="hidden"
onchange={handleImportLorebook}
disabled={importingLorebook}
/>
</label>
{/if}
</div>
</div>
<!-- Tab Bar -->
<div class="flex px-4 sm:px-6">
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'characters' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => activeTab = 'characters'}
>
<Users class="h-4 w-4" />
Characters
<span class="ml-1 rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
{characterVault.characters.length}
</span>
</button>
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab === 'lorebooks' ? 'border-accent-500 text-accent-400' : 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => activeTab = 'lorebooks'}
>
<Book class="h-4 w-4" />
Lorebooks
<span class="ml-1 rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
{lorebookVault.lorebooks.length}
</span>
</button>
</div>
</div>
<!-- Search and Filters (Common) -->
<div class="border-b border-surface-700 px-4 py-3 space-y-3 sm:px-6 bg-surface-900/50">
{#if activeTab === 'characters' && importCharError}
<div class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between">
<span>{importCharError}</span>
<button onclick={() => importCharError = null} class="text-red-400 hover:text-red-300">
<span class="sr-only">Dismiss</span>
&times;
</button>
</div>
{/if}
{#if activeTab === 'lorebooks'}
{#if importLorebookError}
<div class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between">
<span>{importLorebookError}</span>
<button onclick={() => importLorebookError = null} class="text-red-400 hover:text-red-300">
<span class="sr-only">Dismiss</span>
&times;
</button>
</div>
{/if}
{#if importingLorebook && importProgress.total > 0}
<div class="rounded-lg bg-surface-800 p-3 border border-surface-700">
<div class="flex justify-between text-xs text-surface-400 mb-1">
<span>Classifying entries...</span>
<span>{importProgress.current} / {importProgress.total}</span>
</div>
<div class="w-full bg-surface-700 rounded-full h-1.5">
<div
class="bg-accent-500 h-1.5 rounded-full transition-all duration-300"
style="width: {(importProgress.current / importProgress.total) * 100}%"
></div>
</div>
</div>
{/if}
{/if}
<div class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
type="text"
bind:value={searchQuery}
placeholder={activeTab === 'characters' ? "Search characters..." : "Search lorebooks..."}
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-10 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<div class="flex items-center gap-2">
{#if activeTab === 'characters'}
<div class="flex items-center gap-1 rounded-lg bg-surface-800 p-1 border border-surface-700">
<button
class="rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType === 'all' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => charFilterType = 'all'}
>
All
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType === 'protagonist' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => charFilterType = 'protagonist'}
>
<User class="h-3 w-3" />
Protagonists
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType === 'supporting' ? 'bg-surface-600 text-surface-100' : 'text-surface-400 hover:text-surface-200'}"
onclick={() => charFilterType = 'supporting'}
>
<Users class="h-3 w-3" />
Supporting
</button>
</div>
{/if}
<button
class="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-colors {showFavoritesOnly ? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400' : 'border-surface-600 bg-surface-800 text-surface-400 hover:border-surface-500'}"
onclick={() => showFavoritesOnly = !showFavoritesOnly}
>
<Star class="h-3 w-3 {showFavoritesOnly ? 'fill-yellow-400' : ''}" />
Favorites
</button>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6 bg-surface-900">
{#if activeTab === 'characters'}
<!-- Character Grid -->
{#if !characterVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
</div>
{:else if filteredCharacters.length === 0}
<div class="flex h-full items-center justify-center" in:fade>
<div class="text-center">
<Users class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
{#if searchQuery || showFavoritesOnly || charFilterType !== 'all'}
No characters match your filters
{:else}
No characters in vault yet
{/if}
</p>
{#if !searchQuery && !showFavoritesOnly && charFilterType === 'all'}
<div class="mt-4 flex justify-center gap-2">
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateCharForm('protagonist')}
>
<User class="h-4 w-4" />
Create Protagonist
</button>
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-4 py-2 text-sm text-surface-300 hover:border-surface-500"
onclick={() => openCreateCharForm('supporting')}
>
<Users class="h-4 w-4" />
Create Supporting
</button>
</div>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" in:fade>
{#each filteredCharacters as character (character.id)}
<VaultCharacterCard
{character}
onEdit={() => openEditCharForm(character)}
onDelete={() => handleDeleteChar(character.id)}
onToggleFavorite={() => handleToggleFavoriteChar(character.id)}
/>
{/each}
</div>
{/if}
{:else}
<!-- Lorebook Grid -->
{#if !lorebookVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
</div>
{:else if filteredLorebooks.length === 0}
<div class="flex h-full items-center justify-center" in:fade>
<div class="text-center">
<Book class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
{#if searchQuery || showFavoritesOnly}
No lorebooks match your filters
{:else}
No lorebooks in vault yet
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
Save processed lorebooks from the Setup Wizard or your stories
</p>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" in:fade>
{#each filteredLorebooks as lorebook (lorebook.id)}
<VaultLorebookCard
{lorebook}
onDelete={() => handleDeleteLorebook(lorebook.id)}
onToggleFavorite={() => handleToggleFavoriteLorebook(lorebook.id)}
onEdit={() => openEditLorebook(lorebook)}
/>
{/each}
</div>
{/if}
{/if}
</div>
</div>
<!-- Character Form Modal -->
{#if showCharForm}
<VaultCharacterForm
character={editingCharacter}
defaultType={defaultCharFormType}
onClose={() => { showCharForm = false; editingCharacter = null; }}
/>
{/if}
<!-- Lorebook Editor Modal -->
{#if editingLorebook}
<VaultLorebookEditor
lorebook={editingLorebook}
onClose={() => editingLorebook = null}
/>
{/if}

View file

@ -29,8 +29,10 @@
import { NanoGPTImageProvider } from "$lib/services/ai/nanoGPTImageProvider";
import { promptService } from "$lib/services/prompts";
import { normalizeImageDataUrl } from "$lib/utils/image";
import type { StoryMode, POV, EntryType, VaultCharacter } from "$lib/types";
import type { StoryMode, POV, EntryType, VaultCharacter, VaultLorebook, VaultLorebookEntry } from "$lib/types";
import VaultCharacterPicker from "$lib/components/vault/VaultCharacterPicker.svelte";
import VaultLorebookPicker from "$lib/components/vault/VaultLorebookPicker.svelte";
import { lorebookVault } from '$lib/stores/lorebookVault.svelte';
import {
X,
ChevronLeft,
@ -53,6 +55,7 @@
RefreshCw,
Upload,
FileJson,
Archive,
Check,
AlertCircle,
Book,
@ -60,7 +63,6 @@
Plus,
ImageIcon,
ImageUp,
Archive,
} from "lucide-svelte";
interface Props {
@ -115,6 +117,7 @@
// Character Vault integration
let showProtagonistVaultPicker = $state(false);
let showSupportingVaultPicker = $state(false);
let showLorebookVaultPicker = $state(false);
let savedToVaultConfirm = $state(false);
// Step 7: Portraits
@ -1225,6 +1228,52 @@
}
}
async function handleSelectLorebookFromVault(vaultLorebook: VaultLorebook) {
const entries = vaultLorebook.entries.map(e => ({
...e,
}));
importedLorebooks.push({
id: crypto.randomUUID(),
filename: `${vaultLorebook.name} (from Vault)`,
result: {
success: true,
entries: entries,
errors: [],
warnings: [],
metadata: vaultLorebook.metadata || {
format: 'aventura',
totalEntries: entries.length,
importedEntries: entries.length,
skippedEntries: 0,
},
},
entries: entries,
expanded: true,
});
showLorebookVaultPicker = false;
}
async function handleSaveLorebookToVault(lb: ImportedLorebookItem) {
try {
const name = lb.filename.replace(/\.json$/i, '');
const vaultEntries: VaultLorebookEntry[] = lb.entries.map(e => {
const { originalData, ...rest } = e;
return rest;
});
await lorebookVault.saveFromImport(
name,
vaultEntries,
lb.result,
lb.filename
);
} catch (error) {
console.error('Failed to save lorebook to vault:', error);
}
}
function removeLorebook(id: string) {
importedLorebooks = importedLorebooks.filter((lb) => lb.id !== id);
if (importedLorebooks.length === 0) {
@ -1477,37 +1526,62 @@
{:else if currentStep === 2}
<!-- Step 2: Import Lorebook (Optional) -->
<div class="space-y-4">
<!-- Vault Picker Modal -->
{#if showLorebookVaultPicker}
<VaultLorebookPicker
onSelect={handleSelectLorebookFromVault}
onClose={() => (showLorebookVaultPicker = false)}
/>
{/if}
<p class="text-surface-400">
Import an existing lorebook to populate your world with characters,
locations, and lore. This step is optional - you can skip it and add
content later.
</p>
<!-- File Upload Area (always visible unless busy) -->
<!-- File Upload & Vault Area (always visible unless busy) -->
{#if !isImporting && !isClassifying}
<div
class="card bg-surface-900 border-dashed border-2 border-surface-600 p-8 text-center hover:border-accent-500/50 transition-colors cursor-pointer"
onclick={() => importFileInput?.click()}
onkeydown={(e) => e.key === "Enter" && importFileInput?.click()}
role="button"
tabindex="0"
>
<input
type="file"
accept=".json,application/json,*/*"
class="hidden"
bind:this={importFileInput}
onchange={handleFileSelect}
/>
<Upload class="h-8 w-8 mx-auto mb-2 text-surface-500" />
<p class="text-surface-300 font-medium">
{importedLorebooks.length > 0
? "Add Another Lorebook"
: "Click to upload a lorebook"}
</p>
<p class="text-xs text-surface-500 mt-1">
Supports SillyTavern lorebook format (.json)
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- File Upload -->
<div
class="card bg-surface-900 border-dashed border-2 border-surface-600 p-6 text-center hover:border-accent-500/50 transition-colors cursor-pointer flex flex-col items-center justify-center min-h-[140px]"
onclick={() => importFileInput?.click()}
onkeydown={(e) => e.key === "Enter" && importFileInput?.click()}
role="button"
tabindex="0"
>
<input
type="file"
accept=".json,application/json,*/*"
class="hidden"
bind:this={importFileInput}
onchange={handleFileSelect}
/>
<Upload class="h-8 w-8 mb-2 text-surface-500" />
<p class="text-surface-300 font-medium">
{importedLorebooks.length > 0
? "Upload Another"
: "Upload Lorebook"}
</p>
<p class="text-xs text-surface-500 mt-1">
Supports SillyTavern format (.json)
</p>
</div>
<!-- Vault Import -->
<button
class="card bg-surface-900 border-dashed border-2 border-surface-600 p-6 text-center hover:border-accent-500/50 transition-colors cursor-pointer flex flex-col items-center justify-center min-h-[140px]"
onclick={() => showLorebookVaultPicker = true}
>
<Archive class="h-8 w-8 mb-2 text-surface-500" />
<p class="text-surface-300 font-medium">
Add from Vault
</p>
<p class="text-xs text-surface-500 mt-1">
Use processed lorebooks
</p>
</button>
</div>
{:else if isImporting}
<div
@ -1595,15 +1669,30 @@
</span>
{/if}
</div>
<button
class="text-xs text-surface-400 hover:text-red-400 transition-colors z-10 p-1"
onclick={(e) => {
e.stopPropagation();
removeLorebook(lorebook.id);
}}
>
Remove
</button>
<div class="flex items-center gap-2 z-10">
{#if !lorebook.filename.includes('(from Vault)')}
<button
class="flex items-center gap-1 text-xs text-surface-400 hover:text-accent-400 transition-colors p-1"
onclick={(e) => {
e.stopPropagation();
handleSaveLorebookToVault(lorebook);
}}
title="Save to Vault for reuse"
>
<Archive class="h-3 w-3" />
<span class="hidden sm:inline">Save</span>
</button>
{/if}
<button
class="text-xs text-surface-400 hover:text-red-400 transition-colors p-1"
onclick={(e) => {
e.stopPropagation();
removeLorebook(lorebook.id);
}}
>
Remove
</button>
</div>
</div>
<!-- Type breakdown (Always visible) -->

View file

@ -22,6 +22,7 @@ import type {
EmbeddedImageStatus,
VaultCharacter,
VaultCharacterType,
VaultLorebook,
} from '$lib/types';
class DatabaseService {
@ -1759,6 +1760,100 @@ private mapEmbeddedImage(row: any): EmbeddedImage {
updatedAt: row.updated_at,
};
}
// ===== Lorebook Vault Operations =====
async getVaultLorebooks(): Promise<VaultLorebook[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM lorebook_vault ORDER BY favorite DESC, updated_at DESC'
);
return results.map(this.mapVaultLorebook);
}
async getVaultLorebook(id: string): Promise<VaultLorebook | null> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM lorebook_vault WHERE id = ?',
[id]
);
return results.length > 0 ? this.mapVaultLorebook(results[0]) : null;
}
async addVaultLorebook(lorebook: VaultLorebook): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO lorebook_vault (
id, name, description, entries,
tags, favorite, source, original_filename, original_story_id,
metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
lorebook.id,
lorebook.name,
lorebook.description,
JSON.stringify(lorebook.entries),
JSON.stringify(lorebook.tags),
lorebook.favorite ? 1 : 0,
lorebook.source,
lorebook.originalFilename,
lorebook.originalStoryId,
lorebook.metadata ? JSON.stringify(lorebook.metadata) : null,
lorebook.createdAt,
lorebook.updatedAt,
]
);
}
async updateVaultLorebook(id: string, updates: Partial<VaultLorebook>): Promise<void> {
const db = await this.getDb();
const setClauses: string[] = ['updated_at = ?'];
const values: any[] = [Date.now()];
if (updates.name !== undefined) { setClauses.push('name = ?'); values.push(updates.name); }
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
if (updates.entries !== undefined) { setClauses.push('entries = ?'); values.push(JSON.stringify(updates.entries)); }
if (updates.tags !== undefined) { setClauses.push('tags = ?'); values.push(JSON.stringify(updates.tags)); }
if (updates.favorite !== undefined) { setClauses.push('favorite = ?'); values.push(updates.favorite ? 1 : 0); }
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(updates.metadata ? JSON.stringify(updates.metadata) : null); }
values.push(id);
await db.execute(`UPDATE lorebook_vault SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
async deleteVaultLorebook(id: string): Promise<void> {
const db = await this.getDb();
await db.execute('DELETE FROM lorebook_vault WHERE id = ?', [id]);
}
async searchVaultLorebooks(query: string): Promise<VaultLorebook[]> {
const db = await this.getDb();
const searchPattern = `%${query}%`;
const results = await db.select<any[]>(
`SELECT * FROM lorebook_vault WHERE
name LIKE ? OR description LIKE ? OR tags LIKE ?
ORDER BY favorite DESC, updated_at DESC`,
[searchPattern, searchPattern, searchPattern]
);
return results.map(this.mapVaultLorebook);
}
private mapVaultLorebook(row: any): VaultLorebook {
return {
id: row.id,
name: row.name,
description: row.description,
entries: row.entries ? JSON.parse(row.entries) : [],
tags: row.tags ? JSON.parse(row.tags) : [],
favorite: row.favorite === 1,
source: row.source || 'import',
originalFilename: row.original_filename,
originalStoryId: row.original_story_id,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
export const database = new DatabaseService();

View file

@ -93,7 +93,7 @@ export interface ImportedEntry {
priority: number;
disabled: boolean;
group: string | null;
originalData: SillyTavernEntry;
originalData?: SillyTavernEntry;
}
export interface LorebookImportResult {

View file

@ -0,0 +1,158 @@
import type { VaultLorebook, VaultLorebookSource, EntryType, VaultLorebookEntry } from '$lib/types';
import type { LorebookImportResult } from '$lib/services/lorebookImporter';
import { database } from '$lib/services/database';
const DEBUG = true;
function log(...args: any[]) {
if (DEBUG) {
console.log('[LorebookVault]', ...args);
}
}
/**
* Store for managing the global Lorebook Vault.
* Lorebooks in the vault are templates that can be copied to stories.
*/
class LorebookVaultStore {
lorebooks = $state<VaultLorebook[]>([]);
isLoaded = $state(false);
get favorites(): VaultLorebook[] {
return this.lorebooks.filter(lb => lb.favorite);
}
async load(): Promise<void> {
try {
this.lorebooks = await database.getVaultLorebooks();
this.isLoaded = true;
log('Loaded', this.lorebooks.length, 'vault lorebooks');
} catch (error) {
console.error('[LorebookVault] Failed to load:', error);
this.lorebooks = [];
this.isLoaded = true;
}
}
async add(input: Omit<VaultLorebook, 'id' | 'createdAt' | 'updatedAt'>): Promise<VaultLorebook> {
const now = Date.now();
const lorebook: VaultLorebook = {
...input,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
};
await database.addVaultLorebook(lorebook);
this.lorebooks = [lorebook, ...this.lorebooks];
log('Added vault lorebook:', lorebook.name);
return lorebook;
}
async update(id: string, updates: Partial<VaultLorebook>): Promise<void> {
await database.updateVaultLorebook(id, updates);
this.lorebooks = this.lorebooks.map(lb =>
lb.id === id ? { ...lb, ...updates, updatedAt: Date.now() } : lb
);
log('Updated vault lorebook:', id);
}
async delete(id: string): Promise<void> {
await database.deleteVaultLorebook(id);
this.lorebooks = this.lorebooks.filter(lb => lb.id !== id);
log('Deleted vault lorebook:', id);
}
async toggleFavorite(id: string): Promise<void> {
const lorebook = this.lorebooks.find(lb => lb.id === id);
if (lorebook) {
await this.update(id, { favorite: !lorebook.favorite });
}
}
/**
* Save imported lorebook result to the vault.
*/
async saveFromImport(
name: string,
entries: VaultLorebookEntry[],
result: LorebookImportResult,
filename: string
): Promise<VaultLorebook> {
const entryBreakdown: Record<EntryType, number> = {
character: 0, location: 0, item: 0,
faction: 0, concept: 0, event: 0,
};
for (const entry of entries) {
entryBreakdown[entry.type]++;
}
return this.add({
name,
description: null,
entries,
tags: [],
favorite: false,
source: 'import',
originalFilename: filename,
originalStoryId: null,
metadata: {
format: result.metadata.format,
totalEntries: entries.length,
entryBreakdown,
},
});
}
/**
* Save lorebook entries from a story to the vault.
*/
async saveFromStory(
name: string,
entries: VaultLorebookEntry[],
storyId: string
): Promise<VaultLorebook> {
const entryBreakdown: Record<EntryType, number> = {
character: 0, location: 0, item: 0,
faction: 0, concept: 0, event: 0,
};
for (const entry of entries) {
entryBreakdown[entry.type]++;
}
return this.add({
name,
description: null,
entries,
tags: [],
favorite: false,
source: 'story',
originalFilename: null,
originalStoryId: storyId,
metadata: {
format: 'aventura',
totalEntries: entries.length,
entryBreakdown,
},
});
}
/**
* Get a lorebook by ID.
*/
getById(id: string): VaultLorebook | undefined {
return this.lorebooks.find(lb => lb.id === id);
}
/**
* Search vault lorebooks.
*/
async search(query: string): Promise<VaultLorebook[]> {
if (!query.trim()) {
return this.lorebooks;
}
return database.searchVaultLorebooks(query);
}
}
export const lorebookVault = new LorebookVaultStore();

View file

@ -184,6 +184,59 @@ export interface VaultCharacter {
updatedAt: number;
}
// ===== Lorebook Vault Types =====
export type VaultLorebookSource = 'import' | 'story' | 'manual';
export interface VaultLorebookMetadata {
format: 'aventura' | 'sillytavern' | 'unknown';
totalEntries: number;
entryBreakdown: Record<EntryType, number>;
}
/**
* A reusable lorebook stored in the global vault.
* Contains processed ImportedEntry[] that can be copied to stories.
*/
export interface VaultLorebook {
id: string;
name: string;
description: string | null;
// Processed entries (from LorebookImportResult)
entries: VaultLorebookEntry[];
// Organization
tags: string[];
favorite: boolean;
// Provenance
source: VaultLorebookSource;
originalFilename: string | null;
originalStoryId: string | null;
// Metadata
metadata: VaultLorebookMetadata | null;
createdAt: number;
updatedAt: number;
}
/**
* A lorebook entry stored in the vault.
* Similar to ImportedEntry but without originalData for cleaner storage.
*/
export interface VaultLorebookEntry {
name: string;
type: EntryType;
description: string;
keywords: string[];
injectionMode: EntryInjectionMode;
priority: number;
disabled: boolean;
group: string | null;
}
export interface Location {
id: string;
storyId: string;
@ -498,7 +551,7 @@ export interface AgenticSession {
}
// UI State types
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory' | 'character-vault';
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory' | 'vault';
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests' | 'time' | 'branches';
export interface UIState {