mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-08-17 04:53:22 +00:00
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 -->
[](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:
parent
a5a21c0ebb
commit
d1929ff432
24 changed files with 1102 additions and 170 deletions
64
package-lock.json
generated
64
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
81
src/lib/components/vault/VaultAssistantInput.svelte
Normal file
81
src/lib/components/vault/VaultAssistantInput.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
146
src/lib/components/vault/VaultExportModal.svelte
Normal file
146
src/lib/components/vault/VaultExportModal.svelte
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
173
src/lib/services/lorebookImportExport/export/vault.ts
Normal file
173
src/lib/services/lorebookImportExport/export/vault.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue