Fix vault assistant approve-all indexing and add export/duplicate features (#326)

# Pull Request: Vault Agent Bugfixes, Export, Duplication & Performance

## Overview

Fixes 7 issues in the vault assistant and lorebook editor, adds export
and duplication functionality, and improves input responsiveness across
vault components.

---

## Changes

### Bug Fixes

#### 1. ApproveAll racing (Issue 1)
`VaultLorebookEditorContent.handleApproveAll()` now delegates to
`vaultEditor.approveAll(service)` via an `onApproveAllAsync` callback,
processing changes sequentially instead of racing individual approvals.
Added dedup in `previewLorebook` for create overlays. Resyncs local
`entries` after batch approval.

**Files:** `VaultLorebookEditorContent.svelte`,
`VaultEntityEditPanel.svelte`, `InteractiveVaultAssistant.svelte`,
`vaultEditorStore.svelte.ts`

#### 2. Simultaneous deletes corrupt indices (Issue 2)
Re-index entries after each approval to prevent index corruption when
multiple deletes are processed in batch.

**Files:** `vaultEditorStore.svelte.ts`

#### 3. Escape closes lorebook instead of stopping agent (Issue 3)
Added `onEscapeKeydown` prop using bits-ui's `EscapeLayer` so Escape
stops generation instead of closing the editor.

**Files:** `VaultEntityEditPanel.svelte`

#### 4. Assistant closes immediately on re-open (Issue 6)
A `mounted` guard flag in `onOpenChange` handlers prevents spurious
close events during the bits-ui Dialog mount cycle.

**Files:** `InteractiveVaultAssistant.svelte`,
`VaultEntityEditPanel.svelte`

#### 5. Blank-entry corruption from empty strings (Issue 1 v2)
AI was sending empty strings (`""`) for `description` in `update_entry`
calls, overwriting existing descriptions. Applied consistent
empty-string filtering across all 4 merge paths: tool handler
(`cleanUpdates`), `applyLorebookEntryChange`, `previewLorebook` overlay,
and optimistic `handleApproveEntry`.

Also added `VaultLorebookEntry` import to `vaultEditorStore.svelte.ts`
for type safety and improved tool descriptions to clarify which fields
allow empty values.

**Files:** `lorebook.ts`, `InteractiveVaultService.ts`,
`vaultEditorStore.svelte.ts`, `VaultLorebookEditorContent.svelte`

#### 6. Scroll position leaks between lorebook entries
When navigating between lorebook entries, the textarea's `scrollTop`
from the previous entry carried over. If the new entry was shorter, its
content was partially hidden. Fixed by resetting `scrollTop` to 0 in a
`$effect` keyed on the `data` object reference.

**Files:** `VaultLorebookEntryFields.svelte`

---

### New Features

#### 7. Export vault entities (Issue 4)
New export modal (`VaultExportModal.svelte`) that exports vault
characters, lorebooks, and scenarios using the existing
`LorebookImportExport` service. Export functions for vault entities live
in a new file (`src/lib/services/lorebookImportExport/export/vault.ts`),
re-exported through the service's public API.

**Bug fix — save dialog not opening for SillyTavern/Text on large
lorebooks:**
Two issues were found and fixed:

- **Combined filter group rejected by Tauri dialog**: The `saveFile`
filter used a single combined group `extensions: ['json', 'txt']`
instead of separate filter groups per extension. This caused the save
dialog to silently fail for SillyTavern and Text formats on certain
lorebooks. Fixed by splitting into `{ name: 'JSON', extensions: ['json']
}` and `{ name: 'Text', extensions: ['txt'] }`, matching the existing
lorebook export pattern.

- **Null aliases/keywords causing TypeError in format converters**:
`VaultLorebookEntry.aliases` and `.keywords` are typed `string[]` but
can be `null` at runtime (database NULL entries). The Aventura JSON
export survived because `JSON.stringify` serializes `null` safely, but
SillyTavern's spread operator (`...entry.aliases`) and Text's `.length`
call both threw `TypeError` on `null`, silently aborting before the save
dialog opened. Fixed with `?? []` guards in `vaultEntryToEntryLike`,
`entryToSillyTavern`, and `exportToText`.

**Files:** `VaultExportModal.svelte` (new), `vault.ts` (new),
`convert.ts`, `formats.ts`, `public-api.ts`, `VaultPanel.svelte`

#### 8. Duplicate vault entities (Issue 5)
Added `duplicate()` method to all 3 vault stores (`characterVault`,
`lorebookVault`, `scenarioVault`) that deep-clones an entity with a
"(Copy)" suffix. A Copy button appears on hover on `VaultCard`.

**Files:** `characterVault.svelte.ts`, `lorebookVault.svelte.ts`,
`scenarioVault.svelte.ts`, `VaultCard.svelte`

---

### Performance Improvements

#### 9. Debounced vault search + `$derived.by` filtering
The vault panel search input was bound directly to `searchQuery`,
triggering full array filtering (iterate all items, filter by
text/tags/favorites) on every keystroke via template `{@const}` — which
also recomputed on every parent render. Split into `searchInput` (raw) →
debounced `searchQuery` (300ms). Replaced `{@const filteredItems =
getFilteredItems(...)}` with `filteredByTab = $derived.by(...)` — cached
per tab.

**Files:** `VaultPanel.svelte`

#### 10. VaultAssistantInput extracted from InteractiveVaultAssistant
The 1286-line assistant component re-traversed its full template on
every keystroke. Extracted the chat textarea + send button + keyboard
hint into a lightweight `VaultAssistantInput.svelte` child component
with local `inputValue` state. Keystrokes now only re-render ~20 lines.

**Files:** `VaultAssistantInput.svelte` (new),
`InteractiveVaultAssistant.svelte`

#### 11. `$derived` function → `$derived.by` in RuntimeVariableManager
`grouped` and `entityTypeCounts` used `$derived(() => {...})` storing a
**function** whose body re-executed on every `grouped()` call in the
template. Changed to `$derived.by(() => {...})` so the computed value is
cached.

**Files:** `RuntimeVariableManager.svelte`

#### 12. Pre-sliced `$derived` arrays in UniversalVaultCard
`.slice(0, N)` calls in template expressions created new arrays on every
render. Added `$derived` variables (`visibleTraits`,
`visibleEntryCounts`, `visibleScenarioTags`) so slicing happens only
when dependencies change.

**Files:** `UniversalVaultCard.svelte`

---

## Verification

- `npm run check` — 0 errors, 0 warnings
- `npm run lint` — 0 errors, 181 pre-existing boundary-import warnings
(unchanged)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Export vault entities (lorebooks, characters, scenarios) with format
selection and save dialog.
  * Export button on vault cards and an export modal in the panel.
  * Duplicate vault items from list cards.
  * Batch “Approve All” for lorebook pending entries.
* New multiline assistant input with send, append, mount-aware close,
and Escape-to-abort.

* **Bug Fixes**
* Prevent accidental overwrites from empty-string fields during lorebook
updates.
  * Debounced search for snappier filtering.
* Preserve entry textarea scroll position and more robust handling of
optional export fields.
  * Improved pending-change application and reindexing for approvals.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/AventurasTeam/Aventuras/pull/326?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Dalton Sterritt 2026-05-27 15:57:07 -06:00 committed by GitHub
parent a5a21c0ebb
commit d1929ff432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1102 additions and 170 deletions

64
package-lock.json generated
View file

@ -1686,9 +1686,9 @@
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
"integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@ -1705,18 +1705,18 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.58.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
"version": "2.61.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.0.tgz",
"integrity": "sha512-beYjgUux5ITbZeL0vn6gipZlsQiXF1/08C/3F+vlbDvthb/CTgYpZsYPdRIi9RxgTwRSkKIvnxyl+ViZlX4q5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.14.1",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.6.4",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
@ -2586,6 +2586,7 @@
"version": "8.58.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz",
"integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2634,9 +2635,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3479,9 +3480,9 @@
}
},
"node_modules/devalue": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
"license": "MIT"
},
"node_modules/electron-to-chromium": {
@ -3939,13 +3940,20 @@
}
},
"node_modules/esrap": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz",
"integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==",
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz",
"integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/esrecurse": {
@ -5857,23 +5865,23 @@
}
},
"node_modules/svelte": {
"version": "5.55.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.2.tgz",
"integrity": "sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==",
"version": "5.55.9",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz",
"integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"devalue": "^5.8.1",
"esm-env": "^1.2.1",
"esrap": "^2.2.4",
"esrap": "^2.2.9",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@ -6491,9 +6499,9 @@
"license": "MIT"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View file

@ -54,6 +54,12 @@
}
let innerWidth = $state(0)
let scrollContainer = $state<HTMLDivElement | null>(null)
$effect(() => {
void ui.sidebarTab
if (scrollContainer) scrollContainer.scrollTop = 0
})
</script>
<svelte:window bind:innerWidth />
@ -86,7 +92,7 @@
</div>
<!-- Panel content -->
<div class="min-h-0 flex-1 overflow-y-auto p-3">
<div bind:this={scrollContainer} class="min-h-0 flex-1 overflow-y-auto p-3">
<Tabs.Content value="characters" class="mt-0 h-full space-y-4">
<CharacterPanel />
</Tabs.Content>

