mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
added lorebook editing/saving to the vault
This commit is contained in:
parent
b4a9c5daad
commit
28271736e7
14 changed files with 1710 additions and 337 deletions
31
src-tauri/migrations/017_lorebook_vault.sql
Normal file
31
src-tauri/migrations/017_lorebook_vault.sql
Normal 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);
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
×
|
||||
</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}
|
||||
162
src/lib/components/vault/VaultLorebookCard.svelte
Normal file
162
src/lib/components/vault/VaultLorebookCard.svelte
Normal 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>
|
||||
446
src/lib/components/vault/VaultLorebookEditor.svelte
Normal file
446
src/lib/components/vault/VaultLorebookEditor.svelte
Normal 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>
|
||||
127
src/lib/components/vault/VaultLorebookPicker.svelte
Normal file
127
src/lib/components/vault/VaultLorebookPicker.svelte
Normal 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>
|
||||
501
src/lib/components/vault/VaultPanel.svelte
Normal file
501
src/lib/components/vault/VaultPanel.svelte
Normal 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>
|
||||
×
|
||||
</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>
|
||||
×
|
||||
</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}
|
||||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export interface ImportedEntry {
|
|||
priority: number;
|
||||
disabled: boolean;
|
||||
group: string | null;
|
||||
originalData: SillyTavernEntry;
|
||||
originalData?: SillyTavernEntry;
|
||||
}
|
||||
|
||||
export interface LorebookImportResult {
|
||||
|
|
|
|||
158
src/lib/stores/lorebookVault.svelte.ts
Normal file
158
src/lib/stores/lorebookVault.svelte.ts
Normal 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();
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue