diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b763c95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,343 @@ +# AGENTS.md + +This file contains guidelines for agentic coding assistants working on the Aventura codebase. + +## Build/Lint/Test Commands + +```bash +# Development +npm run dev # Start dev server (Vite) + +# Type Checking +npm run check # Run svelte-check (type checking) +npm run check:watch # Watch mode type checking + +# Production Build +npm run build # Build for production +npm run preview # Preview production build + +# Tauri (desktop app) +npm run tauri # Tauri CLI commands + +# Running specific checks +npx svelte-check --tsconfig ./tsconfig.json # Direct type check +``` + +**Note**: No test suite is currently configured. When adding tests, update this file. + +## Project Architecture + +- **Framework**: SvelteKit 2 + Tauri 2 (desktop app) +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS +- **State Management**: Svelte 5 runes ($state, $derived, $effect) +- **Database**: SQLite via @tauri-apps/plugin-sql +- **AI Integration**: OpenAI-compatible APIs (OpenRouter, custom providers) + +## Code Style Guidelines + +### File Organization + +``` +src/ +├── routes/ # SvelteKit pages (use +page.svelte, +layout.svelte) +├── lib/ +│ ├── components/ # Svelte components (PascalCase.svelte) +│ ├── services/ # Business logic classes (PascalCase.ts) +│ ├── stores/ # Svelte stores (*.svelte.ts for runes) +│ ├── types/ # TypeScript types (index.ts) +│ └── utils/ # Utility functions (camelCase.ts) +``` + +### Naming Conventions + +- **Components**: `PascalCase.svelte` (e.g., `StoryView.svelte`) +- **Services/Classes**: `PascalCase.ts` (e.g., `AIService.ts`) +- **Functions/Methods**: `camelCase` (e.g., `generateResponse()`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_MEMORY_CONFIG`) +- **Types/Interfaces**: `PascalCase` (e.g., `interface Story`, `type StoryMode`) +- **Variables**: `camelCase` (e.g., `currentStory`, `isLoading`) +- **Event handlers**: `handle` (e.g., `handleSubmit`, `handleScroll`) + +### Imports and Type Imports + +```typescript +// Type imports use 'type' keyword +import type { Story, StoryEntry } from '$lib/types'; +import { settings } from '$lib/stores/settings.svelte'; + +// Non-type imports +import { marked } from 'marked'; +``` + +Use `$lib` alias for src/lib imports. Always use `type` keyword for type-only imports. + +### Svelte Component Patterns + +```typescript + + + + + +{#if isOpen} + isOpen = false} /> +{/if} +``` + +### TypeScript Patterns + +- **Strict mode enabled**: All code must be type-safe +- **Interfaces over types**: Use `interface` for object shapes, `type` for unions +- **Optional chaining**: Use `?.` for safe property access +- **Nullish coalescing**: Use `??` for fallbacks + +```typescript +// Async functions with proper error handling +async function loadStory(storyId: string): Promise { + try { + const story = await database.getStory(storyId); + if (!story) { + throw new Error(`Story not found: ${storyId}`); + } + this.currentStory = story; + } catch (error) { + console.error('[StoryStore] Failed to load story:', error); + throw error; + } +} + +// Type guards for runtime checks +function isValidEntry(entry: any): entry is StoryEntry { + return entry && typeof entry.content === 'string' && typeof entry.type === 'string'; +} +``` + +### Error Handling + +- **Logging**: Use consistent prefix pattern for console logs + +```typescript +const DEBUG = true; + +function log(...args: any[]) { + if (DEBUG) { + console.log('[FeatureName]', ...args); + } +} + +// Always validate before operations +if (!this.currentStory) { + throw new Error('No story loaded'); +} +``` + +- **Error messages**: Descriptive with context (include IDs, names, etc.) + +### State Management + +Svelte 5 runes are used for reactive state: + +```typescript +// Class-based store pattern +class StoryStore { + currentStory = $state(null); + entries = $state([]); + + // Computed properties (getters) + get activeCharacters(): Character[] { + return this.characters.filter(c => c.status === 'active'); + } + + // Methods update state immutably + async addEntry(content: string): Promise { + const entry = await database.addEntry({ content }); + this.entries = [...this.entries, entry]; // Spread for immutability + } +} + +export const story = new StoryStore(); +``` + +### Service Classes + +Services encapsulate business logic and external API interactions: + +```typescript +class AIService { + private getProvider() { + const apiSettings = settings.getApiSettingsForProfile(profileId); + if (!apiSettings.openaiApiKey) { + throw new Error(`No API key configured`); + } + return new OpenAIProvider(apiSettings); + } + + async generateResponse(context: Context): Promise { + // Implementation + } +} + +export const aiService = new AIService(); +``` + +### Database Operations + +Use the database service for all data persistence: + +```typescript +// Parallel queries for performance +const [entries, characters, locations] = await Promise.all([ + database.getStoryEntries(storyId), + database.getCharacters(storyId), + database.getLocations(storyId), +]); + +// CRUD operations with state updates +async updateCharacter(id: string, updates: Partial): Promise { + await database.updateCharacter(id, updates); + this.characters = this.characters.map(c => + c.id === id ? { ...c, ...updates } : c + ); +} +``` + +### JSDoc Comments + +Add JSDoc for complex functions, not simple getters: + +```typescript +/** + * Generate a summary and metadata for a chapter. + * @param entries - The entries to summarize + * @param previousChapters - Previous chapter summaries for context (optional) + * @returns ChapterSummary with summary text and metadata + */ +async summarizeChapter( + entries: StoryEntry[], + previousChapters?: Chapter[] +): Promise { + // Implementation +} +``` + +### Tailwind CSS + +- Use responsive classes: `sm:`, `md:`, `lg:` prefixes +- Dark mode with `surface-*` tokens (custom design system) +- Flexbox/Grid layouts preferred +- Spacing: `p-3 sm:p-4`, `space-y-3 sm:space-y-4` +- Mobile-safe areas: `pb-safe` for iOS bottom notch + +```html +
+
+ {#each items as item (item.id)} +
{item.name}
+ {/each} +
+
+``` + +### Event Patterns + +- Custom events via callbacks: `onConfirm={() => ...}` +- DOM events: `onclick`, `onsubmit`, `onscroll` +- Bindings: `bind:value`, `bind:this={element}` + +## AI Integration Patterns + +The codebase uses multiple AI services with profile-based configuration: + +```typescript +// Get provider for specific service +const provider = this.getProviderForProfile( + settings.systemServicesSettings.memory.profileId +); + +// Stream responses with async generators +async *streamResponse( + entries: StoryEntry[], + signal?: AbortSignal +): AsyncIterable { + for await (const chunk of provider.streamResponse({...})) { + yield chunk; + } +} +``` + +## Common Patterns + +### Conditional Logging + +```typescript +const DEBUG = true; // Set to false in production + +function log(...args: any[]) { + if (DEBUG) console.log('[Module]', ...args); +} +``` + +### Immutability + +Always spread arrays/objects for state updates: + +```typescript +// Good +this.entries = [...this.entries, newEntry]; +this.characters = this.characters.map(c => + c.id === id ? { ...c, updates } : c +); + +// Avoid direct mutations +this.entries.push(newEntry); // Bad +``` + +### UUID Generation + +```typescript +import { crypto } from 'crypto'; + +const id = crypto.randomUUID(); +``` + +## When in Doubt + +- Follow existing patterns in similar files +- Check nearby components/services for conventions +- Use TypeScript strict mode - fix all type errors +- Run `npm run check` before committing +- Keep functions small and focused +- Use descriptive names over comments diff --git a/package-lock.json b/package-lock.json index a40af76..09d2018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -923,6 +923,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -962,6 +963,7 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1311,6 +1313,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1470,6 +1473,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1926,6 +1930,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2148,6 +2153,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2195,6 +2201,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2567,6 +2574,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2790,6 +2798,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2842,6 +2851,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 455f23b..07af254 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -260,7 +260,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aventura" -version = "0.3.2" +version = "0.3.3" dependencies = [ "axum", "base64 0.22.1", diff --git a/src/lib/components/wizard/SetupWizard.svelte b/src/lib/components/wizard/SetupWizard.svelte index 849462c..69447f9 100644 --- a/src/lib/components/wizard/SetupWizard.svelte +++ b/src/lib/components/wizard/SetupWizard.svelte @@ -12,13 +12,17 @@ type GeneratedOpening, type Tense, } from '$lib/services/ai/scenario'; - import { +import { parseSillyTavernLorebook, classifyEntriesWithLLM, getImportSummary, type ImportedEntry, type LorebookImportResult, } from '$lib/services/lorebookImporter'; + import { + convertCardToScenario, + type CardImportResult, + } from '$lib/services/characterCardImporter'; import type { StoryMode, POV, EntryType } from '$lib/types'; import { X, @@ -98,7 +102,7 @@ let supportingCharacterTraits = $state(''); let isElaboratingSupportingCharacter = $state(false); - // Step 2: Import Lorebook (optional - moved to early position) +// Step 2: Import Lorebook (optional - moved to early position) let importedLorebook = $state(null); let importedEntries = $state([]); let isImporting = $state(false); @@ -107,6 +111,17 @@ let importError = $state(null); let importFileInput: HTMLInputElement | null = null; + // Step 4: Character Card Import (optional) + let isImportingCard = $state(false); + let cardImportError = $state(null); + let importedCardNpcs = $state([]); + let cardImportFileInput: HTMLInputElement | null = null; + // Card-imported opening scene data (used in step 7) + let cardImportedTitle = $state(null); + let cardImportedFirstMessage = $state(null); + let cardImportedAlternateGreetings = $state([]); + let selectedGreetingIndex = $state(0); // 0 = first_mes, 1+ = alternate greetings + // Step 6: Writing Style let selectedPOV = $state('first'); let selectedTense = $state('present'); @@ -540,11 +555,20 @@ return; } + // Get protagonist name for {{user}} replacement + const protagonistName = protagonist?.name || 'the protagonist'; + + // Replace {{user}} placeholders in the opening scene + const processedOpening = { + ...generatedOpening, + scene: generatedOpening.scene.replace(/\{\{user\}\}/gi, protagonistName), + }; + const wizardData: WizardData = { mode: selectedMode, genre: selectedGenre, customGenre: customGenre || undefined, - settingSeed, + settingSeed: settingSeed.replace(/\{\{user\}\}/gi, protagonistName), expandedSetting: expandedSetting || undefined, protagonist: protagonist || undefined, characters: supportingCharacters.length > 0 ? supportingCharacters : undefined, @@ -558,7 +582,7 @@ }; // Prepare story data - const storyData = scenarioService.prepareStoryData(wizardData, generatedOpening); + const storyData = scenarioService.prepareStoryData(wizardData, processedOpening); // Create the story using the store, including any imported entries const newStory = await story.createStoryFromWizard({ @@ -649,7 +673,7 @@ } } - function clearImport() { +function clearImport() { importedLorebook = null; importedEntries = []; importError = null; @@ -660,6 +684,81 @@ } } + // Character Card Import handler + async function handleCardImport(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + cardImportError = null; + isImportingCard = true; + + try { + const content = await file.text(); + const result = await convertCardToScenario( + content, + selectedMode, + selectedGenre + ); + + if (!result.success && result.errors.length > 0) { + // Show error but still use fallback if available + cardImportError = result.errors.join('; '); + } + + if (result.settingSeed) { + // Set the setting seed from the card + settingSeed = result.settingSeed; + // Clear any previous expanded setting since we have new content + expandedSetting = null; + } + + if (result.npcs && result.npcs.length > 0) { + // Add imported NPCs to supporting characters + supportingCharacters = [...supportingCharacters, ...result.npcs]; + // Also store reference for display purposes + importedCardNpcs = result.npcs; + } + + // Store card-imported opening scene data for step 7 + if (result.storyTitle) { + cardImportedTitle = result.storyTitle; + storyTitle = result.storyTitle; // Pre-fill the story title + } + if (result.firstMessage) { + cardImportedFirstMessage = result.firstMessage; + cardImportedAlternateGreetings = result.alternateGreetings || []; + selectedGreetingIndex = 0; + } + + // Reset the file input + if (cardImportFileInput) { + cardImportFileInput.value = ''; + } + } catch (err) { + cardImportError = err instanceof Error ? err.message : 'Failed to import character card'; + } finally { + isImportingCard = false; + } + } + + function clearCardImport() { + // Remove card-imported NPCs from supporting characters + if (importedCardNpcs.length > 0) { + const importedNames = new Set(importedCardNpcs.map(n => n.name)); + supportingCharacters = supportingCharacters.filter(c => !importedNames.has(c.name)); + } + importedCardNpcs = []; + cardImportError = null; + cardImportedTitle = null; + cardImportedFirstMessage = null; + cardImportedAlternateGreetings = []; + selectedGreetingIndex = 0; + if (cardImportFileInput) { + cardImportFileInput.value = ''; + } + } + // Get entry type icon color function getTypeColor(type: EntryType): string { switch (type) { @@ -673,6 +772,18 @@ } } + // Check if setting seed contains {{user}} placeholder + const hasUserPlaceholder = $derived(settingSeed.includes('{{user}}')); + + // Helper to style {{user}} placeholders in text for display + // Returns HTML with {{user}} styled as inline tags + function styleUserPlaceholders(text: string): string { + return text.replace( + /\{\{user\}\}/gi, + '{{user}}' + ); + } + // Derived import summary const importSummary = $derived( importedEntries.length > 0 ? getImportSummary(importedEntries) : null @@ -953,12 +1064,66 @@ {/if} - {:else if currentStep === 4} +{:else if currentStep === 4}

Describe your world in a few sentences. The AI will expand it into a rich setting.

+ + +
+
+
+ + Import Character Card + (Optional) +
+ {#if importedCardNpcs.length > 0} + + {/if} +
+

+ Import a SillyTavern character card (.json) to generate a setting with the character as an NPC. +

+
+ + + {#if importedCardNpcs.length > 0} + + + Imported: {importedCardNpcs.map(n => n.name).join(', ')} + + {/if} +
+ {#if cardImportError} +

{cardImportError}

+ {/if} +
+
{#if settingSeed.trim().length > 0 && !expandedSetting} @@ -1504,6 +1675,84 @@ />
+ + {#if cardImportedFirstMessage} +
+
+
+ +

Imported Opening Scene

+
+ +
+ + + {#if cardImportedAlternateGreetings.length > 0} +
+ +
+ + {#each cardImportedAlternateGreetings as _, i} + + {/each} +
+
+ {/if} + + +
+

+ {@html styleUserPlaceholders(selectedGreetingIndex === 0 ? (cardImportedFirstMessage || '') : (cardImportedAlternateGreetings[selectedGreetingIndex - 1] || ''))} +

+
+ {#if (selectedGreetingIndex === 0 ? cardImportedFirstMessage : cardImportedAlternateGreetings[selectedGreetingIndex - 1])?.includes('{{user}}')} +

+ {'{{user}}'} + will be replaced with your character's name +

+ {/if} + + +
+ {/if} + {#if selectedMode === 'creative-writing'}
@@ -1539,8 +1788,10 @@ {generatedOpening ? 'Regenerate Opening' : 'Generate Opening Scene'} {/if} - {#if !generatedOpening && !isGeneratingOpening} + {#if !generatedOpening && !isGeneratingOpening && !cardImportedFirstMessage} Required to begin story + {:else if !generatedOpening && !isGeneratingOpening && cardImportedFirstMessage} + Or use the imported opening above {/if}
{:else} diff --git a/src/lib/services/characterCardImporter.ts b/src/lib/services/characterCardImporter.ts new file mode 100644 index 0000000..f4a8f20 --- /dev/null +++ b/src/lib/services/characterCardImporter.ts @@ -0,0 +1,474 @@ +/** + * Character Card Importer Service + * + * Imports SillyTavern character cards (V1/V2 JSON format) into Aventura's wizard. + * Converts character cards into scenario settings with the card character as an NPC. + */ + +import type { StoryMode } from '$lib/types'; +import type { Genre, GeneratedCharacter } from '$lib/services/ai/scenario'; +import { OpenAIProvider } from './ai/openrouter'; +import { settings } from '$lib/stores/settings.svelte'; +import { buildExtraBody } from '$lib/services/ai/requestOverrides'; + +const DEBUG = true; + +function log(...args: any[]) { + if (DEBUG) { + console.log('[CharacterCardImporter]', ...args); + } +} + +// ===== SillyTavern Card Types ===== + +/** + * SillyTavern V1 card format (also the core data for V2) + */ +export interface SillyTavernCardV1 { + name: string; + description: string; + personality: string; + scenario: string; + first_mes: string; + mes_example: string; +} + +/** + * SillyTavern V2/V3 card format (V3 has same structure as V2) + */ +export interface SillyTavernCardV2 { + spec: 'chara_card_v2' | 'chara_card_v3'; + spec_version: string; + data: SillyTavernCardV1 & { + creator_notes?: string; + system_prompt?: string; + post_history_instructions?: string; + alternate_greetings?: string[]; + character_book?: unknown; // Lorebook - ignored for this import + tags?: string[]; + creator?: string; + character_version?: string; + extensions?: Record; + }; + // V3 cards also duplicate fields at root level + name?: string; + description?: string; + personality?: string; + scenario?: string; + first_mes?: string; + mes_example?: string; +} + +/** + * Parsed card data (normalized from V1, V2, or V3) + */ +export interface ParsedCard { + name: string; + description: string; + personality: string; + scenario: string; + firstMessage: string; + alternateGreetings: string[]; + exampleMessages: string; + version: 'v1' | 'v2' | 'v3'; +} + +/** + * Result of card import/conversion + */ +export interface CardImportResult { + success: boolean; + settingSeed: string; + /** NPCs identified from the card content */ + npcs: GeneratedCharacter[]; + /** The primary character name (for replacing {{char}} in first_mes) */ + primaryCharacterName: string; + /** Card name to use as story title */ + storyTitle: string; + /** First message to use as opening scene (with {{char}} replaced) */ + firstMessage: string; + /** Alternate greetings the user can choose from (with {{char}} replaced) */ + alternateGreetings: string[]; + errors: string[]; +} + +// ===== Card Parsing ===== + +/** + * Check if the data is a V2 or V3 card + */ +function isV2OrV3Card(data: unknown): data is SillyTavernCardV2 { + if (typeof data !== 'object' || data === null) return false; + if (!('spec' in data) || !('data' in data)) return false; + const spec = (data as SillyTavernCardV2).spec; + return spec === 'chara_card_v2' || spec === 'chara_card_v3'; +} + +/** + * Check if the data is a V1 card + */ +function isV1Card(data: unknown): data is SillyTavernCardV1 { + return ( + typeof data === 'object' && + data !== null && + 'name' in data && + 'description' in data && + !('spec' in data) // V2 cards have spec field + ); +} + +/** + * Parse a character card JSON string into normalized format. + * Supports both V1 and V2 SillyTavern formats. + */ +export function parseCharacterCard(jsonString: string): ParsedCard | null { + try { + const data = JSON.parse(jsonString); + + if (isV2OrV3Card(data)) { + const version = data.spec === 'chara_card_v3' ? 'v3' : 'v2'; + log(`Detected ${version.toUpperCase()} card format`); + return { + name: data.data.name || data.name || 'Unknown Character', + description: data.data.description || data.description || '', + personality: data.data.personality || data.personality || '', + scenario: data.data.scenario || data.scenario || '', + firstMessage: data.data.first_mes || data.first_mes || '', + alternateGreetings: data.data.alternate_greetings || [], + exampleMessages: data.data.mes_example || data.mes_example || '', + version, + }; + } + + if (isV1Card(data)) { + log('Detected V1 card format'); + return { + name: data.name || 'Unknown Character', + description: data.description || '', + personality: data.personality || '', + scenario: data.scenario || '', + firstMessage: data.first_mes || '', + alternateGreetings: [], + exampleMessages: data.mes_example || '', + version: 'v1', + }; + } + + log('Unknown card format'); + return null; + } catch (error) { + log('Failed to parse card JSON:', error); + return null; + } +} + +// ===== Macro Replacement ===== + +/** + * Normalize {{user}} macro to consistent case. + * We keep {{user}} in the text - it will be replaced with protagonist name at story creation time. + */ +export function normalizeUserMacro(text: string): string { + if (!text) return ''; + + // Normalize to consistent {{user}} case + return text.replace(/\{\{user\}\}/gi, '{{user}}'); +} + +// ===== LLM Conversion ===== + +/** + * Build the combined card content for LLM processing. + * Combines scenario, description, personality, and example messages. + * Note: first_mes and alternate_greetings are excluded - they go to step 7. + * Note: {{user}} is normalized but kept - will be replaced with protagonist name at story creation. + * Note: {{char}} is left for LLM to interpret. + */ +function buildCardContext(card: ParsedCard): string { + const sections: string[] = []; + + // Always include scenario if present + if (card.scenario.trim()) { + sections.push(`\n${normalizeUserMacro(card.scenario)}\n`); + } + + // Always include character description + if (card.description.trim()) { + sections.push(`\n${normalizeUserMacro(card.description)}\n`); + } + + // Always include personality + if (card.personality.trim()) { + sections.push(`\n${normalizeUserMacro(card.personality)}\n`); + } + + // Include example messages for additional lore/context + if (card.exampleMessages.trim()) { + sections.push(`\n${normalizeUserMacro(card.exampleMessages)}\n`); + } + + return sections.join('\n\n'); +} + +/** + * System prompt for card-to-scenario conversion + */ +function getCardConversionSystemPrompt(): string { + return `You are cleaning a SillyTavern character card for use as a scenario setting in interactive fiction. + +## Your Task +1. IDENTIFY who "{{char}}" refers to based on the content (the actual character name - NOT the card title) +2. IDENTIFY ALL NPCs/characters mentioned in the card content +3. REPLACE all instances of "{{char}}" with the actual character name in your output +4. KEEP all instances of "{{user}}" as-is - this placeholder will be replaced with the player's character name later +5. REMOVE specific meta-content patterns (see below) +6. PRESERVE the original text as much as possible - do NOT summarize or condense + +The "{{user}}" refers to the player's character (protagonist). Characters identified from the card will become NPCs. + +## REMOVE THESE PATTERNS (delete entirely): + +### Roleplay Instructions (DELETE): +- "You are {{char}}", "You will portray...", "Play as..." +- "Do not speak for {{user}}", "Never speak for {{user}}" +- "Stay in character", "Never break character" +- "Always respond as...", "You must..." +- Any instruction telling the AI HOW to behave + +### Meta-Content (DELETE): +- HTML comments: +- OOC markers: "(OOC:", "[Author's note:", "[A/N:", etc. +- System prompts, jailbreaks, NSFW toggles +- Format instructions: "Use asterisks for actions", "Write in third person", "Use markdown" +- Section headers like "=== Narration ===" or "=== Character Embodiment ===" +- Guidelines about writing style, vocabulary, pacing + +### Example Dialogue Format (EXTRACT LORE ONLY): +- Remove the dialogue format itself +- Keep any world-building or lore mentioned within dialogues + +## CONVERT TO NATURAL PROSE (don't delete): + +### PList Syntax → Natural Prose: +- [Character: trait1, trait2; clothes: x] → "CharacterName is trait1 and trait2. She wears x." +- Keep ALL the information, just convert the bracket format to sentences + +## PRESERVE VERBATIM: +- World descriptions, locations, atmosphere +- Character appearance (physical details, clothing, etc.) +- Character personality and behavior patterns +- Backstory and history +- Relationship dynamics +- Scenario/situation setup +- Any lore, world rules, or setting details +- All {{user}} placeholders (keep them exactly as {{user}}) + +## OUTPUT FORMAT +Respond with valid JSON only (no markdown code blocks): +{ + "primaryCharacterName": "The ACTUAL name of the main character that {{char}} refers to", + "settingSeed": "The FULL cleaned text with {{char}} replaced by the actual name, but {{user}} kept as-is. This should be LONG - include ALL world-building, character details, and scenario setup. Only meta-instructions should be removed.", + "npcs": [ + { + "name": "Character's actual name", + "role": "their role (e.g., 'ally', 'mentor', 'antagonist', 'love interest', 'guide', 'friend')", + "description": "1-2 sentences: who they are and key appearance details", + "personality": "key personality traits as comma-separated list", + "relationship": "their relationship to {{user}}" + } + ] +} + +Note: Include ALL significant characters mentioned in the card as NPCs. The primary character ({{char}}) should be the first NPC in the array.`; +} + +/** + * Convert a parsed character card into a scenario setting using LLM. + */ +export async function convertCardToScenario( + jsonString: string, + mode: StoryMode, + genre: Genre, + profileId?: string | null +): Promise { + // Parse the card + const card = parseCharacterCard(jsonString); + if (!card) { + return { + success: false, + settingSeed: '', + npcs: [], + primaryCharacterName: '', + storyTitle: '', + firstMessage: '', + alternateGreetings: [], + errors: ['Failed to parse character card. Please ensure the file is a valid SillyTavern character card JSON.'], + }; + } + + log('Parsed card:', { name: card.name, version: card.version }); + + const cardTitle = card.name; + + // Pre-process first message and alternate greetings - normalize {{user}} case + // {{char}} will be replaced after LLM determines the actual character name + const preprocessedFirstMessage = normalizeUserMacro(card.firstMessage); + const preprocessedAlternateGreetings = card.alternateGreetings.map(g => normalizeUserMacro(g)); + + // Get API provider + const resolvedProfileId = profileId ?? settings.apiSettings.mainNarrativeProfileId; + const apiSettings = settings.getApiSettingsForProfile(resolvedProfileId); + + if (!apiSettings.openaiApiKey) { + return { + success: false, + settingSeed: '', + npcs: [], + primaryCharacterName: cardTitle, + storyTitle: cardTitle, + firstMessage: preprocessedFirstMessage, + alternateGreetings: preprocessedAlternateGreetings, + errors: ['No API key configured. Please set up an API key to convert character cards.'], + }; + } + + const provider = new OpenAIProvider(apiSettings); + + // Build the card context - normalize {{user}}, keep {{char}} for LLM to interpret + const cardContext = buildCardContext(card); + + if (!cardContext.trim()) { + return { + success: false, + settingSeed: '', + npcs: [], + primaryCharacterName: cardTitle, + storyTitle: cardTitle, + firstMessage: preprocessedFirstMessage, + alternateGreetings: preprocessedAlternateGreetings, + errors: ['Character card appears to be empty. No content found to convert.'], + }; + } + + const systemPrompt = getCardConversionSystemPrompt(); + const userPrompt = `Clean this character card for use as a ${genre} scenario setting. + +The {{user}} will be the protagonist (their name will be filled in later) interacting with the NPCs in an interactive story. + +IMPORTANT: +- Identify who "{{char}}" refers to based on the content (NOT the card title "${cardTitle}") +- Replace all {{char}} with the actual character name +- KEEP all {{user}} placeholders as-is (they will be replaced with the player's character name later) +- Preserve the original text - only REMOVE meta-instructions and roleplay guidelines +- Do NOT summarize or condense - the output should be nearly as long as the input + +## CARD CONTENT +${cardContext} + +Clean the above content. Identify all NPCs, replace {{char}} with the actual name, keep {{user}} as-is, and remove meta-content. Output valid JSON only.`; + + log('Sending to LLM for conversion...'); + + try { + const response = await provider.generateResponse({ + model: 'deepseek/deepseek-v3.2', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0.3, + maxTokens: 16384, + extraBody: buildExtraBody({ + manualMode: settings.advancedRequestSettings.manualMode, + reasoningEffort: 'off', + providerOnly: [], + }), + }); + + log('LLM response received, parsing...'); + + // Parse the JSON response + let jsonStr = response.content.trim(); + + // Strip markdown code blocks if present + if (jsonStr.startsWith('```')) { + jsonStr = jsonStr.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim(); + } + + // Try to extract JSON object + const jsonMatch = jsonStr.match(/\{[\s\S]*\}/); + if (jsonMatch) { + jsonStr = jsonMatch[0]; + } + + interface LLMNpc { + name: string; + role: string; + description: string; + personality: string; + relationship: string; + } + + interface ConversionResult { + primaryCharacterName: string; + settingSeed: string; + npcs: LLMNpc[]; + } + + const result = JSON.parse(jsonStr) as ConversionResult; + + // Get the primary character name for replacing {{char}} in first_mes + const primaryName = result.primaryCharacterName || cardTitle; + + // Convert LLM NPCs to GeneratedCharacter format + const npcs: GeneratedCharacter[] = (result.npcs || []).map(npc => ({ + name: npc.name || 'Unknown', + role: npc.role || 'NPC', + description: npc.description || '', + relationship: npc.relationship || 'acquaintance', + traits: npc.personality + ? npc.personality.split(/[,;]/).map(t => t.trim()).filter(t => t.length > 0).slice(0, 5) + : [], + })); + + // Replace {{char}} in first message and alternate greetings with the actual character name + const finalFirstMessage = preprocessedFirstMessage.replace(/\{\{char\}\}/gi, primaryName); + const finalAlternateGreetings = preprocessedAlternateGreetings.map(g => g.replace(/\{\{char\}\}/gi, primaryName)); + + log('Conversion successful:', { + settingSeedLength: result.settingSeed?.length, + primaryName, + npcCount: npcs.length + }); + + return { + success: true, + settingSeed: result.settingSeed || '', + npcs, + primaryCharacterName: primaryName, + storyTitle: cardTitle, + firstMessage: finalFirstMessage, + alternateGreetings: finalAlternateGreetings, + errors: [], + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error during conversion'; + log('LLM conversion failed:', error); + + // Return a fallback with basic extraction (keep {{char}} as-is since we couldn't determine the name) + const fallbackDescription = normalizeUserMacro( + [card.scenario, card.description].filter(s => s.trim()).join('\n\n') + ); + + return { + success: false, + settingSeed: fallbackDescription.slice(0, 2000), + npcs: [], + primaryCharacterName: cardTitle, + storyTitle: cardTitle, + firstMessage: preprocessedFirstMessage, + alternateGreetings: preprocessedAlternateGreetings, + errors: [`LLM conversion failed: ${errorMsg}. Basic extraction was used as fallback.`], + }; + } +}