View file

@ -95,6 +95,7 @@
let isRawActionChoice = $state(false)
let stopRequested = false
let activeAbortController: AbortController | null = null
let textareaRef: HTMLTextAreaElement | null = $state(null)
let lastImageGenContext = $state<ImageGenerationContext | null>(null)
let isManualImageGenRunning = $state(false)
@ -786,7 +787,6 @@
ui.setGenerating(false)
ui.setGenerationStatus('')
activeAbortController = null
stopRequested = false
// Android: always stop the foreground service when generation ends
if (useBackgroundService) {
@ -969,6 +969,7 @@
isRawActionChoice = false
inputValue = ''
if (textareaRef) textareaRef.scrollTop = 0
const embeddedImages = await database.getEmbeddedImagesForStory(story.currentStory.id)
ui.createRetryBackup(
@ -1005,7 +1006,7 @@
}
async function handleStopGeneration() {
if (!ui.isGenerating || ui.isRetryingLastMessage) return
if (stopRequested || ui.isRetryingLastMessage) return
stopRequested = true
activeAbortController?.abort()
@ -1236,6 +1237,7 @@
<div class="relative min-w-0 flex-1">
<textarea
bind:value={inputValue}
bind:this={textareaRef}
use:autoResize={inputValue}
onkeydown={handleKeydown}
placeholder="Describe what happens next in the story..."
@ -1297,6 +1299,7 @@
<div class="relative min-w-0 flex-1 self-center">
<textarea
bind:value={inputValue}
bind:this={textareaRef}
use:autoResize={inputValue}
onkeydown={handleKeydown}
placeholder={actionType === 'story'

View file

@ -17,7 +17,6 @@
import {
ChevronLeft,
Bot,
Send,
Loader2,
User,
Brain,
@ -39,16 +38,15 @@
CircleUser,
} from 'lucide-svelte'
import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea'
import VaultDiffView from './VaultDiffView.svelte'
import VaultEntityEditPanel from './VaultEntityEditPanel.svelte'
import VaultAssistantInput from './VaultAssistantInput.svelte'
import { fade, slide } from 'svelte/transition'
import { onMount, onDestroy, tick } from 'svelte'
import * as Dialog from '$lib/components/ui/dialog'
import * as ResponsiveModal from '$lib/components/ui/responsive-modal'
import { parseMarkdown } from '$lib/utils/markdown'
import { cn } from '$lib/utils/cn'
import { isTouchDevice } from '$lib/utils/swipe'
import { SvelteSet } from 'svelte/reactivity'
import { createIsCompact } from '$lib/hooks/is-compact.svelte'
@ -69,9 +67,14 @@
// Service instance
let service: InteractiveVaultService | null = $state(null)
// Tracks whether the component has finished mounting.
// Prevents spurious onOpenChange(false) events from immediately closing the
// assistant when it's re-opened after a previous close (bits-ui Dialog can
// fire an onOpenChange(false) during the initial render/mount cycle).
let mounted = $state(false)
// UI State
let messages = $state<ChatMessage[]>([])
let inputValue = $state('')
let isGenerating = $state(false)
let error = $state<string | null>(null)
let messagesContainer = $state<HTMLDivElement | null>(null)
@ -176,8 +179,9 @@
// Initialize service on mount
onMount(() => {
mounted = true
initializeService()
loadConversationsList()
loadConversationsList().catch(() => {})
// Auto-open focused entity if provided
if (focusedEntity) {
@ -209,6 +213,7 @@
// Clean up on unmount
onDestroy(() => {
mounted = false
if (abortController) {
abortController.abort()
abortController = null
@ -216,7 +221,7 @@
vaultEditor.reset()
})
function initializeService() {
function initializeService(focused: FocusedEntity | null = null) {
try {
service = new InteractiveVaultService('interactiveVault')
@ -228,11 +233,12 @@
totalEntryCount: allLorebooks.reduce((sum, lb) => sum + lb.entries.length, 0),
scenarioCount: scenarioVault.items.length,
},
focusedEntity ?? undefined,
focused ?? undefined,
)
const greetingContent = focusedEntity
? `Hello! I can see you were editing the ${focusedEntity.entityType} **${focusedEntity.entityName}**. What would you like to work on?`
const entityToUse = focused ?? focusedEntity
const greetingContent = entityToUse
? `Hello! I can see you were editing the ${entityToUse.entityType} **${entityToUse.entityName}**. What would you like to work on?`
: "Hello! I'm your Vault Assistant. I can help you manage characters, lorebooks, and scenarios in your vault.\n\nTry asking me to create a character, organize lorebook entries, or set up a new scenario."
messages = [
@ -328,11 +334,11 @@
}
function handleReferenceImage(imageId: string) {
const ref = `[Image: ${imageId}]`
inputValue = inputValue.trim() ? `${inputValue.trim()}\n${ref}` : ref
assistantInputRef?.appendText(`[Image: ${imageId}]`)
}
let editPanelRef = $state<ReturnType<typeof VaultEntityEditPanel> | null>(null)
let assistantInputRef = $state<ReturnType<typeof VaultAssistantInput> | null>(null)
async function handleSetPortrait(imageId: string) {
if (!activeCharacterEntity || !service) return
@ -346,11 +352,9 @@
editPanelRef?.setPortrait(dataUrl)
}
async function handleSend() {
async function handleSend(userMessage: string) {
if (!service || isGenerating) return
const userMessage = inputValue.trim()
inputValue = ''
error = null
if (userMessage) {
@ -516,18 +520,6 @@
}
}
function handleKeyDown(e: KeyboardEvent) {
const isTouch = isTouchDevice()
const shouldSubmit = isTouch
? e.key === 'Enter' && e.shiftKey
: e.key === 'Enter' && !e.shiftKey
if (shouldSubmit) {
e.preventDefault()
handleSend()
}
}
function scrollToBottom() {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight
@ -577,10 +569,32 @@
onEditEntity?.(change)
}
async function handleApproveAll() {
if (!service) return
async function handleApproveAll(): Promise<string | null> {
if (!service) return 'Service not initialized'
const err = await vaultEditor.approveAll(service)
if (err) error = err
return err
}
/**
* Handle Escape key in the modal:
* - If generating: abort the request and keep the assistant open
* - If not generating: let the modal close normally (onOpenChange handles this)
*/
function handleEscapeKeydown(e: KeyboardEvent) {
if (isGenerating) {
e.preventDefault()
// Abort the ongoing request
if (abortController) {
abortController.abort()
abortController = null
}
isGenerating = false
isThinking = false
activeToolCalls = []
streamingChanges = []
error = 'Generation stopped'
}
}
</script>
@ -643,6 +657,7 @@
onApprove={(specificChange) =>
handleApprove(specificChange ?? vaultEditor.activeChange!)}
onReject={(change) => handleReject(change)}
onApproveAllAsync={handleApproveAll}
onClose={() => vaultEditor.closeEditor()}
/>
</div>
@ -1127,42 +1142,12 @@
{/if}
<!-- Input area -->
<div
class="border-surface-700 bg-surface-900 border-t p-3"
style="padding-bottom: calc(0.75rem + var(--safe-bottom));"
>
<div class="flex items-end gap-2">
<Textarea
bind:value={inputValue}
onkeydown={handleKeyDown}
placeholder="Ask me to create characters, organize lorebooks, set up scenarios..."
rows={2}
class="border-surface-700 bg-surface-800 placeholder:text-surface-500 min-h-[2.5rem] resize-none rounded-xl text-sm"
disabled={isGenerating || !service}
/>
<Button
size="icon"
class={cn(
'h-10 w-10 shrink-0 rounded-xl',
isGenerating ? 'opacity-70' : 'bg-accent-600 hover:bg-accent-500',
)}
onclick={handleSend}
disabled={!inputValue.trim() || isGenerating || !service}
title="Send message"
>
{#if isGenerating}
<Loader2 class="h-5 w-5 animate-spin" />
{:else}
<Send class="h-5 w-5" />
{/if}
</Button>
</div>
<div class="text-surface-500 mt-1.5 hidden text-center text-[10px] md:block">
{isTouchDevice()
? 'Shift+Enter to send, Enter for new line'
: 'Enter to send, Shift+Enter for new line'}
</div>
</div>
<VaultAssistantInput
bind:this={assistantInputRef}
onSend={handleSend}
disabled={!service}
{isGenerating}
/>
{:else}
<!-- Entity tab body (compact only) -->
{#if vaultEditor.activeChange}
@ -1174,6 +1159,7 @@
onApprove={(specificChange) =>
handleApprove(specificChange ?? vaultEditor.activeChange!)}
onReject={(change) => handleReject(change)}
onApproveAllAsync={handleApproveAll}
onClose={() => vaultEditor.closeEditor()}
/>
</div>
@ -1185,21 +1171,26 @@
{/snippet}
{#if isCompact.current}
<Dialog.Root open={true} onOpenChange={(open) => !open && onClose()}>
<Dialog.Root open={true} onOpenChange={(open) => !open && mounted && !isGenerating && onClose()}>
<Dialog.Content
class="flex h-[100dvh] w-screen max-w-none flex-col gap-0 overflow-hidden rounded-none border-none p-0"
style="padding-top: var(--safe-top);"
onEscapeKeydown={handleEscapeKeydown}
>
{@render assistantContent()}
</Dialog.Content>
</Dialog.Root>
{:else}
<ResponsiveModal.Root open={true} onOpenChange={(open) => !open && onClose()}>
<ResponsiveModal.Root
open={true}
onOpenChange={(open) => !open && mounted && !isGenerating && onClose()}
>
<ResponsiveModal.Content
class={cn(
'flex h-[90vh] w-full flex-col gap-0 overflow-hidden p-0',
vaultEditor.editorOpen ? 'max-w-[90vw]' : 'max-w-2xl',
)}
onEscapeKeydown={handleEscapeKeydown}
>
{@render assistantContent()}
</ResponsiveModal.Content>

View file

@ -28,6 +28,8 @@
onEdit?: () => void
onDelete?: () => void
onToggleFavorite?: () => void
onExport?: () => void
onDuplicate?: () => void
selectable?: boolean
onSelect?: () => void
}
@ -38,6 +40,8 @@
onEdit,
onDelete,
onToggleFavorite,
onExport,
onDuplicate,
selectable = false,
onSelect,
}: Props = $props()
@ -88,6 +92,11 @@
.filter(([_, count]) => count > 0)
.map(([type, count]) => ({ type, count }))
})
// Pre-sliced views to avoid per-render .slice() calls in template
const visibleTraits = $derived(asCharacter?.traits.slice(0, 3) ?? [])
const visibleEntryCounts = $derived(lorebookEntryCounts.slice(0, 4))
const visibleScenarioTags = $derived(scenarioTags.slice(0, 3))
</script>
<VaultCard
@ -99,6 +108,8 @@
{onDelete}
{onToggleFavorite}
{onSelect}
{onExport}
{onDuplicate}
>
{#snippet icon()}
{#if asCharacter}
@ -172,7 +183,7 @@
{#if asCharacter}
{#if asCharacter.traits.length > 0}
<div class="flex flex-wrap gap-1">
{#each asCharacter.traits.slice(0, 3) as trait, i (i)}
{#each visibleTraits as trait, i (i)}
<Badge
variant="outline"
class="text-muted-foreground/80 border-muted-foreground/20 min-h-4 px-1.5 py-0 text-[10px] leading-4 font-normal"
@ -190,7 +201,7 @@
{:else if asLorebook}
{#if lorebookEntryCounts.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each lorebookEntryCounts.slice(0, 4) as { type, count } (type)}
{#each visibleEntryCounts as { type, count } (type)}
{@const Icon = entryTypeIcons[type]}
<div
class="text-muted-foreground/80 bg-muted/50 border-border/50 flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[10px]"
@ -237,7 +248,7 @@
{asScenario.source === 'wizard' ? 'Created' : 'Imported'}
</Badge>
{/if}
{#each scenarioTags.slice(0, 3) as tag, i (i)}
{#each visibleScenarioTags as tag, i (i)}
<TagBadge name={tag} color={tagStore.getColor(tag, 'scenario')} />
{/each}
{#if scenarioTags.length > 3}

View file

@ -0,0 +1,81 @@
<script lang="ts">
import { isTouchDevice } from '$lib/utils/swipe'
import { Textarea } from '$lib/components/ui/textarea'
import { Button } from '$lib/components/ui/button'
import { cn } from '$lib/utils/cn'
import { Loader2, Send } from 'lucide-svelte'
let {
onSend,
disabled = false,
isGenerating = false,
}: {
onSend: (message: string) => void
disabled?: boolean
isGenerating?: boolean
} = $props()
let inputValue = $state('')
let textareaRef = $state<HTMLTextAreaElement | null>(null)
function handleKeyDown(e: KeyboardEvent) {
const isTouch = isTouchDevice()
const shouldSubmit = isTouch
? e.key === 'Enter' && e.shiftKey
: e.key === 'Enter' && !e.shiftKey
if (shouldSubmit) {
e.preventDefault()
handleSend()
}
}
function handleSend() {
const msg = inputValue.trim()
if (!msg) return
inputValue = ''
if (textareaRef) textareaRef.scrollTop = 0
onSend(msg)
}
export function appendText(text: string) {
inputValue = inputValue.trim() ? `${inputValue.trim()}\n${text}` : text
}
</script>
<div
class="border-surface-700 bg-surface-900 border-t p-3"
style="padding-bottom: calc(0.75rem + var(--safe-bottom));"
>
<div class="flex items-end gap-2">
<Textarea
bind:value={inputValue}
bind:ref={textareaRef}
onkeydown={handleKeyDown}
placeholder="Ask me to create characters, organize lorebooks, set up scenarios..."
rows={2}
class="border-surface-700 bg-surface-800 placeholder:text-surface-500 min-h-[2.5rem] resize-none rounded-xl text-sm"
disabled={disabled || isGenerating}
/>
<Button
size="icon"
class={cn(
'h-10 w-10 shrink-0 rounded-xl',
isGenerating ? 'opacity-70' : 'bg-accent-600 hover:bg-accent-500',
)}
onclick={handleSend}
disabled={!inputValue.trim() || disabled || isGenerating}
title="Send message"
>
{#if isGenerating}
<Loader2 class="h-5 w-5 animate-spin" />
{:else}
<Send class="h-5 w-5" />
{/if}
</Button>
</div>
<div class="text-surface-500 mt-1.5 hidden text-center text-[10px] md:block">
{isTouchDevice()
? 'Shift+Enter to send, Enter for new line'
: 'Enter to send, Shift+Enter for new line'}
</div>
</div>

View file

@ -17,11 +17,19 @@
change: VaultPendingChange
onApprove: (change?: VaultPendingChange) => void
onReject?: (change: VaultPendingChange) => void
onApproveAllAsync?: () => Promise<string | null>
onClose: () => void
hideHeader?: boolean
}
let { change, onApprove, onReject, onClose, hideHeader = false }: Props = $props()
let {
change,
onApprove,
onReject,
onApproveAllAsync,
onClose,
hideHeader = false,
}: Props = $props()
// Local state for the editable data (character / scenario)
let charData = $state<VaultCharacterInput | null>(null)
@ -209,6 +217,7 @@
data: newData,
} as VaultPendingChange)
}}
{onApproveAllAsync}
{hideHeader}
/>
{/key}

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { LorebookImportExport } from '$lib/services/lorebookImportExport'
import { Download, FileJson, FileText, Loader2 } from 'lucide-svelte'
import type { VaultLorebook, VaultCharacter, VaultScenario } from '$lib/types'
import * as ResponsiveModal from '$lib/components/ui/responsive-modal'
import { Button } from '$lib/components/ui/button'
import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group'
import { Label } from '$lib/components/ui/label'
import { cn } from '$lib/utils/cn'
import { ui } from '$lib/stores/ui.svelte'
type ExportFormat = LorebookImportExport.ExportFormat
type EntityType = 'lorebook' | 'character' | 'scenario'
interface Props {
entity: VaultLorebook | VaultCharacter | VaultScenario
entityType: EntityType
onClose: () => void
}
let { entity, entityType, onClose }: Props = $props()
let selectedFormat = $state<ExportFormat>('aventura')
let exporting = $state(false)
const formats: ExportFormat[] = ['aventura', 'sillytavern', 'text']
const entityLabel = $derived(
entityType === 'lorebook' ? 'Lorebook' : entityType === 'character' ? 'Character' : 'Scenario',
)
const entryCount = $derived(
entityType === 'lorebook' ? (entity as VaultLorebook).entries.length : 0,
)
async function handleExport() {
exporting = true
try {
let success = false
if (entityType === 'lorebook') {
success = await LorebookImportExport.exportVaultLorebook(
entity as VaultLorebook,
selectedFormat,
)
} else if (entityType === 'character') {
success = await LorebookImportExport.exportVaultCharacter(entity as VaultCharacter)
} else {
success = await LorebookImportExport.exportVaultScenario(entity as VaultScenario)
}
if (success) {
onClose()
}
} catch (err) {
console.error('[VaultExportModal] Export failed:', err)
ui.showToast('Export failed', 'error')
} finally {
exporting = false
}
}
</script>
<ResponsiveModal.Root open={true} onOpenChange={(open) => !open && onClose()}>
<ResponsiveModal.Content class="flex max-h-[90vh] max-w-md flex-col gap-0 p-0">
<ResponsiveModal.Header class="border-b px-6 py-4">
<div class="flex items-center gap-2">
<Download class="text-primary h-5 w-5" />
<ResponsiveModal.Title>Export {entityLabel}</ResponsiveModal.Title>
</div>
</ResponsiveModal.Header>
<div class="space-y-6 px-6 py-6">
<!-- Entity info -->
<div class="bg-muted/50 rounded-lg border p-3">
<div class="text-foreground font-medium">{entity.name}</div>
{#if entityType === 'lorebook'}
<div class="text-muted-foreground text-xs">{entryCount} entries</div>
{/if}
</div>
<!-- Format selection (only for lorebooks) -->
{#if entityType === 'lorebook'}
<div class="space-y-3">
<Label>Export format</Label>
<RadioGroup
value={selectedFormat}
onValueChange={(v) => (selectedFormat = v as ExportFormat)}
>
{#each formats as format (format)}
{@const info = LorebookImportExport.getFormatInfo(format)}
<div
class={cn(
'hover:bg-muted/50 flex cursor-pointer items-start space-x-3 rounded-lg border p-3 transition-colors',
selectedFormat === format && 'border-primary bg-primary/5',
)}
>
<RadioGroupItem value={format} id={`format-${format}`} class="mt-1" />
<div class="flex-1 space-y-1">
<Label
for={`format-${format}`}
class="flex cursor-pointer items-center gap-2 font-medium"
>
{info.label}
<span class="text-muted-foreground ml-auto text-xs font-normal"
>{info.extension}</span
>
</Label>
<p class="text-muted-foreground text-xs">{info.description}</p>
</div>
<div class="text-muted-foreground mt-0.5">
{#if format === 'text'}
<FileText class="h-4 w-4" />
{:else}
<FileJson class="h-4 w-4" />
{/if}
</div>
</div>
{/each}
</RadioGroup>
</div>
{:else}
<div class="bg-muted/30 rounded-lg border p-3">
<div class="text-muted-foreground text-sm">
{entityLabel}s are exported as Aventura JSON format.
</div>
</div>
{/if}
</div>
<ResponsiveModal.Footer class="mt-auto border-t px-6 py-4">
<Button variant="outline" onclick={onClose} disabled={exporting}>Cancel</Button>
<Button onclick={handleExport} disabled={exporting} class="gap-2">
{#if exporting}
<Loader2 class="h-4 w-4 animate-spin" />
Exporting...
{:else}
<Download class="h-4 w-4" />
Export
{/if}
</Button>
</ResponsiveModal.Footer>
</ResponsiveModal.Content>
</ResponsiveModal.Root>

View file

@ -22,10 +22,13 @@
Pencil,
GitMerge,
Bot,
Download,
} from 'lucide-svelte'
import TagInput from '$lib/components/tags/TagInput.svelte'
import VaultLorebookEntryFields from './VaultLorebookEntryFields.svelte'
import VaultPendingOperations, { type PendingOperation } from './VaultPendingOperations.svelte'
import { LorebookImportExport } from '$lib/services/lorebookImportExport'
import { ui } from '$lib/stores/ui.svelte'
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
@ -49,6 +52,7 @@
onRejectEntry?: (change: VaultPendingChange) => void
onUpdatePendingChange?: (change: VaultPendingChange, newData: VaultLorebookEntry) => void
onOpenAssistant?: (entity: FocusedEntity) => void
onApproveAllAsync?: () => Promise<string | null>
hideHeader?: boolean
}
@ -65,6 +69,7 @@
onRejectEntry,
onUpdatePendingChange,
onOpenAssistant,
onApproveAllAsync,
hideHeader = false,
}: Props = $props()
@ -439,6 +444,17 @@
}
}
async function handleExport() {
try {
const success = await LorebookImportExport.exportVaultLorebook(lorebook, 'aventura')
if (success) {
ui.showToast('Lorebook exported successfully', 'info')
}
} catch (e) {
ui.showToast(e instanceof Error ? e.message : 'Export failed', 'error')
}
}
function handleAddEntry() {
newEntryDraft = {
name: '',
@ -502,7 +518,11 @@
case 'update': {
const idx = change.entryIndex
if (idx >= 0 && idx < entries.length) {
entries[idx] = { ...entries[idx], ...(change.data as Partial<VaultLorebookEntry>) }
const raw = change.data as Partial<VaultLorebookEntry> | undefined
const safeData = Object.fromEntries(
Object.entries(raw ?? {}).filter(([_, v]) => v !== ''),
) as Partial<VaultLorebookEntry>
entries[idx] = { ...entries[idx], ...safeData }
entries = [...entries]
}
break
@ -590,12 +610,22 @@
}
}
/** Approve all pending operations — snapshot first to avoid index-shifting bugs */
function handleApproveAll() {
if (!onApproveEntry) return
/** Approve all pending operations — use batch approval when available */
async function handleApproveAll() {
selectedOperationId = null
// Snapshot: handleApproveEntry mutates entries[] which shifts indices,
// so we collect all changes first and delegate to parent without optimistic splicing
if (onApproveAllAsync) {
const err = await onApproveAllAsync()
if (err) {
error = err
return
}
// After batch approval, resync local state from the vault
entries = JSON.parse(JSON.stringify(lorebook.entries))
locallyDeleted = new Set()
return
}
if (!onApproveEntry) return
// Fallback: iterate and approve one by one
const changes = [...pendingOperations].map((op) => op.primaryChange)
for (const change of changes) {
onApproveEntry(change)
@ -1080,6 +1110,16 @@
<span class="hidden sm:inline">Ask Assistant</span>
</Button>
{/if}
<Button
variant="outline"
class="border-surface-600 h-8 text-xs"
onclick={handleExport}
disabled={saving || entries.length === 0}
title="Export lorebook"
>
<Download class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Export</span>
</Button>
<div class="flex flex-1 items-center justify-end gap-2">
<Button
variant="outline"

View file

@ -15,6 +15,12 @@
let { data, onUpdate, changedFields }: Props = $props()
let textareaRef = $state<HTMLTextAreaElement | null>(null)
$effect(() => {
void data
textareaRef?.scrollTo(0, 0)
})
const changed = (field: string) =>
changedFields?.has(field)
? 'border-l-2 border-l-blue-400/50 bg-blue-500/5 pl-3 -ml-3 rounded-lg'
@ -100,6 +106,7 @@
<Label>Description / Content</Label>
<Textarea
bind:value={data.description}
bind:ref={textareaRef}
oninput={handleInput}
class="min-h-[200px] font-mono text-sm leading-relaxed"
placeholder="Enter the lore content here..."

View file

@ -35,6 +35,7 @@
import PromptPackList from './prompts/PromptPackList.svelte'
import PromptPackEditor from './prompts/PromptPackEditor.svelte'
import ImportPreviewDialog from './prompts/ImportPreviewDialog.svelte'
import VaultExportModal from './VaultExportModal.svelte'
import {
importExportService,
type ImportValidationResult,
@ -62,7 +63,15 @@
// State
let activeTab = $state<VaultTab>(ui.vaultTab)
let searchInput = $state('')
let searchQuery = $state('')
let debounceTimer: ReturnType<typeof setTimeout> | undefined
$effect(() => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
searchQuery = searchInput
}, 300)
})
let showFavoritesOnly = $state(false)
let selectedTags = $state<string[]>([])
let filterLogic = $state<'AND' | 'OR'>('OR')
@ -90,6 +99,10 @@
let showVaultAssistant = $state(false)
let assistantFocusedEntity = $state<FocusedEntity | null>(null)
// Export modal state
let exportEntity = $state<VaultLorebook | VaultCharacter | VaultScenario | null>(null)
let exportEntityType = $state<'lorebook' | 'character' | 'scenario' | null>(null)
async function openAssistantWithEntity(entity: FocusedEntity) {
showCharForm = false
editingCharacter = null
@ -216,6 +229,14 @@
return result
}
// Memoised filtered items per tab — only recomputes when deps change
let filteredByTab = $derived.by(() => ({
characters: getFilteredItems(characterVault.items as AnyVaultItem[]),
lorebooks: getFilteredItems(lorebookVault.items as AnyVaultItem[]),
scenarios: getFilteredItems(scenarioVault.items as AnyVaultItem[]),
prompts: [],
}))
// Load on mount
$effect(() => {
if (!characterVault.isLoaded) characterVault.load()
@ -326,14 +347,6 @@
else if (type === 'scenario') editingScenario = item as VaultScenario
}
async function handleDelete(id: string, store: any) {
await store.delete(id)
}
async function handleToggleFavorite(id: string, store: any) {
await store.toggleFavorite(id)
}
function handleOpenPack(packId: string) {
promptsViewState = { mode: 'editing', packId }
}
@ -535,7 +548,7 @@
<div class="flex items-center gap-2">
<Input
type="text"
bind:value={searchQuery}
bind:value={searchInput}
placeholder={`Search ${activeTab}...`}
class="bg-muted/40 flex-1"
leftIcon={SearchIcon}
@ -590,7 +603,7 @@
{/each}
</div>
{:else}
{@const filteredItems = getFilteredItems(section.store.items as AnyVaultItem[])}
{@const filteredItems = filteredByTab[section.id]}
{#if filteredItems.length === 0}
<div in:fade class="flex flex-1 flex-col items-center justify-center">
@ -626,8 +639,31 @@
item={item as AnyVaultItem}
type={section.type}
onEdit={() => handleEdit(item, section.type)}
onDelete={() => handleDelete(item.id, section.store)}
onToggleFavorite={() => handleToggleFavorite(item.id, section.store)}
onDelete={() => section.store.delete(item.id)}
onToggleFavorite={() => section.store.toggleFavorite(item.id)}
onExport={() => {
exportEntity = item
exportEntityType = section.type
}}
onDuplicate={async () => {
try {
const result = await section.store.duplicate(item.id)
if (!result) {
ui.showToast(
`Original ${section.singularLabel.toLowerCase()} not found`,
'error',
)
return
}
ui.showToast(`${section.singularLabel} duplicated`, 'info')
} catch (e) {
console.error('Duplicate failed:', e)
ui.showToast(
`Failed to duplicate ${section.singularLabel.toLowerCase()}`,
'error',
)
}
}}
/>
{/each}
</div>
@ -719,6 +755,17 @@
}}
/>
{/if}
<!-- Export Modal -->
{#if exportEntity && exportEntityType}
<VaultExportModal
entity={exportEntity}
entityType={exportEntityType}
onClose={() => {
exportEntity = null
exportEntityType = null
}}
/>
{/if}
<!-- Import Preview Dialog -->
<ImportPreviewDialog
open={importDialogOpen}

View file

@ -33,7 +33,7 @@
}
// Group variables by entity type
let grouped = $derived(() => {
let grouped = $derived.by(() => {
const groups: Record<RuntimeEntityType, RuntimeVariable[]> = {
character: [],
location: [],
@ -51,7 +51,7 @@
})
// Compute entity type counts for soft warning
let entityTypeCounts = $derived(() => {
let entityTypeCounts = $derived.by(() => {
const counts: Record<RuntimeEntityType, number> = {
character: 0,
location: 0,
@ -65,7 +65,7 @@
})
// Active entity types (those with at least one variable)
let activeEntityTypes = $derived(ENTITY_TYPE_ORDER.filter((et) => grouped()[et].length > 0))
let activeEntityTypes = $derived(ENTITY_TYPE_ORDER.filter((et) => grouped[et].length > 0))
function nextVariableName(): string {
let max = 0
@ -181,7 +181,7 @@
index: number,
direction: 'up' | 'down',
) {
const group = grouped()[entityType]
const group = grouped[entityType]
const newIndex = direction === 'up' ? index - 1 : index + 1
if (newIndex < 0 || newIndex >= group.length) return
@ -242,12 +242,12 @@
{ENTITY_TYPE_LABELS[entityType]}
</h4>
<span class="text-muted-foreground text-xs">
({grouped()[entityType].length})
({grouped[entityType].length})
</span>
</div>
<!-- Soft warning for 10+ variables -->
{#if entityTypeCounts()[entityType] >= 10}
{#if entityTypeCounts[entityType] >= 10}
<div
class="text-muted-foreground mb-2 flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs"
>
@ -260,7 +260,7 @@
{/if}
<div class="space-y-2">
{#each grouped()[entityType] as variable, i (variable.id)}
{#each grouped[entityType] as variable, i (variable.id)}
<RuntimeVariableCard
{variable}
onUpdate={handleUpdateVariable}
@ -271,7 +271,7 @@
initialExpanded={variable.id === newlyCreatedId}
entityTypeWarningCount={entityCounts[variable.id] ?? 0}
onMoveUp={i > 0 ? () => moveVariable(entityType, i, 'up') : undefined}
onMoveDown={i < grouped()[entityType].length - 1
onMoveDown={i < grouped[entityType].length - 1
? () => moveVariable(entityType, i, 'down')
: undefined}
/>

View file

@ -2,7 +2,7 @@
import { Card, CardContent } from '$lib/components/ui/card'
import { Button } from '$lib/components/ui/button'
import IconRow from '$lib/components/ui/icon-row.svelte'
import { Star, Pencil, Loader2 } from 'lucide-svelte'
import { Star, Pencil, Loader2, Download, Copy } from 'lucide-svelte'
import { cn } from '$lib/utils/cn'
import type { Snippet } from 'svelte'
@ -17,6 +17,8 @@
onEdit?: () => void
onDelete?: () => void
onToggleFavorite?: () => void
onExport?: () => void
onDuplicate?: () => void
onSelect?: () => void
// Styling
@ -38,6 +40,8 @@
onEdit,
onDelete,
onToggleFavorite,
onExport,
onDuplicate,
onSelect,
class: className,
icon,
@ -112,7 +116,7 @@
</div>
<!-- Actions -->
{#if !selectable && (onEdit || onDelete || onToggleFavorite)}
{#if !selectable && (onEdit || onDelete || onToggleFavorite || onExport || onDuplicate)}
<IconRow class="-mt-2" size="icon" {onDelete}>
{#if onToggleFavorite}
<Button
@ -147,6 +151,34 @@
title="Edit"
/>
{/if}
{#if onExport}
<Button
icon={Download}
variant="ghost"
size="icon"
class="hover:text-foreground text-muted-foreground h-3.5 w-5 transition-all hover:bg-transparent"
iconClass="h-3.5 w-3.5"
onclick={(e) => {
e.stopPropagation()
onExport?.()
}}
title="Export"
/>
{/if}
{#if onDuplicate}
<Button
icon={Copy}
variant="ghost"
size="icon"
class="hover:text-foreground text-muted-foreground h-3.5 w-5 transition-all hover:bg-transparent"
iconClass="h-3.5 w-3.5"
onclick={(e) => {
e.stopPropagation()
onDuplicate?.()
}}
title="Duplicate"
/>
{/if}
</IconRow>
{/if}
</div>

View file

@ -92,10 +92,10 @@ function createLorebookEntryTools(context: LorebookEntryToolContext) {
return {
entries: filtered.map((e) => {
// Find original index in full entries array
const originalIndex = entries.indexOf(e)
// Map to the full targetEntries array index
const fullIndex = targetEntries.indexOf(e)
return {
index: originalIndex,
index: fullIndex,
name: e.name,
type: e.type,
description: e.description.slice(0, 200) + (e.description.length > 200 ? '...' : ''),
@ -256,7 +256,7 @@ function createLorebookEntryTools(context: LorebookEntryToolContext) {
index: z.number().describe('Index of the entry to update'),
name: z.string().optional().describe('New name'),
type: entryTypeSchema.optional().describe('New type'),
description: z.string().optional().describe('New description'),
description: z.string().optional().describe('New description (only send if changing)'),
keywords: z.array(z.string()).optional().describe('New keywords (replaces existing)'),
aliases: z.array(z.string()).optional().describe('New aliases (replaces existing)'),
injectionMode: injectionModeSchema.optional().describe('New injection mode'),
@ -306,9 +306,10 @@ function createLorebookEntryTools(context: LorebookEntryToolContext) {
const previous = targetEntries[index]
const changeId = generateId()
// Filter out undefined values
// Filter out undefined and empty-string values to prevent
// accidental overwrites (e.g. the AI sending description: "")
const cleanUpdates = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined),
Object.entries(updates).filter(([_, v]) => v !== undefined && v !== ''),
) as Partial<VaultLorebookEntry>
const pendingChange: LorebookEntryPendingChangeSchema = {

View file

@ -695,7 +695,13 @@ export class InteractiveVaultService extends BaseAIService {
return
}
const entries = [...lorebook.entries]
// Get a fresh copy of entries from the vault store
const currentLorebook = lorebookVault.getById(change.lorebookId)
if (!currentLorebook) {
log('Lorebook disappeared during change application', { lorebookId: change.lorebookId })
return
}
const entries = [...currentLorebook.entries]
switch (change.action) {
case 'create':
@ -703,12 +709,36 @@ export class InteractiveVaultService extends BaseAIService {
break
case 'update':
if (change.entryIndex >= 0 && change.entryIndex < entries.length) {
entries[change.entryIndex] = { ...entries[change.entryIndex], ...change.data }
// Strip empty strings from update data to avoid accidentally
// overwriting existing content (e.g. AI sending description: '')
const safeData = Object.fromEntries(
Object.entries(change.data ?? {}).filter(([_, v]) => v !== ''),
) as Partial<VaultLorebookEntry>
entries[change.entryIndex] = { ...entries[change.entryIndex], ...safeData }
} else {
const error = `Update failed: index ${change.entryIndex} out of bounds (0-${entries.length - 1})`
log('Update entry index out of bounds', {
lorebookId: change.lorebookId,
entryIndex: change.entryIndex,
entriesLength: entries.length,
changeId: change.id,
changeData: change.data,
})
throw new Error(error)
}
break
case 'delete':
if (change.entryIndex >= 0 && change.entryIndex < entries.length) {
entries.splice(change.entryIndex, 1)
} else {
const error = `Delete failed: index ${change.entryIndex} out of bounds (0-${entries.length - 1})`
log('Delete entry index out of bounds', {
lorebookId: change.lorebookId,
entryIndex: change.entryIndex,
entriesLength: entries.length,
changeId: change.id,
})
throw new Error(error)
}
break
case 'merge':
@ -727,6 +757,13 @@ export class InteractiveVaultService extends BaseAIService {
}
await lorebookVault.update(change.lorebookId, { entries })
log('Applied lorebook entry change', {
action: change.action,
lorebookId: change.lorebookId,
entryIndex:
change.action === 'update' || change.action === 'delete' ? change.entryIndex : undefined,
newEntriesCount: entries.length,
})
}
/**

View file

@ -6,7 +6,7 @@ import type { Entry } from '$lib/types'
import type { SillyTavernEntry } from '../types'
export function entryToSillyTavern(entry: Entry, index: number): SillyTavernEntry {
const keywords = [entry.name, ...entry.aliases, ...entry.injection.keywords]
const keywords = [entry.name, ...(entry.aliases ?? []), ...(entry.injection.keywords ?? [])]
return {
uid: index,

View file

@ -59,11 +59,12 @@ export function exportToText(entries: Entry[]): string {
for (const entry of typeEntries) {
lines.push(`### ${entry.name}`)
if (entry.aliases.length > 0) {
lines.push(`Aliases: ${entry.aliases.join(', ')}`)
const aliases = entry.aliases ?? []
if (aliases.length > 0) {
lines.push(`Aliases: ${aliases.join(', ')}`)
}
lines.push('')
lines.push(entry.description)
lines.push(entry.description ?? '')
if (entry.hiddenInfo) {
lines.push('')
lines.push(`Hidden: ${entry.hiddenInfo}`)

View file

@ -0,0 +1,173 @@
/**
* Export functions for vault entities (lorebooks, characters, scenarios)
* These convert vault entity types to exportable formats.
*/
import { save } from '@tauri-apps/plugin-dialog'
import { writeTextFile } from '@tauri-apps/plugin-fs'
import type { VaultLorebook, VaultLorebookEntry, VaultCharacter, VaultScenario } from '$lib/types'
import type { Entry, EntryType } from '$lib/types'
import type { ExportFormat } from '../types'
import { exportToAventura, exportToSillyTavern, exportToText } from './formats'
import { getFormatInfo } from './metadata'
/**
* Convert a VaultLorebookEntry to an Entry-like structure for export.
* Synthesizes required fields that don't exist in vault entries.
*/
export function vaultEntryToEntryLike(vaultEntry: VaultLorebookEntry, index: number): Entry {
const now = Date.now()
return {
id: `vault-export-${index}`,
storyId: '',
name: vaultEntry.name,
type: vaultEntry.type,
description: vaultEntry.description,
hiddenInfo: null,
aliases: vaultEntry.aliases ?? [],
state: createDefaultState(vaultEntry.type),
adventureState: null,
creativeState: null,
injection: {
mode: vaultEntry.injectionMode,
keywords: vaultEntry.keywords ?? [],
priority: vaultEntry.priority,
},
firstMentioned: null,
lastMentioned: null,
mentionCount: 0,
createdBy: 'user' as const,
createdAt: now,
updatedAt: now,
loreManagementBlacklisted: false,
branchId: null,
}
}
/**
* Create default state for a given entry type.
*/
function createDefaultState(type: EntryType): Entry['state'] {
switch (type) {
case 'character':
return {
type: 'character',
isPresent: false,
lastSeenLocation: null,
currentDisposition: null,
relationship: { level: 0, status: 'unknown', history: [] },
knownFacts: [],
revealedSecrets: [],
}
case 'location':
return {
type: 'location',
isCurrentLocation: false,
visitCount: 0,
changes: [],
presentCharacters: [],
presentItems: [],
}
case 'item':
return {
type: 'item',
inInventory: false,
currentLocation: null,
condition: null,
uses: [],
}
case 'faction':
return {
type: 'faction',
playerStanding: 0,
status: 'unknown',
knownMembers: [],
}
case 'event':
return {
type: 'event',
occurred: false,
occurredAt: null,
witnesses: [],
consequences: [],
}
case 'concept':
default:
return {
type: 'concept',
revealed: false,
comprehensionLevel: 'unknown',
relatedEntries: [],
}
}
}
/**
* Export a vault lorebook to a file.
*/
export async function exportVaultLorebook(
lorebook: VaultLorebook,
format: ExportFormat,
): Promise<boolean> {
if (lorebook.entries.length === 0) {
throw new Error('No entries to export')
}
const entries = lorebook.entries.map((e, i) => vaultEntryToEntryLike(e, i))
const baseFilename = lorebook.name || `lorebook-${new Date().toISOString().split('T')[0]}`
let content: string
const extension = getFormatInfo(format).extension
switch (format) {
case 'aventura':
content = exportToAventura(entries)
break
case 'sillytavern':
content = exportToSillyTavern(entries, baseFilename)
break
case 'text':
content = exportToText(entries)
break
}
return await saveFile(content, baseFilename + extension)
}
/**
* Export a vault character to a JSON file.
*/
export async function exportVaultCharacter(character: VaultCharacter): Promise<boolean> {
const baseFilename = character.name || `character-${new Date().toISOString().split('T')[0]}`
const content = JSON.stringify(character, null, 2)
return await saveFile(content, `${baseFilename}.json`)
}
/**
* Export a vault scenario to a JSON file.
*/
export async function exportVaultScenario(scenario: VaultScenario): Promise<boolean> {
const baseFilename = scenario.name || `scenario-${new Date().toISOString().split('T')[0]}`
const content = JSON.stringify(scenario, null, 2)
return await saveFile(content, `${baseFilename}.json`)
}
async function saveFile(content: string, defaultPath: string): Promise<boolean> {
try {
const filePath = await save({
defaultPath,
filters: [
{ name: 'JSON', extensions: ['json'] },
{ name: 'Text', extensions: ['txt'] },
],
})
if (!filePath) return false
await writeTextFile(filePath, content)
return true
} catch (error) {
console.error('[VaultExporter] Failed to save file:', error)
throw error
}
}

View file

@ -4,19 +4,9 @@
import { save } from '@tauri-apps/plugin-dialog'
import { writeTextFile } from '@tauri-apps/plugin-fs'
import type { LorebookExportOptions, ExportFormat } from '../types'
import type { LorebookExportOptions } from '../types'
import { exportToAventura, exportToSillyTavern, exportToText } from './formats'
function getFileExtension(format: ExportFormat): string {
switch (format) {
case 'aventura':
return '.json'
case 'sillytavern':
return '.json'
case 'text':
return '.txt'
}
}
import { getFormatInfo } from './metadata'
async function saveFile(content: string, defaultPath: string): Promise<boolean> {
try {
@ -47,7 +37,7 @@ export async function exportLorebook(options: LorebookExportOptions): Promise<bo
let content: string
const baseFilename = filename ?? `lorebook-${new Date().toISOString().split('T')[0]}`
const extension = getFileExtension(format)
const extension = getFormatInfo(format).extension
switch (format) {
case 'aventura':

View file

@ -26,3 +26,6 @@ export { importEntries } from './import/orchestrator'
// Export operations
export { exportLorebook } from './export/write'
export { getFormatInfo } from './export/metadata'
// Vault export operations
export { exportVaultLorebook, exportVaultCharacter, exportVaultScenario } from './export/vault'

View file

@ -155,6 +155,27 @@ class CharacterVaultStore {
return this.characters.find((c) => c.id === id)
}
/**
* Duplicate a character with a new ID and "(Copy)" suffix.
*/
async duplicate(id: string): Promise<VaultCharacter | null> {
const original = this.getById(id)
if (!original) return null
return this.add({
name: `${original.name} (Copy)`,
description: original.description,
traits: [...original.traits],
visualDescriptors: { ...original.visualDescriptors },
portrait: original.portrait,
tags: [...original.tags],
favorite: false,
source: original.source,
originalStoryId: null,
metadata: original.metadata ? { ...original.metadata } : null,
})
}
/**
* Import a sanitized character (from LLM processing).
*/

View file

@ -165,6 +165,26 @@ class LorebookVaultStore {
return this.lorebooks.find((lb) => lb.id === id)
}
/**
* Duplicate a lorebook with a new ID and "(Copy)" suffix.
*/
async duplicate(id: string): Promise<VaultLorebook | null> {
const original = this.getById(id)
if (!original) return null
return this.add({
name: `${original.name} (Copy)`,
description: original.description,
entries: JSON.parse(JSON.stringify(original.entries)),
tags: [...original.tags],
favorite: false,
source: original.source,
originalFilename: null,
originalStoryId: null,
metadata: original.metadata ? JSON.parse(JSON.stringify(original.metadata)) : null,
})
}
/**
* Search vault lorebooks.
*/

View file

@ -78,6 +78,29 @@ class ScenarioVaultStore {
return this.scenarios.find((s) => s.id === id)
}
/**
* Duplicate a scenario with a new ID and "(Copy)" suffix.
*/
async duplicate(id: string): Promise<VaultScenario | null> {
const original = this.getById(id)
if (!original) return null
return this.add({
name: `${original.name} (Copy)`,
description: original.description,
settingSeed: original.settingSeed,
npcs: JSON.parse(JSON.stringify(original.npcs)),
primaryCharacterName: original.primaryCharacterName,
firstMessage: original.firstMessage,
alternateGreetings: [...(original.alternateGreetings || [])],
tags: [...original.tags],
favorite: false,
source: original.source,
originalFilename: null,
metadata: original.metadata ? JSON.parse(JSON.stringify(original.metadata)) : null,
})
}
async search(query: string): Promise<VaultScenario[]> {
if (!query.trim()) {
return this.scenarios

View file

@ -8,7 +8,7 @@
* - Approval / rejection / edit workflows
*/
import type { VaultPendingChange } from '$lib/services/ai/sdk/schemas/vault'
import type { VaultPendingChange } from '$lib/services/ai/sdk/schemas'
import type { VaultLorebook, VaultLorebookEntry } from '$lib/types'
import type { InteractiveVaultService } from '$lib/services/ai/vault/InteractiveVaultService'
import { lorebookVault } from './lorebookVault.svelte'
@ -109,15 +109,96 @@ class VaultEditorStore {
if (!lorebook) return null
const copy = JSON.parse(JSON.stringify(lorebook)) as VaultLorebook
// Overlay update changes onto the preview copy
if (change.action === 'update' && 'data' in change && typeof change.entryIndex === 'number') {
if (change.entryIndex >= 0 && change.entryIndex < copy.entries.length) {
copy.entries[change.entryIndex] = {
...copy.entries[change.entryIndex],
...(change.data as VaultLorebookEntry),
// Overlay ALL pending changes for this lorebook onto the preview copy
// Process deletes/merges in descending index order to avoid shifting
const entryChanges = this.pendingChanges
.filter(
(c): c is Extract<VaultPendingChange, { entityType: 'lorebook-entry' }> =>
c.entityType === 'lorebook-entry' &&
c.status === 'pending' &&
(c as Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>).lorebookId ===
change.lorebookId,
)
.map(
(c) =>
(this._editedChanges.get(c.id) ?? c) as Extract<
VaultPendingChange,
{ entityType: 'lorebook-entry' }
>,
)
const deletesAndMerges = entryChanges.filter(
(
c,
): c is Extract<
VaultPendingChange,
{ entityType: 'lorebook-entry'; action: 'delete' | 'merge' }
> => c.action === 'delete' || c.action === 'merge',
)
const creates = entryChanges.filter(
(c): c is Extract<VaultPendingChange, { entityType: 'lorebook-entry'; action: 'create' }> =>
c.action === 'create',
)
const updates = entryChanges.filter(
(c): c is Extract<VaultPendingChange, { entityType: 'lorebook-entry'; action: 'update' }> =>
c.action === 'update',
)
// Apply updates before deletes/merges so index references are stable
// (updates don't add/remove entries, so they don't shift positions)
for (const entryChange of updates) {
if (
typeof entryChange.entryIndex === 'number' &&
entryChange.entryIndex >= 0 &&
entryChange.entryIndex < copy.entries.length
) {
const safeData = Object.fromEntries(
Object.entries(entryChange.data ?? {}).filter(([_, v]) => v !== ''),
) as Partial<VaultLorebookEntry>
copy.entries[entryChange.entryIndex] = {
...copy.entries[entryChange.entryIndex],
...safeData,
}
}
}
deletesAndMerges.sort((a, b) => {
const aIdx = a.action === 'delete' ? a.entryIndex : Math.max(...a.entryIndices)
const bIdx = b.action === 'delete' ? b.entryIndex : Math.max(...b.entryIndices)
return bIdx - aIdx
})
for (const entryChange of deletesAndMerges) {
switch (entryChange.action) {
case 'delete':
if (
typeof entryChange.entryIndex === 'number' &&
entryChange.entryIndex >= 0 &&
entryChange.entryIndex < copy.entries.length
) {
copy.entries.splice(entryChange.entryIndex, 1)
}
break
case 'merge':
if (entryChange.entryIndices) {
const sorted = [...entryChange.entryIndices].sort((a, b) => b - a)
for (const idx of sorted) {
if (idx >= 0 && idx < copy.entries.length) {
copy.entries.splice(idx, 1)
}
}
copy.entries.push(entryChange.data)
}
break
}
}
for (const entryChange of creates) {
if (!copy.entries.some((e) => e.name === entryChange.data.name)) {
copy.entries.push(entryChange.data)
}
}
return copy
}
@ -234,6 +315,8 @@ class VaultEditorStore {
/**
* Approve a single pending change.
* Uses the edited version if the user modified it before approving.
* Re-indexes remaining pending lorebook-entry changes if this approval
* shifts array indices (delete / merge).
*/
async approve(change: VaultPendingChange, service: InteractiveVaultService): Promise<void> {
if (change.status !== 'pending') return
@ -252,6 +335,9 @@ class VaultEditorStore {
if (c.id === change.id) c.status = 'approved'
}
// Re-index remaining pending changes whose indices may have shifted
this._reindexAfterApproval(effectiveChange)
// Auto-close logic: keep open for lorebook-related, close for others
this._autoCloseAfterAction(change)
@ -276,10 +362,86 @@ class VaultEditorStore {
this.pendingChanges = [...this.pendingChanges]
}
/** Approve all pending changes */
/** Approve all pending changes — processes deletes/merges in descending index order to avoid shifting */
async approveAll(service: InteractiveVaultService): Promise<string | null> {
const pending = this.pendingChanges.filter((c) => c.status === 'pending')
for (const change of pending) {
// Separate by type and action
const lorebookEntryChanges = pending.filter(
(c): c is Extract<VaultPendingChange, { entityType: 'lorebook-entry' }> =>
c.entityType === 'lorebook-entry',
)
const otherChanges = pending.filter((c) => c.entityType !== 'lorebook-entry')
// For lorebook-entry changes: group by lorebook, then process deletes/merges in descending order
const byLorebook = new SvelteMap<
string,
Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>[]
>()
for (const change of lorebookEntryChanges) {
const group = byLorebook.get(change.lorebookId) ?? []
group.push(change)
byLorebook.set(change.lorebookId, group)
}
// Process each lorebook's changes: deletes/merges descending, then creates/updates ascending
for (const [_lorebookId, changes] of byLorebook) {
const deletesAndMerges = changes.filter((c) => c.action === 'delete' || c.action === 'merge')
const createsAndUpdates = changes.filter(
(c) => c.action === 'create' || c.action === 'update',
)
// Sort deletes/merges by descending entryIndex (or highest entryIndex for merges)
deletesAndMerges.sort((a, b) => {
const aIdx = a.action === 'delete' ? a.entryIndex : Math.max(...a.entryIndices)
const bIdx = b.action === 'delete' ? b.entryIndex : Math.max(...b.entryIndices)
return bIdx - aIdx
})
// Process deletes/merges first (descending order)
for (const change of deletesAndMerges) {
try {
const effectiveChange = this._editedChanges.get(change.id) ?? change
await service.applyChange(effectiveChange)
service.handleApproval(change, true)
const edits = new SvelteMap(this._editedChanges)
edits.delete(change.id)
this._editedChanges = edits
for (const c of this.pendingChanges) {
if (c.id === change.id) c.status = 'approved'
}
// Re-index remaining pending changes after each delete/merge
this._reindexAfterApproval(effectiveChange)
} catch (e) {
return e instanceof Error ? e.message : 'Failed to apply change'
}
}
// Process creates/updates (order doesn't matter for index shifting)
for (const change of createsAndUpdates) {
try {
const effectiveChange = this._editedChanges.get(change.id) ?? change
await service.applyChange(effectiveChange)
service.handleApproval(change, true)
const edits = new SvelteMap(this._editedChanges)
edits.delete(change.id)
this._editedChanges = edits
for (const c of this.pendingChanges) {
if (c.id === change.id) c.status = 'approved'
}
} catch (e) {
return e instanceof Error ? e.message : 'Failed to apply change'
}
}
}
// Process non-lorebook-entry changes (characters, scenarios, lorebooks)
for (const change of otherChanges) {
try {
const effectiveChange = this._editedChanges.get(change.id) ?? change
await service.applyChange(effectiveChange)
@ -288,18 +450,15 @@ class VaultEditorStore {
const edits = new SvelteMap(this._editedChanges)
edits.delete(change.id)
this._editedChanges = edits
for (const c of this.pendingChanges) {
if (c.id === change.id) c.status = 'approved'
}
} catch (e) {
return e instanceof Error ? e.message : 'Failed to apply change'
}
}
// Mark all as approved
for (const c of this.pendingChanges) {
if (pending.some((p) => p.id === c.id)) {
c.status = 'approved'
}
}
// Auto-close unless active editor is lorebook-related
if (this.activeChange) {
this._autoCloseAfterAction(this.activeChange)
@ -357,6 +516,129 @@ class VaultEditorStore {
this.closeEditor()
}
}
/**
* Re-index remaining pending lorebook-entry changes after an approval
* that modified the entries array (delete or merge).
* Mutates in-place so all refs (including snapshots in approveAll) stay current.
*/
private _reindexAfterApproval(approvedChange: VaultPendingChange): void {
// Only lorebook-entry changes need re-indexing
if (approvedChange.entityType !== 'lorebook-entry') return
if (approvedChange.action !== 'delete' && approvedChange.action !== 'merge') return
const lorebookId =
approvedChange.entityType === 'lorebook-entry' ? approvedChange.lorebookId : null
if (!lorebookId) return
// Update pendingChanges in-place (entries are $state proxy objects)
for (let i = 0; i < this.pendingChanges.length; i++) {
const c = this.pendingChanges[i]
if (
c.entityType !== 'lorebook-entry' ||
c.status !== 'pending' ||
c.id === approvedChange.id
) {
continue
}
const entryChange = c as Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>
if (entryChange.lorebookId !== lorebookId) continue
const result = this._shiftIndexForApproval(approvedChange, entryChange)
if (result !== null) {
if (entryChange.action === 'delete' || entryChange.action === 'update') {
entryChange.entryIndex = result
}
// For merge, entryIndices was already mutated in-place by _shiftIndexForApproval
}
}
// Also update _editedChanges — SvelteMap values are proxied, mutate in-place
for (const id of [...this._editedChanges.keys()]) {
const edited = this._editedChanges.get(id)
if (
!edited ||
edited.entityType !== 'lorebook-entry' ||
edited.id === approvedChange.id ||
(edited as Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>).lorebookId !==
lorebookId
) {
continue
}
const entryEdited = edited as Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>
const result = this._shiftIndexForApproval(approvedChange, entryEdited)
if (result !== null) {
if (entryEdited.action === 'delete' || entryEdited.action === 'update') {
entryEdited.entryIndex = result
}
// For merge, entryIndices was already mutated in-place by _shiftIndexForApproval
}
}
}
/**
* Compute the new index for a pending change after an approval.
* Returns null if no change needed.
*/
private _shiftIndexForApproval(
approved: VaultPendingChange,
pending: Extract<VaultPendingChange, { entityType: 'lorebook-entry' }>,
): number | null {
if (approved.entityType !== 'lorebook-entry') return null
if (approved.action === 'delete') {
const deletedIndex = approved.entryIndex
if (pending.action === 'delete' || pending.action === 'update') {
const pendingIndex = pending.entryIndex
if (pendingIndex > deletedIndex) {
return pendingIndex - 1
}
} else if (pending.action === 'merge') {
// Merge has entryIndices array - shift each one
const newIndices = pending.entryIndices.map((idx) => (idx > deletedIndex ? idx - 1 : idx))
// Check if array changed
if (newIndices.some((idx, i) => idx !== pending.entryIndices[i])) {
;(
pending as Extract<
VaultPendingChange,
{ entityType: 'lorebook-entry'; action: 'merge' }
>
).entryIndices = newIndices
// Return 0 to signal a change happened (the caller will use the mutated entryIndices)
return 0
}
}
} else if (approved.action === 'merge') {
// Merge removes source entries (in descending order) then appends the result
const removedIndices = [...approved.entryIndices].sort((a, b) => b - a) // descending
let totalShift = 0
let mergeChanged = false
for (const removedIdx of removedIndices) {
if (pending.action === 'delete' || pending.action === 'update') {
if (pending.entryIndex > removedIdx) {
totalShift++
}
} else if (pending.action === 'merge') {
// Shift indices in the merge's entryIndices array
const newIndices = pending.entryIndices.map((idx) => (idx > removedIdx ? idx - 1 : idx))
;(
pending as Extract<
VaultPendingChange,
{ entityType: 'lorebook-entry'; action: 'merge' }
>
).entryIndices = newIndices
mergeChanged = true
}
}
if ((pending.action === 'delete' || pending.action === 'update') && totalShift > 0) {
return pending.entryIndex - totalShift
}
if (pending.action === 'merge' && mergeChanged) {
return 0
}
}
return null
}
}
export const vaultEditor = new VaultEditorStore()