Initial commit
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.svelte-kit/
|
||||
|
||||
# Rust/Tauri
|
||||
src-tauri/target/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Local development
|
||||
.vscode/settings.json
|
||||
7
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
7
README.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tauri + SvelteKit + TypeScript
|
||||
|
||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||
2856
package-lock.json
generated
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "aventura",
|
||||
"version": "0.1.0",
|
||||
"description": "AI-powered adventure and creative writing frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-sql": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"lucide-svelte": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.4.49",
|
||||
"autoprefixer": "^10.4.20"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7
src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6099
src-tauri/Cargo.lock
generated
Normal file
23
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "aventura"
|
||||
version = "0.1.0"
|
||||
description = "AI-powered adventure and creative writing frontend"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "aventura_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
25
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"sql:default",
|
||||
"sql:allow-load",
|
||||
"sql:allow-execute",
|
||||
"sql:allow-select",
|
||||
"sql:allow-close",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-mkdir",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
104
src-tauri/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
-- Aventura Initial Schema
|
||||
|
||||
-- Stories table
|
||||
CREATE TABLE IF NOT EXISTS stories (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
genre TEXT,
|
||||
template_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
settings TEXT
|
||||
);
|
||||
|
||||
-- Story entries (the actual narrative content)
|
||||
CREATE TABLE IF NOT EXISTS story_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
story_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
position INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Characters
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id TEXT PRIMARY KEY,
|
||||
story_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
relationship TEXT,
|
||||
traits TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Locations
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id TEXT PRIMARY KEY,
|
||||
story_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
visited INTEGER DEFAULT 0,
|
||||
current INTEGER DEFAULT 0,
|
||||
connections TEXT,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Inventory items
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
story_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
equipped INTEGER DEFAULT 0,
|
||||
location TEXT,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Story beats / plot points
|
||||
CREATE TABLE IF NOT EXISTS story_beats (
|
||||
id TEXT PRIMARY KEY,
|
||||
story_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
type TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
triggered_at INTEGER,
|
||||
metadata TEXT,
|
||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Templates
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
genre TEXT,
|
||||
system_prompt TEXT NOT NULL,
|
||||
initial_state TEXT,
|
||||
is_builtin INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Settings
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_story_entries_story ON story_entries(story_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_story_entries_position ON story_entries(story_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_characters_story ON characters(story_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_locations_story ON locations(story_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_story ON items(story_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_story_beats_story ON story_beats(story_id);
|
||||
25
src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let migrations = vec![
|
||||
Migration {
|
||||
version: 1,
|
||||
description: "create_initial_tables",
|
||||
sql: include_str!("../migrations/001_initial.sql"),
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations("sqlite:aventura.db", migrations)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
aventura_lib::run()
|
||||
}
|
||||
37
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Aventura",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.aureal.aventura",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Aventura",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
84
src/app.css
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
html {
|
||||
@apply antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface-900 text-surface-100;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-surface-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-surface-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-surface-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom component styles */
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent-600 text-white hover:bg-accent-700 active:bg-accent-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-surface-700 text-surface-100 hover:bg-surface-600 active:bg-surface-500;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent text-surface-300 hover:bg-surface-800 hover:text-surface-100;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-surface-800 border border-surface-700 rounded-lg
|
||||
text-surface-100 placeholder-surface-500
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent
|
||||
transition-colors duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-surface-800 border border-surface-700 rounded-xl p-4;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply bg-surface-800 border-r border-surface-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Story text styling */
|
||||
@layer utilities {
|
||||
.story-text {
|
||||
@apply font-story text-lg leading-relaxed text-surface-200;
|
||||
}
|
||||
|
||||
.user-action {
|
||||
@apply text-accent-400 font-medium;
|
||||
}
|
||||
|
||||
.narration {
|
||||
@apply text-surface-200;
|
||||
}
|
||||
|
||||
.system-text {
|
||||
@apply text-surface-400 italic text-sm;
|
||||
}
|
||||
}
|
||||
16
src/app.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Aventura</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="overflow-hidden">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
39
src/lib/components/layout/AppShell.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import Header from './Header.svelte';
|
||||
import StoryView from '$lib/components/story/StoryView.svelte';
|
||||
import LibraryView from '$lib/components/story/LibraryView.svelte';
|
||||
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen w-screen bg-surface-900">
|
||||
<!-- Sidebar -->
|
||||
{#if ui.sidebarOpen && story.currentStory}
|
||||
<Sidebar />
|
||||
{/if}
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<main class="flex-1 overflow-hidden">
|
||||
{#if ui.activePanel === 'story' && story.currentStory}
|
||||
<StoryView />
|
||||
{:else if ui.activePanel === 'library' || !story.currentStory}
|
||||
<LibraryView />
|
||||
{:else if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
{#if ui.settingsModalOpen}
|
||||
<SettingsModal />
|
||||
{/if}
|
||||
</div>
|
||||
163
src/lib/components/layout/Header.svelte
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { exportService } from '$lib/services/export';
|
||||
import { PanelLeft, Settings, BookOpen, Library, Feather, Download, FileJson, FileText, ChevronDown } from 'lucide-svelte';
|
||||
|
||||
let showExportMenu = $state(false);
|
||||
|
||||
async function exportAventura() {
|
||||
if (!story.currentStory) return;
|
||||
showExportMenu = false;
|
||||
await exportService.exportToAventura(
|
||||
story.currentStory,
|
||||
story.entries,
|
||||
story.characters,
|
||||
story.locations,
|
||||
story.items,
|
||||
story.storyBeats
|
||||
);
|
||||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
if (!story.currentStory) return;
|
||||
showExportMenu = false;
|
||||
await exportService.exportToMarkdown(
|
||||
story.currentStory,
|
||||
story.entries,
|
||||
story.characters,
|
||||
story.locations,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
async function exportText() {
|
||||
if (!story.currentStory) return;
|
||||
showExportMenu = false;
|
||||
await exportService.exportToText(story.currentStory, story.entries);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="flex h-14 items-center justify-between border-b border-surface-700 bg-surface-800 px-4">
|
||||
<!-- Left side: Menu toggle and story title -->
|
||||
<div class="flex items-center gap-3">
|
||||
{#if story.currentStory}
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2"
|
||||
onclick={() => ui.toggleSidebar()}
|
||||
title={ui.sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
>
|
||||
<PanelLeft class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Feather class="h-5 w-5 text-accent-500" />
|
||||
<span class="font-semibold text-surface-100">Aventura</span>
|
||||
</div>
|
||||
|
||||
{#if story.currentStory}
|
||||
<span class="text-surface-500">|</span>
|
||||
<span class="text-surface-300">{story.currentStory.title}</span>
|
||||
{#if settings.uiSettings.showWordCount}
|
||||
<span class="text-sm text-surface-500">({story.wordCount} words)</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation tabs -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="btn-ghost flex items-center gap-2 rounded-lg px-3 py-1.5"
|
||||
class:bg-surface-700={ui.activePanel === 'library' || !story.currentStory}
|
||||
onclick={() => ui.setActivePanel('library')}
|
||||
>
|
||||
<Library class="h-4 w-4" />
|
||||
<span class="text-sm">Library</span>
|
||||
</button>
|
||||
|
||||
{#if story.currentStory}
|
||||
<button
|
||||
class="btn-ghost flex items-center gap-2 rounded-lg px-3 py-1.5"
|
||||
class:bg-surface-700={ui.activePanel === 'story'}
|
||||
onclick={() => ui.setActivePanel('story')}
|
||||
>
|
||||
<BookOpen class="h-4 w-4" />
|
||||
<span class="text-sm">Story</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right side: Export and Settings -->
|
||||
<div class="flex items-center gap-2">
|
||||
{#if ui.isGenerating}
|
||||
<div class="flex items-center gap-2 text-sm text-accent-400">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-accent-500"></div>
|
||||
<span>Generating...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if story.currentStory}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="btn-ghost flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm"
|
||||
onclick={() => showExportMenu = !showExportMenu}
|
||||
title="Export story"
|
||||
>
|
||||
<Download class="h-4 w-4" />
|
||||
<span>Export</span>
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{#if showExportMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-surface-600 bg-surface-800 py-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
|
||||
onclick={exportAventura}
|
||||
>
|
||||
<FileJson class="h-4 w-4 text-accent-400" />
|
||||
Aventura (.avt)
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
|
||||
onclick={exportMarkdown}
|
||||
>
|
||||
<FileText class="h-4 w-4 text-blue-400" />
|
||||
Markdown (.md)
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
|
||||
onclick={exportText}
|
||||
>
|
||||
<FileText class="h-4 w-4 text-surface-400" />
|
||||
Plain Text (.txt)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2"
|
||||
onclick={() => ui.openSettings()}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Click outside to close export menu -->
|
||||
{#if showExportMenu}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showExportMenu = false}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showExportMenu = false)}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Close export menu"
|
||||
></div>
|
||||
{/if}
|
||||
55
src/lib/components/layout/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Users, MapPin, Backpack, Scroll } from 'lucide-svelte';
|
||||
import CharacterPanel from '$lib/components/world/CharacterPanel.svelte';
|
||||
import LocationPanel from '$lib/components/world/LocationPanel.svelte';
|
||||
import InventoryPanel from '$lib/components/world/InventoryPanel.svelte';
|
||||
import QuestPanel from '$lib/components/world/QuestPanel.svelte';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'characters' as const, icon: Users, label: 'Characters' },
|
||||
{ id: 'locations' as const, icon: MapPin, label: 'Locations' },
|
||||
{ id: 'inventory' as const, icon: Backpack, label: 'Inventory' },
|
||||
{ id: 'quests' as const, icon: Scroll, label: 'Quests' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside class="sidebar flex h-full w-72 flex-col border-r border-surface-700">
|
||||
<!-- Tab navigation -->
|
||||
<div class="flex border-b border-surface-700">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class="flex flex-1 items-center justify-center gap-1.5 py-3 text-sm transition-colors"
|
||||
class:text-accent-400={ui.sidebarTab === tab.id}
|
||||
class:text-surface-400={ui.sidebarTab !== tab.id}
|
||||
class:border-b-2={ui.sidebarTab === tab.id}
|
||||
class:border-accent-500={ui.sidebarTab === tab.id}
|
||||
class:hover:text-surface-200={ui.sidebarTab !== tab.id}
|
||||
onclick={() => ui.setSidebarTab(tab.id)}
|
||||
title={tab.label}
|
||||
>
|
||||
<svelte:component this={tab.icon} class="h-4 w-4" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Panel content -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if ui.sidebarTab === 'characters'}
|
||||
<CharacterPanel />
|
||||
{:else if ui.sidebarTab === 'locations'}
|
||||
<LocationPanel />
|
||||
{:else if ui.sidebarTab === 'inventory'}
|
||||
<InventoryPanel />
|
||||
{:else if ui.sidebarTab === 'quests'}
|
||||
<QuestPanel />
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
background-color: rgb(20 27 37);
|
||||
}
|
||||
</style>
|
||||
227
src/lib/components/settings/SettingsModal.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { X, Key, Cpu, Palette } from 'lucide-svelte';
|
||||
|
||||
let activeTab = $state<'api' | 'generation' | 'ui'>('api');
|
||||
|
||||
// Local state for API key (to avoid showing actual key)
|
||||
let apiKeyInput = $state('');
|
||||
let apiKeySet = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
apiKeySet = !!settings.apiSettings.openrouterApiKey;
|
||||
});
|
||||
|
||||
async function saveApiKey() {
|
||||
if (apiKeyInput.trim()) {
|
||||
await settings.setApiKey(apiKeyInput.trim());
|
||||
apiKeyInput = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearApiKey() {
|
||||
await settings.setApiKey('');
|
||||
apiKeySet = false;
|
||||
}
|
||||
|
||||
const popularModels = [
|
||||
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
|
||||
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus' },
|
||||
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
|
||||
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo' },
|
||||
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
|
||||
{ id: 'meta-llama/llama-3.1-405b-instruct', name: 'Llama 3.1 405B' },
|
||||
{ id: 'mistralai/mistral-large', name: 'Mistral Large' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick={() => ui.closeSettings()}>
|
||||
<div
|
||||
class="card w-full max-w-2xl max-h-[80vh] overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
|
||||
<h2 class="text-xl font-semibold text-surface-100">Settings</h2>
|
||||
<button class="btn-ghost rounded-lg p-2" onclick={() => ui.closeSettings()}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 border-b border-surface-700 py-2">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
class:bg-surface-700={activeTab === 'api'}
|
||||
class:text-surface-100={activeTab === 'api'}
|
||||
class:text-surface-400={activeTab !== 'api'}
|
||||
onclick={() => activeTab = 'api'}
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
API
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
class:bg-surface-700={activeTab === 'generation'}
|
||||
class:text-surface-100={activeTab === 'generation'}
|
||||
class:text-surface-400={activeTab !== 'generation'}
|
||||
onclick={() => activeTab = 'generation'}
|
||||
>
|
||||
<Cpu class="h-4 w-4" />
|
||||
Generation
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
class:bg-surface-700={activeTab === 'ui'}
|
||||
class:text-surface-100={activeTab === 'ui'}
|
||||
class:text-surface-400={activeTab !== 'ui'}
|
||||
onclick={() => activeTab = 'ui'}
|
||||
>
|
||||
<Palette class="h-4 w-4" />
|
||||
Interface
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-96 overflow-y-auto py-4">
|
||||
{#if activeTab === 'api'}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
OpenRouter API Key
|
||||
</label>
|
||||
{#if apiKeySet}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="input flex-1 bg-surface-700 text-surface-400">
|
||||
••••••••••••••••••••
|
||||
</div>
|
||||
<button class="btn btn-secondary" onclick={clearApiKey}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-green-400">API key configured</p>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={apiKeyInput}
|
||||
placeholder="sk-or-..."
|
||||
class="input flex-1"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={saveApiKey}
|
||||
disabled={!apiKeyInput.trim()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-2 text-sm text-surface-500">
|
||||
Get your API key from <a href="https://openrouter.ai/keys" target="_blank" class="text-accent-400 hover:underline">openrouter.ai</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Default Model
|
||||
</label>
|
||||
<select
|
||||
class="input"
|
||||
value={settings.apiSettings.defaultModel}
|
||||
onchange={(e) => settings.setDefaultModel(e.currentTarget.value)}
|
||||
>
|
||||
{#each popularModels as model}
|
||||
<option value={model.id}>{model.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'generation'}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Temperature: {settings.apiSettings.temperature.toFixed(1)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={settings.apiSettings.temperature}
|
||||
oninput={(e) => settings.setTemperature(parseFloat(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-surface-500">
|
||||
<span>Focused</span>
|
||||
<span>Creative</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Max Tokens: {settings.apiSettings.maxTokens}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="256"
|
||||
max="4096"
|
||||
step="128"
|
||||
value={settings.apiSettings.maxTokens}
|
||||
oninput={(e) => settings.setMaxTokens(parseInt(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-surface-500">
|
||||
<span>Shorter</span>
|
||||
<span>Longer</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'ui'}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Theme
|
||||
</label>
|
||||
<select
|
||||
class="input"
|
||||
value={settings.uiSettings.theme}
|
||||
onchange={(e) => settings.setTheme(e.currentTarget.value as 'dark' | 'light')}
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Font Size
|
||||
</label>
|
||||
<select
|
||||
class="input"
|
||||
value={settings.uiSettings.fontSize}
|
||||
onchange={(e) => settings.setFontSize(e.currentTarget.value as 'small' | 'medium' | 'large')}
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-surface-300">Show Word Count</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.uiSettings.showWordCount}
|
||||
onchange={() => {
|
||||
settings.uiSettings.showWordCount = !settings.uiSettings.showWordCount;
|
||||
}}
|
||||
class="h-5 w-5 rounded border-surface-600 bg-surface-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
150
src/lib/components/story/ActionInput.svelte
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { aiService } from '$lib/services/ai';
|
||||
import { Send, Wand2, MessageSquare, Brain, Sparkles } from 'lucide-svelte';
|
||||
|
||||
let inputValue = $state('');
|
||||
let actionType = $state<'do' | 'say' | 'think' | 'story'>('do');
|
||||
|
||||
const actionPrefixes = {
|
||||
do: 'You ',
|
||||
say: 'You say "',
|
||||
think: 'You think to yourself, "',
|
||||
story: '',
|
||||
};
|
||||
|
||||
const actionSuffixes = {
|
||||
do: '',
|
||||
say: '"',
|
||||
think: '"',
|
||||
story: '',
|
||||
};
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!inputValue.trim() || ui.isGenerating) return;
|
||||
|
||||
const content = actionPrefixes[actionType] + inputValue.trim() + actionSuffixes[actionType];
|
||||
|
||||
// Add user action to story
|
||||
await story.addEntry('user_action', content);
|
||||
|
||||
// Clear input
|
||||
inputValue = '';
|
||||
|
||||
// Generate AI response with streaming
|
||||
if (settings.hasApiKey) {
|
||||
ui.setGenerating(true);
|
||||
ui.startStreaming();
|
||||
|
||||
try {
|
||||
// Build world state for AI context
|
||||
const worldState = {
|
||||
characters: story.characters,
|
||||
locations: story.locations,
|
||||
items: story.items,
|
||||
storyBeats: story.storyBeats,
|
||||
currentLocation: story.currentLocation,
|
||||
};
|
||||
|
||||
let fullResponse = '';
|
||||
|
||||
// Use streaming response
|
||||
for await (const chunk of aiService.streamResponse(story.entries, worldState, story.currentStory)) {
|
||||
if (chunk.content) {
|
||||
fullResponse += chunk.content;
|
||||
ui.appendStreamContent(chunk.content);
|
||||
}
|
||||
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete response as a story entry
|
||||
if (fullResponse.trim()) {
|
||||
await story.addEntry('narration', fullResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Generation failed:', error);
|
||||
await story.addEntry('system', 'Failed to generate response. Please check your API settings.');
|
||||
} finally {
|
||||
ui.endStreaming();
|
||||
ui.setGenerating(false);
|
||||
}
|
||||
} else {
|
||||
await story.addEntry('system', 'Please configure your OpenRouter API key in settings to enable AI generation.');
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Action type buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn flex items-center gap-1.5 text-sm"
|
||||
class:btn-primary={actionType === 'do'}
|
||||
class:btn-secondary={actionType !== 'do'}
|
||||
onclick={() => actionType = 'do'}
|
||||
>
|
||||
<Wand2 class="h-4 w-4" />
|
||||
Do
|
||||
</button>
|
||||
<button
|
||||
class="btn flex items-center gap-1.5 text-sm"
|
||||
class:btn-primary={actionType === 'say'}
|
||||
class:btn-secondary={actionType !== 'say'}
|
||||
onclick={() => actionType = 'say'}
|
||||
>
|
||||
<MessageSquare class="h-4 w-4" />
|
||||
Say
|
||||
</button>
|
||||
<button
|
||||
class="btn flex items-center gap-1.5 text-sm"
|
||||
class:btn-primary={actionType === 'think'}
|
||||
class:btn-secondary={actionType !== 'think'}
|
||||
onclick={() => actionType = 'think'}
|
||||
>
|
||||
<Brain class="h-4 w-4" />
|
||||
Think
|
||||
</button>
|
||||
<button
|
||||
class="btn flex items-center gap-1.5 text-sm"
|
||||
class:btn-primary={actionType === 'story'}
|
||||
class:btn-secondary={actionType !== 'story'}
|
||||
onclick={() => actionType = 'story'}
|
||||
>
|
||||
<Sparkles class="h-4 w-4" />
|
||||
Story
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<textarea
|
||||
bind:value={inputValue}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={actionType === 'story' ? 'Describe what happens...' : 'What do you do?'}
|
||||
class="input min-h-[60px] resize-none pr-12"
|
||||
rows="2"
|
||||
disabled={ui.isGenerating}
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={!inputValue.trim() || ui.isGenerating}
|
||||
class="btn btn-primary self-end px-4 py-3"
|
||||
>
|
||||
<Send class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
317
src/lib/components/story/LibraryView.svelte
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { templateService, BUILTIN_TEMPLATES } from '$lib/services/templates';
|
||||
import { exportService } from '$lib/services/export';
|
||||
import { Plus, BookOpen, Trash2, Clock, Sparkles, Wand2, Rocket, Search, Skull, Heart, FileText, Upload } from 'lucide-svelte';
|
||||
import type { Template } from '$lib/types';
|
||||
|
||||
let showNewStoryModal = $state(false);
|
||||
let newStoryTitle = $state('');
|
||||
let selectedTemplateId = $state<string | null>(null);
|
||||
let step = $state<'template' | 'details'>('template');
|
||||
|
||||
// Derived template based on selection
|
||||
let selectedTemplate = $derived(
|
||||
selectedTemplateId ? BUILTIN_TEMPLATES.find(t => t.id === selectedTemplateId) : null
|
||||
);
|
||||
|
||||
const templateIcons: Record<string, typeof Wand2> = {
|
||||
'fantasy-adventure': Wand2,
|
||||
'scifi-exploration': Rocket,
|
||||
'mystery-investigation': Search,
|
||||
'horror-survival': Skull,
|
||||
'slice-of-life': Heart,
|
||||
'custom': FileText,
|
||||
};
|
||||
|
||||
// Load stories on mount
|
||||
$effect(() => {
|
||||
story.loadAllStories();
|
||||
});
|
||||
|
||||
function selectTemplate(templateId: string) {
|
||||
selectedTemplateId = templateId;
|
||||
step = 'details';
|
||||
|
||||
// Set default title based on template
|
||||
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
|
||||
if (template && template.id !== 'custom') {
|
||||
newStoryTitle = `My ${template.genre} Adventure`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewStory() {
|
||||
if (!newStoryTitle.trim() || !selectedTemplateId) return;
|
||||
|
||||
const template = BUILTIN_TEMPLATES.find(t => t.id === selectedTemplateId);
|
||||
const newStoryData = await story.createStoryFromTemplate(
|
||||
newStoryTitle.trim(),
|
||||
selectedTemplateId,
|
||||
template?.genre ?? undefined
|
||||
);
|
||||
|
||||
await story.loadStory(newStoryData.id);
|
||||
ui.setActivePanel('story');
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showNewStoryModal = false;
|
||||
newStoryTitle = '';
|
||||
selectedTemplateId = null;
|
||||
step = 'template';
|
||||
}
|
||||
|
||||
function goBackToTemplates() {
|
||||
step = 'template';
|
||||
newStoryTitle = '';
|
||||
}
|
||||
|
||||
async function openStory(storyId: string) {
|
||||
await story.loadStory(storyId);
|
||||
ui.setActivePanel('story');
|
||||
}
|
||||
|
||||
async function deleteStory(storyId: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this story?')) {
|
||||
await story.deleteStory(storyId);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getGenreColor(genre: string | null): string {
|
||||
switch (genre) {
|
||||
case 'Fantasy': return 'bg-purple-500/20 text-purple-400';
|
||||
case 'Sci-Fi': return 'bg-cyan-500/20 text-cyan-400';
|
||||
case 'Mystery': return 'bg-amber-500/20 text-amber-400';
|
||||
case 'Horror': return 'bg-red-500/20 text-red-400';
|
||||
case 'Slice of Life': return 'bg-green-500/20 text-green-400';
|
||||
default: return 'bg-surface-700 text-surface-400';
|
||||
}
|
||||
}
|
||||
|
||||
let importError = $state<string | null>(null);
|
||||
|
||||
async function importStory() {
|
||||
importError = null;
|
||||
const result = await exportService.importFromAventura();
|
||||
|
||||
if (result.success && result.storyId) {
|
||||
await story.loadAllStories();
|
||||
await story.loadStory(result.storyId);
|
||||
ui.setActivePanel('story');
|
||||
} else if (result.error) {
|
||||
importError = result.error;
|
||||
setTimeout(() => importError = null, 5000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full overflow-y-auto p-6">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-100">Story Library</h1>
|
||||
<p class="text-surface-400">Your adventures await</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-secondary flex items-center gap-2"
|
||||
onclick={importStory}
|
||||
>
|
||||
<Upload class="h-5 w-5" />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex items-center gap-2"
|
||||
onclick={() => showNewStoryModal = true}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
New Story
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import error message -->
|
||||
{#if importError}
|
||||
<div class="mb-4 rounded-lg bg-red-500/20 p-3 text-sm text-red-400">
|
||||
{importError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stories grid -->
|
||||
{#if story.allStories.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<BookOpen class="mb-4 h-16 w-16 text-surface-600" />
|
||||
<h2 class="text-xl font-semibold text-surface-300">No stories yet</h2>
|
||||
<p class="mt-2 text-surface-500">Create your first adventure to get started</p>
|
||||
<button
|
||||
class="btn btn-primary mt-6 flex items-center gap-2"
|
||||
onclick={() => showNewStoryModal = true}
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Create Story
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each story.allStories as s (s.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openStory(s.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && openStory(s.id)}
|
||||
class="card group cursor-pointer text-left transition-colors hover:border-accent-500/50 hover:bg-surface-700/50"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-surface-100 group-hover:text-accent-400">
|
||||
{s.title}
|
||||
</h3>
|
||||
{#if s.genre}
|
||||
<span class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs {getGenreColor(s.genre)}">
|
||||
{s.genre}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={(e) => deleteStory(s.id, e)}
|
||||
class="rounded p-1 text-surface-500 opacity-0 transition-opacity hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
title="Delete story"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{#if s.description}
|
||||
<p class="mt-2 line-clamp-2 text-sm text-surface-400">
|
||||
{s.description}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-3 flex items-center gap-1 text-xs text-surface-500">
|
||||
<Clock class="h-3 w-3" />
|
||||
<span>Updated {formatDate(s.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Story Modal -->
|
||||
{#if showNewStoryModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="card w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
{#if step === 'template'}
|
||||
<!-- Template Selection Step -->
|
||||
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-surface-100">Choose Your Adventure</h2>
|
||||
<p class="text-sm text-surface-400">Select a genre template to get started</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
|
||||
onclick={closeModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 py-4 sm:grid-cols-2 max-h-96 overflow-y-auto">
|
||||
{#each BUILTIN_TEMPLATES as template}
|
||||
{@const Icon = templateIcons[template.id] ?? Sparkles}
|
||||
<button
|
||||
onclick={() => selectTemplate(template.id)}
|
||||
class="card flex items-start gap-3 p-4 text-left transition-all hover:border-accent-500/50 hover:bg-surface-700/50"
|
||||
class:border-accent-500={selectedTemplateId === template.id}
|
||||
>
|
||||
<div class="rounded-lg bg-surface-700 p-2">
|
||||
<Icon class="h-5 w-5 text-accent-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-surface-100">{template.name}</h3>
|
||||
<p class="mt-1 text-sm text-surface-400 line-clamp-2">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Story Details Step -->
|
||||
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
|
||||
onclick={goBackToTemplates}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-surface-100">Name Your Story</h2>
|
||||
{#if selectedTemplate}
|
||||
<p class="text-sm text-surface-400">Template: {selectedTemplate.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
|
||||
onclick={closeModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="py-4">
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Story Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newStoryTitle}
|
||||
placeholder="Enter a title for your adventure..."
|
||||
class="input"
|
||||
onkeydown={(e) => e.key === 'Enter' && newStoryTitle.trim() && createNewStory()}
|
||||
/>
|
||||
|
||||
{#if selectedTemplate?.initialState?.openingScene}
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium text-surface-300">
|
||||
Opening Scene Preview
|
||||
</label>
|
||||
<div class="rounded-lg bg-surface-900 p-4 text-sm text-surface-400 max-h-32 overflow-y-auto">
|
||||
{selectedTemplate.initialState.openingScene}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 border-t border-surface-700 pt-4">
|
||||
<button class="btn btn-secondary" onclick={goBackToTemplates}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary flex items-center gap-2"
|
||||
onclick={createNewStory}
|
||||
disabled={!newStoryTitle.trim()}
|
||||
>
|
||||
<Sparkles class="h-4 w-4" />
|
||||
Begin Adventure
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
147
src/lib/components/story/StoryEntry.svelte
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<script lang="ts">
|
||||
import type { StoryEntry } from '$lib/types';
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { User, BookOpen, Info, Pencil, Trash2, Check, X } from 'lucide-svelte';
|
||||
|
||||
let { entry }: { entry: StoryEntry } = $props();
|
||||
|
||||
let isEditing = $state(false);
|
||||
let editContent = $state('');
|
||||
let isDeleting = $state(false);
|
||||
|
||||
const icons = {
|
||||
user_action: User,
|
||||
narration: BookOpen,
|
||||
system: Info,
|
||||
retry: BookOpen,
|
||||
};
|
||||
|
||||
const styles = {
|
||||
user_action: 'border-l-accent-500 bg-accent-500/5',
|
||||
narration: 'border-l-surface-600 bg-surface-800/50',
|
||||
system: 'border-l-surface-500 bg-surface-800/30 italic text-surface-400',
|
||||
retry: 'border-l-amber-500 bg-amber-500/5',
|
||||
};
|
||||
|
||||
const Icon = $derived(icons[entry.type]);
|
||||
|
||||
function startEdit() {
|
||||
editContent = entry.content;
|
||||
isEditing = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (editContent.trim() && editContent !== entry.content) {
|
||||
await story.updateEntry(entry.id, editContent.trim());
|
||||
}
|
||||
isEditing = false;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing = false;
|
||||
editContent = '';
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
await story.deleteEntry(entry.id);
|
||||
isDeleting = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
cancelEdit();
|
||||
} else if (event.key === 'Enter' && event.ctrlKey) {
|
||||
saveEdit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="group rounded-lg border-l-4 p-4 {styles[entry.type]} relative">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 rounded-full bg-surface-700 p-1.5">
|
||||
<Icon class="h-4 w-4 text-surface-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
{#if entry.type === 'user_action'}
|
||||
<p class="user-action mb-1 text-sm font-medium">You</p>
|
||||
{/if}
|
||||
|
||||
{#if isEditing}
|
||||
<div class="space-y-2">
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
onkeydown={handleKeydown}
|
||||
class="input min-h-[100px] w-full resize-y text-sm"
|
||||
rows="4"
|
||||
></textarea>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={saveEdit}
|
||||
class="btn btn-primary flex items-center gap-1 text-xs"
|
||||
>
|
||||
<Check class="h-3 w-3" />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelEdit}
|
||||
class="btn btn-secondary flex items-center gap-1 text-xs"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-surface-500">Ctrl+Enter to save, Esc to cancel</p>
|
||||
</div>
|
||||
{:else if isDeleting}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-surface-300">Delete this entry?</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={confirmDelete}
|
||||
class="btn flex items-center gap-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={() => isDeleting = false}
|
||||
class="btn btn-secondary flex items-center gap-1 text-xs"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="story-text whitespace-pre-wrap">
|
||||
{entry.content}
|
||||
</div>
|
||||
{#if entry.metadata?.tokenCount}
|
||||
<p class="mt-2 text-xs text-surface-500">
|
||||
{entry.metadata.tokenCount} tokens
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edit/Delete buttons (shown on hover) -->
|
||||
{#if !isEditing && !isDeleting && entry.type !== 'system'}
|
||||
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onclick={startEdit}
|
||||
class="rounded p-1.5 text-surface-400 hover:bg-surface-600 hover:text-surface-200"
|
||||
title="Edit entry"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => isDeleting = true}
|
||||
class="rounded p-1.5 text-surface-400 hover:bg-red-500/20 hover:text-red-400"
|
||||
title="Delete entry"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
55
src/lib/components/story/StoryView.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import StoryEntry from './StoryEntry.svelte';
|
||||
import StreamingEntry from './StreamingEntry.svelte';
|
||||
import ActionInput from './ActionInput.svelte';
|
||||
|
||||
let storyContainer: HTMLDivElement;
|
||||
|
||||
// Auto-scroll to bottom when new entries are added or streaming content changes
|
||||
$effect(() => {
|
||||
// Track both entries and streaming state for scroll
|
||||
const _ = story.entries.length;
|
||||
const __ = ui.streamingContent;
|
||||
|
||||
if (storyContainer) {
|
||||
storyContainer.scrollTop = storyContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Story entries container -->
|
||||
<div
|
||||
bind:this={storyContainer}
|
||||
class="flex-1 overflow-y-auto px-6 py-4"
|
||||
>
|
||||
<div class="mx-auto max-w-3xl space-y-4">
|
||||
{#if story.entries.length === 0 && !ui.isStreaming}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p class="text-lg text-surface-400">Your adventure begins here...</p>
|
||||
<p class="mt-2 text-sm text-surface-500">
|
||||
Type an action below to start your story
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each story.entries as entry (entry.id)}
|
||||
<StoryEntry {entry} />
|
||||
{/each}
|
||||
|
||||
<!-- Show streaming entry while generating -->
|
||||
{#if ui.isStreaming}
|
||||
<StreamingEntry />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action input area -->
|
||||
<div class="border-t border-surface-700 bg-surface-800 p-4">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<ActionInput />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
40
src/lib/components/story/StreamingEntry.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { ui } from '$lib/stores/ui.svelte';
|
||||
import { BookOpen } from 'lucide-svelte';
|
||||
|
||||
// Reactive binding to streaming content
|
||||
let content = $derived(ui.streamingContent);
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border-l-4 border-l-accent-500 bg-accent-500/5 p-4 animate-fade-in">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 rounded-full bg-surface-700 p-1.5">
|
||||
<BookOpen class="h-4 w-4 text-accent-400 animate-pulse" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="story-text whitespace-pre-wrap">
|
||||
{content}<span class="inline-block w-2 h-4 ml-0.5 bg-accent-400 animate-blink"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
116
src/lib/components/world/CharacterPanel.svelte
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Plus, User, Heart, Skull, UserX } from 'lucide-svelte';
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newRelationship = $state('');
|
||||
|
||||
async function addCharacter() {
|
||||
if (!newName.trim()) return;
|
||||
await story.addCharacter(newName.trim(), newDescription.trim() || undefined, newRelationship.trim() || undefined);
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newRelationship = '';
|
||||
showAddForm = false;
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'active': return User;
|
||||
case 'inactive': return UserX;
|
||||
case 'deceased': return Skull;
|
||||
default: return User;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'active': return 'text-green-400';
|
||||
case 'inactive': return 'text-surface-500';
|
||||
case 'deceased': return 'text-red-400';
|
||||
default: return 'text-surface-400';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatusIcon(status: string) {
|
||||
return getStatusIcon(status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-200">Characters</h3>
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={() => showAddForm = !showAddForm}
|
||||
title="Add character"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="card space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Character name"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newRelationship}
|
||||
placeholder="Relationship (e.g., ally, enemy)"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={newDescription}
|
||||
placeholder="Description (optional)"
|
||||
class="input text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary text-xs" onclick={addCharacter} disabled={!newName.trim()}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if story.characters.length === 0}
|
||||
<p class="py-4 text-center text-sm text-surface-500">
|
||||
No characters yet
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each story.characters as character (character.id)}
|
||||
{@const StatusIcon = getStatusIcon(character.status)}
|
||||
<div class="card p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-surface-700 p-1.5 {getStatusColor(character.status)}">
|
||||
<StatusIcon class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-surface-100">{character.name}</span>
|
||||
{#if character.relationship}
|
||||
<span class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
|
||||
{character.relationship}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if character.description}
|
||||
<p class="mt-1 text-sm text-surface-400">{character.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
115
src/lib/components/world/InventoryPanel.svelte
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Plus, Package, Shield } from 'lucide-svelte';
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
let newQuantity = $state(1);
|
||||
|
||||
async function addItem() {
|
||||
if (!newName.trim()) return;
|
||||
await story.addItem(newName.trim(), newDescription.trim() || undefined, newQuantity);
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
newQuantity = 1;
|
||||
showAddForm = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-200">Inventory</h3>
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={() => showAddForm = !showAddForm}
|
||||
title="Add item"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="card space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Item name"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={newQuantity}
|
||||
min="1"
|
||||
class="input w-20 text-sm"
|
||||
/>
|
||||
<span class="self-center text-sm text-surface-400">quantity</span>
|
||||
</div>
|
||||
<textarea
|
||||
bind:value={newDescription}
|
||||
placeholder="Description (optional)"
|
||||
class="input text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary text-xs" onclick={addItem} disabled={!newName.trim()}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Equipped items -->
|
||||
{#if story.equippedItems.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-surface-400">Equipped</h4>
|
||||
{#each story.equippedItems as item (item.id)}
|
||||
<div class="card border-accent-500/30 bg-accent-500/5 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Shield class="h-4 w-4 text-accent-400" />
|
||||
<span class="font-medium text-surface-100">{item.name}</span>
|
||||
{#if item.quantity > 1}
|
||||
<span class="text-sm text-surface-400">x{item.quantity}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if story.inventoryItems.length === 0 && story.equippedItems.length === 0}
|
||||
<p class="py-4 text-center text-sm text-surface-500">
|
||||
No items yet
|
||||
</p>
|
||||
{:else if story.inventoryItems.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#if story.equippedItems.length > 0}
|
||||
<h4 class="text-sm font-medium text-surface-400">Inventory</h4>
|
||||
{/if}
|
||||
{#each story.inventoryItems as item (item.id)}
|
||||
<div class="card p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-surface-700 p-1.5">
|
||||
<Package class="h-4 w-4 text-surface-400" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-surface-100">{item.name}</span>
|
||||
{#if item.quantity > 1}
|
||||
<span class="text-sm text-surface-400">x{item.quantity}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if item.description}
|
||||
<p class="mt-1 text-sm text-surface-400">{item.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
110
src/lib/components/world/LocationPanel.svelte
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Plus, MapPin, Eye, Navigation } from 'lucide-svelte';
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newDescription = $state('');
|
||||
|
||||
async function addLocation() {
|
||||
if (!newName.trim()) return;
|
||||
const makeCurrent = story.locations.length === 0;
|
||||
await story.addLocation(newName.trim(), newDescription.trim() || undefined, makeCurrent);
|
||||
newName = '';
|
||||
newDescription = '';
|
||||
showAddForm = false;
|
||||
}
|
||||
|
||||
async function goToLocation(locationId: string) {
|
||||
await story.setCurrentLocation(locationId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-200">Locations</h3>
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={() => showAddForm = !showAddForm}
|
||||
title="Add location"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="card space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder="Location name"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={newDescription}
|
||||
placeholder="Description (optional)"
|
||||
class="input text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary text-xs" onclick={addLocation} disabled={!newName.trim()}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Current Location -->
|
||||
{#if story.currentLocation}
|
||||
<div class="card border-accent-500/50 bg-accent-500/10 p-3">
|
||||
<div class="flex items-center gap-2 text-accent-400">
|
||||
<Navigation class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Current Location</span>
|
||||
</div>
|
||||
<h4 class="mt-1 font-medium text-surface-100">{story.currentLocation.name}</h4>
|
||||
{#if story.currentLocation.description}
|
||||
<p class="mt-1 text-sm text-surface-400">{story.currentLocation.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if story.locations.length === 0}
|
||||
<p class="py-4 text-center text-sm text-surface-500">
|
||||
No locations yet
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each story.locations.filter(l => !l.current) as location (location.id)}
|
||||
<div class="card p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="rounded-full bg-surface-700 p-1.5">
|
||||
<MapPin class="h-4 w-4 text-surface-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium text-surface-100">{location.name}</span>
|
||||
{#if location.visited}
|
||||
<span class="ml-2 text-xs text-surface-500">
|
||||
<Eye class="inline h-3 w-3" /> visited
|
||||
</span>
|
||||
{/if}
|
||||
{#if location.description}
|
||||
<p class="mt-1 text-sm text-surface-400">{location.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-ghost rounded px-2 py-1 text-xs"
|
||||
onclick={() => goToLocation(location.id)}
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
146
src/lib/components/world/QuestPanel.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<script lang="ts">
|
||||
import { story } from '$lib/stores/story.svelte';
|
||||
import { Plus, Target, CheckCircle, XCircle, Circle } from 'lucide-svelte';
|
||||
import type { StoryBeat } from '$lib/types';
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newDescription = $state('');
|
||||
let newType = $state<StoryBeat['type']>('quest');
|
||||
|
||||
async function addBeat() {
|
||||
if (!newTitle.trim()) return;
|
||||
await story.addStoryBeat(newTitle.trim(), newType, newDescription.trim() || undefined);
|
||||
newTitle = '';
|
||||
newDescription = '';
|
||||
newType = 'quest';
|
||||
showAddForm = false;
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return Circle;
|
||||
case 'active': return Target;
|
||||
case 'completed': return CheckCircle;
|
||||
case 'failed': return XCircle;
|
||||
default: return Circle;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-surface-500';
|
||||
case 'active': return 'text-amber-400';
|
||||
case 'completed': return 'text-green-400';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-surface-400';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string) {
|
||||
switch (type) {
|
||||
case 'milestone': return 'Milestone';
|
||||
case 'quest': return 'Quest';
|
||||
case 'revelation': return 'Revelation';
|
||||
case 'event': return 'Event';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-medium text-surface-200">Story Beats</h3>
|
||||
<button
|
||||
class="btn-ghost rounded p-1"
|
||||
onclick={() => showAddForm = !showAddForm}
|
||||
title="Add story beat"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="card space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
placeholder="Title"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<select bind:value={newType} class="input text-sm">
|
||||
<option value="quest">Quest</option>
|
||||
<option value="milestone">Milestone</option>
|
||||
<option value="revelation">Revelation</option>
|
||||
<option value="event">Event</option>
|
||||
</select>
|
||||
<textarea
|
||||
bind:value={newDescription}
|
||||
placeholder="Description (optional)"
|
||||
class="input text-sm"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-primary text-xs" onclick={addBeat} disabled={!newTitle.trim()}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active quests -->
|
||||
{#if story.pendingQuests.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-surface-400">Active</h4>
|
||||
{#each story.pendingQuests as beat (beat.id)}
|
||||
{@const StatusIcon = getStatusIcon(beat.status)}
|
||||
<div class="card p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class={getStatusColor(beat.status)}>
|
||||
<StatusIcon class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-surface-100">{beat.title}</span>
|
||||
<span class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
|
||||
{getTypeLabel(beat.type)}
|
||||
</span>
|
||||
</div>
|
||||
{#if beat.description}
|
||||
<p class="mt-1 text-sm text-surface-400">{beat.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if story.storyBeats.length === 0}
|
||||
<p class="py-4 text-center text-sm text-surface-500">
|
||||
No story beats yet
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Completed/Failed -->
|
||||
{@const completedBeats = story.storyBeats.filter(b => b.status === 'completed' || b.status === 'failed')}
|
||||
{#if completedBeats.length > 0}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium text-surface-400">History</h4>
|
||||
{#each completedBeats as beat (beat.id)}
|
||||
{@const StatusIcon = getStatusIcon(beat.status)}
|
||||
<div class="card p-3 opacity-60">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class={getStatusColor(beat.status)}>
|
||||
<StatusIcon class="h-4 w-4" />
|
||||
</div>
|
||||
<span class="text-surface-300">{beat.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
190
src/lib/services/ai/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import { OpenRouterProvider } from './openrouter';
|
||||
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
|
||||
import type { Message, GenerationResponse, StreamChunk } from './types';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
|
||||
|
||||
interface WorldState {
|
||||
characters: Character[];
|
||||
locations: Location[];
|
||||
items: Item[];
|
||||
storyBeats: StoryBeat[];
|
||||
currentLocation?: Location;
|
||||
}
|
||||
|
||||
class AIService {
|
||||
private getProvider() {
|
||||
const apiKey = settings.apiSettings.openrouterApiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error('No API key configured');
|
||||
}
|
||||
return new OpenRouterProvider(apiKey);
|
||||
}
|
||||
|
||||
async generateResponse(
|
||||
entries: StoryEntry[],
|
||||
worldState: WorldState,
|
||||
story?: Story | null
|
||||
): Promise<string> {
|
||||
const provider = this.getProvider();
|
||||
|
||||
// Build the system prompt with world state context
|
||||
const systemPrompt = this.buildSystemPrompt(worldState, story?.templateId);
|
||||
|
||||
// Build conversation history
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
];
|
||||
|
||||
// Add recent entries as conversation history
|
||||
const recentEntries = entries.slice(-20); // Keep last 20 entries for context
|
||||
for (const entry of recentEntries) {
|
||||
if (entry.type === 'user_action') {
|
||||
messages.push({ role: 'user', content: entry.content });
|
||||
} else if (entry.type === 'narration') {
|
||||
messages.push({ role: 'assistant', content: entry.content });
|
||||
}
|
||||
}
|
||||
|
||||
const response = await provider.generateResponse({
|
||||
messages,
|
||||
model: settings.apiSettings.defaultModel,
|
||||
temperature: settings.apiSettings.temperature,
|
||||
maxTokens: settings.apiSettings.maxTokens,
|
||||
});
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
async *streamResponse(
|
||||
entries: StoryEntry[],
|
||||
worldState: WorldState,
|
||||
story?: Story | null
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const provider = this.getProvider();
|
||||
|
||||
// Build the system prompt with world state context
|
||||
const systemPrompt = this.buildSystemPrompt(worldState, story?.templateId);
|
||||
|
||||
// Build conversation history
|
||||
const messages: Message[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
];
|
||||
|
||||
// Add recent entries as conversation history
|
||||
const recentEntries = entries.slice(-20);
|
||||
for (const entry of recentEntries) {
|
||||
if (entry.type === 'user_action') {
|
||||
messages.push({ role: 'user', content: entry.content });
|
||||
} else if (entry.type === 'narration') {
|
||||
messages.push({ role: 'assistant', content: entry.content });
|
||||
}
|
||||
}
|
||||
|
||||
yield* provider.streamResponse({
|
||||
messages,
|
||||
model: settings.apiSettings.defaultModel,
|
||||
temperature: settings.apiSettings.temperature,
|
||||
maxTokens: settings.apiSettings.maxTokens,
|
||||
});
|
||||
}
|
||||
|
||||
private buildSystemPrompt(worldState: WorldState, templateId?: string | null): string {
|
||||
// Get template-specific system prompt if available
|
||||
let basePrompt = '';
|
||||
|
||||
if (templateId) {
|
||||
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
|
||||
if (template?.systemPrompt) {
|
||||
basePrompt = template.systemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// If no template prompt, use default
|
||||
if (!basePrompt) {
|
||||
basePrompt = `You are an expert interactive fiction narrator and game master. Your role is to create engaging, immersive narrative responses to player actions.
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You see...", "You feel...")
|
||||
- Be descriptive and evocative, using sensory details
|
||||
- Respond to player actions naturally and logically
|
||||
- Maintain consistency with established world elements
|
||||
- Introduce interesting characters, challenges, and plot developments
|
||||
- Keep responses concise but atmospheric (2-4 paragraphs typically)
|
||||
- Never break character or mention being an AI`;
|
||||
}
|
||||
|
||||
// Add current world state context
|
||||
let worldContext = '\n\n---\n\n## Current World State:';
|
||||
let hasContext = false;
|
||||
|
||||
// Add current location
|
||||
if (worldState.currentLocation) {
|
||||
hasContext = true;
|
||||
worldContext += `\n\n### Current Location: ${worldState.currentLocation.name}`;
|
||||
if (worldState.currentLocation.description) {
|
||||
worldContext += `\n${worldState.currentLocation.description}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add active characters (excluding self)
|
||||
const activeChars = worldState.characters.filter(c => c.status === 'active' && c.relationship !== 'self');
|
||||
if (activeChars.length > 0) {
|
||||
hasContext = true;
|
||||
worldContext += '\n\n### Known Characters:';
|
||||
for (const char of activeChars) {
|
||||
worldContext += `\n- **${char.name}**`;
|
||||
if (char.relationship) worldContext += ` (${char.relationship})`;
|
||||
if (char.description) worldContext += `: ${char.description}`;
|
||||
if (char.traits && char.traits.length > 0) {
|
||||
worldContext += ` [${char.traits.join(', ')}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add inventory
|
||||
const inventory = worldState.items.filter(i => i.location === 'inventory');
|
||||
if (inventory.length > 0) {
|
||||
hasContext = true;
|
||||
worldContext += '\n\n### Player Inventory:';
|
||||
for (const item of inventory) {
|
||||
worldContext += `\n- ${item.name}`;
|
||||
if (item.quantity > 1) worldContext += ` (x${item.quantity})`;
|
||||
if (item.equipped) worldContext += ' [equipped]';
|
||||
if (item.description) worldContext += `: ${item.description}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add active quests
|
||||
const activeQuests = worldState.storyBeats.filter(b => b.status === 'active' || b.status === 'pending');
|
||||
if (activeQuests.length > 0) {
|
||||
hasContext = true;
|
||||
worldContext += '\n\n### Active Story Threads:';
|
||||
for (const quest of activeQuests) {
|
||||
worldContext += `\n- **${quest.title}**`;
|
||||
if (quest.type) worldContext += ` [${quest.type}]`;
|
||||
if (quest.description) worldContext += `: ${quest.description}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add visited locations for context
|
||||
const visitedLocations = worldState.locations.filter(l => l.visited && !l.current);
|
||||
if (visitedLocations.length > 0) {
|
||||
hasContext = true;
|
||||
worldContext += '\n\n### Previously Visited:';
|
||||
worldContext += visitedLocations.map(l => l.name).join(', ');
|
||||
}
|
||||
|
||||
// Only add world context if we have any
|
||||
if (hasContext) {
|
||||
basePrompt += worldContext;
|
||||
}
|
||||
|
||||
// Add final instruction
|
||||
basePrompt += '\n\n---\n\n**Instructions:** Respond to the player\'s action with an engaging narrative continuation. Describe the results of their action, include sensory details and character reactions, and set up opportunities for further exploration or interaction.';
|
||||
|
||||
return basePrompt;
|
||||
}
|
||||
}
|
||||
|
||||
export const aiService = new AIService();
|
||||
145
src/lib/services/ai/openrouter.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { AIProvider, GenerationRequest, GenerationResponse, StreamChunk, ModelInfo, Message } from './types';
|
||||
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
export class OpenRouterProvider implements AIProvider {
|
||||
id = 'openrouter';
|
||||
name = 'OpenRouter';
|
||||
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async generateResponse(request: GenerationRequest): Promise<GenerationResponse> {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'HTTP-Referer': 'https://aventura.app',
|
||||
'X-Title': 'Aventura',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: request.messages,
|
||||
temperature: request.temperature ?? 0.8,
|
||||
max_tokens: request.maxTokens ?? 1024,
|
||||
stop: request.stopSequences,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
content: data.choices[0]?.message?.content ?? '',
|
||||
model: data.model,
|
||||
usage: data.usage ? {
|
||||
promptTokens: data.usage.prompt_tokens,
|
||||
completionTokens: data.usage.completion_tokens,
|
||||
totalTokens: data.usage.total_tokens,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async *streamResponse(request: GenerationRequest): AsyncIterable<StreamChunk> {
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'HTTP-Referer': 'https://aventura.app',
|
||||
'X-Title': 'Aventura',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: request.messages,
|
||||
temperature: request.temperature ?? 0.8,
|
||||
max_tokens: request.maxTokens ?? 1024,
|
||||
stop: request.stopSequences,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
yield { content: '', done: true };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices[0]?.delta?.content ?? '';
|
||||
if (content) {
|
||||
yield { content, done: false };
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors for incomplete JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<ModelInfo[]> {
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch models');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data.map((model: any) => ({
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
description: model.description,
|
||||
contextLength: model.context_length ?? 4096,
|
||||
pricing: model.pricing ? {
|
||||
prompt: parseFloat(model.pricing.prompt),
|
||||
completion: parseFloat(model.pricing.completion),
|
||||
} : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async validateApiKey(): Promise<boolean> {
|
||||
try {
|
||||
await this.listModels();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/lib/services/ai/types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface GenerationRequest {
|
||||
messages: Message[];
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stopSequences?: string[];
|
||||
}
|
||||
|
||||
export interface GenerationResponse {
|
||||
content: string;
|
||||
model: string;
|
||||
usage?: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
content: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contextLength: number;
|
||||
pricing?: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
generateResponse(request: GenerationRequest): Promise<GenerationResponse>;
|
||||
streamResponse(request: GenerationRequest): AsyncIterable<StreamChunk>;
|
||||
listModels(): Promise<ModelInfo[]>;
|
||||
validateApiKey(): Promise<boolean>;
|
||||
}
|
||||
433
src/lib/services/database.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import Database from '@tauri-apps/plugin-sql';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Template } from '$lib/types';
|
||||
|
||||
class DatabaseService {
|
||||
private db: Database | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.db) return;
|
||||
this.db = await Database.load('sqlite:aventura.db');
|
||||
}
|
||||
|
||||
private async getDb(): Promise<Database> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
return this.db!;
|
||||
}
|
||||
|
||||
// Settings operations
|
||||
async getSetting(key: string): Promise<string | null> {
|
||||
const db = await this.getDb();
|
||||
const result = await db.select<{ value: string }[]>(
|
||||
'SELECT value FROM settings WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
return result.length > 0 ? result[0].value : null;
|
||||
}
|
||||
|
||||
async setSetting(key: string, value: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
// Story operations
|
||||
async getAllStories(): Promise<Story[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM stories ORDER BY updated_at DESC'
|
||||
);
|
||||
return results.map(this.mapStory);
|
||||
}
|
||||
|
||||
async getStory(id: string): Promise<Story | null> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM stories WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
return results.length > 0 ? this.mapStory(results[0]) : null;
|
||||
}
|
||||
|
||||
async createStory(story: Omit<Story, 'createdAt' | 'updatedAt'>): Promise<Story> {
|
||||
const db = await this.getDb();
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
`INSERT INTO stories (id, title, description, genre, template_id, created_at, updated_at, settings)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
story.id,
|
||||
story.title,
|
||||
story.description,
|
||||
story.genre,
|
||||
story.templateId,
|
||||
now,
|
||||
now,
|
||||
story.settings ? JSON.stringify(story.settings) : null,
|
||||
]
|
||||
);
|
||||
return { ...story, createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
async updateStory(id: string, updates: Partial<Story>): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
const now = Date.now();
|
||||
const setClauses: string[] = ['updated_at = ?'];
|
||||
const values: any[] = [now];
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
setClauses.push('title = ?');
|
||||
values.push(updates.title);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
setClauses.push('description = ?');
|
||||
values.push(updates.description);
|
||||
}
|
||||
if (updates.genre !== undefined) {
|
||||
setClauses.push('genre = ?');
|
||||
values.push(updates.genre);
|
||||
}
|
||||
if (updates.settings !== undefined) {
|
||||
setClauses.push('settings = ?');
|
||||
values.push(JSON.stringify(updates.settings));
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
await db.execute(
|
||||
`UPDATE stories SET ${setClauses.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
async deleteStory(id: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute('DELETE FROM stories WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// Story entries operations
|
||||
async getStoryEntries(storyId: string): Promise<StoryEntry[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM story_entries WHERE story_id = ? ORDER BY position ASC',
|
||||
[storyId]
|
||||
);
|
||||
return results.map(this.mapStoryEntry);
|
||||
}
|
||||
|
||||
async addStoryEntry(entry: Omit<StoryEntry, 'createdAt'>): Promise<StoryEntry> {
|
||||
const db = await this.getDb();
|
||||
const now = Date.now();
|
||||
await db.execute(
|
||||
`INSERT INTO story_entries (id, story_id, type, content, parent_id, position, created_at, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.id,
|
||||
entry.storyId,
|
||||
entry.type,
|
||||
entry.content,
|
||||
entry.parentId,
|
||||
entry.position,
|
||||
now,
|
||||
entry.metadata ? JSON.stringify(entry.metadata) : null,
|
||||
]
|
||||
);
|
||||
return { ...entry, createdAt: now };
|
||||
}
|
||||
|
||||
async getNextEntryPosition(storyId: string): Promise<number> {
|
||||
const db = await this.getDb();
|
||||
const result = await db.select<{ maxPos: number | null }[]>(
|
||||
'SELECT MAX(position) as maxPos FROM story_entries WHERE story_id = ?',
|
||||
[storyId]
|
||||
);
|
||||
return (result[0]?.maxPos ?? -1) + 1;
|
||||
}
|
||||
|
||||
async updateStoryEntry(id: string, updates: Partial<StoryEntry>): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.content !== undefined) {
|
||||
setClauses.push('content = ?');
|
||||
values.push(updates.content);
|
||||
}
|
||||
if (updates.type !== undefined) {
|
||||
setClauses.push('type = ?');
|
||||
values.push(updates.type);
|
||||
}
|
||||
if (updates.metadata !== undefined) {
|
||||
setClauses.push('metadata = ?');
|
||||
values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) return;
|
||||
values.push(id);
|
||||
await db.execute(
|
||||
`UPDATE story_entries SET ${setClauses.join(', ')} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
async deleteStoryEntry(id: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute('DELETE FROM story_entries WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// Character operations
|
||||
async getCharacters(storyId: string): Promise<Character[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM characters WHERE story_id = ?',
|
||||
[storyId]
|
||||
);
|
||||
return results.map(this.mapCharacter);
|
||||
}
|
||||
|
||||
async addCharacter(character: Character): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO characters (id, story_id, name, description, relationship, traits, status, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
character.id,
|
||||
character.storyId,
|
||||
character.name,
|
||||
character.description,
|
||||
character.relationship,
|
||||
JSON.stringify(character.traits),
|
||||
character.status,
|
||||
character.metadata ? JSON.stringify(character.metadata) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async updateCharacter(id: string, updates: Partial<Character>): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.name !== undefined) { setClauses.push('name = ?'); values.push(updates.name); }
|
||||
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
|
||||
if (updates.relationship !== undefined) { setClauses.push('relationship = ?'); values.push(updates.relationship); }
|
||||
if (updates.traits !== undefined) { setClauses.push('traits = ?'); values.push(JSON.stringify(updates.traits)); }
|
||||
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
|
||||
|
||||
if (setClauses.length === 0) return;
|
||||
values.push(id);
|
||||
await db.execute(`UPDATE characters SET ${setClauses.join(', ')} WHERE id = ?`, values);
|
||||
}
|
||||
|
||||
// Location operations
|
||||
async getLocations(storyId: string): Promise<Location[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM locations WHERE story_id = ?',
|
||||
[storyId]
|
||||
);
|
||||
return results.map(this.mapLocation);
|
||||
}
|
||||
|
||||
async addLocation(location: Location): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO locations (id, story_id, name, description, visited, current, connections, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
location.id,
|
||||
location.storyId,
|
||||
location.name,
|
||||
location.description,
|
||||
location.visited ? 1 : 0,
|
||||
location.current ? 1 : 0,
|
||||
JSON.stringify(location.connections),
|
||||
location.metadata ? JSON.stringify(location.metadata) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async setCurrentLocation(storyId: string, locationId: string): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute('UPDATE locations SET current = 0 WHERE story_id = ?', [storyId]);
|
||||
await db.execute('UPDATE locations SET current = 1, visited = 1 WHERE id = ?', [locationId]);
|
||||
}
|
||||
|
||||
// Item operations
|
||||
async getItems(storyId: string): Promise<Item[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM items WHERE story_id = ?',
|
||||
[storyId]
|
||||
);
|
||||
return results.map(this.mapItem);
|
||||
}
|
||||
|
||||
async addItem(item: Item): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO items (id, story_id, name, description, quantity, equipped, location, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
item.id,
|
||||
item.storyId,
|
||||
item.name,
|
||||
item.description,
|
||||
item.quantity,
|
||||
item.equipped ? 1 : 0,
|
||||
item.location,
|
||||
item.metadata ? JSON.stringify(item.metadata) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Story beats operations
|
||||
async getStoryBeats(storyId: string): Promise<StoryBeat[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>(
|
||||
'SELECT * FROM story_beats WHERE story_id = ?',
|
||||
[storyId]
|
||||
);
|
||||
return results.map(this.mapStoryBeat);
|
||||
}
|
||||
|
||||
async addStoryBeat(beat: StoryBeat): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO story_beats (id, story_id, title, description, type, status, triggered_at, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
beat.id,
|
||||
beat.storyId,
|
||||
beat.title,
|
||||
beat.description,
|
||||
beat.type,
|
||||
beat.status,
|
||||
beat.triggeredAt,
|
||||
beat.metadata ? JSON.stringify(beat.metadata) : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Template operations
|
||||
async getTemplates(): Promise<Template[]> {
|
||||
const db = await this.getDb();
|
||||
const results = await db.select<any[]>('SELECT * FROM templates ORDER BY is_builtin DESC, name ASC');
|
||||
return results.map(this.mapTemplate);
|
||||
}
|
||||
|
||||
async addTemplate(template: Template): Promise<void> {
|
||||
const db = await this.getDb();
|
||||
await db.execute(
|
||||
`INSERT INTO templates (id, name, description, genre, system_prompt, initial_state, is_builtin, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
template.id,
|
||||
template.name,
|
||||
template.description,
|
||||
template.genre,
|
||||
template.systemPrompt,
|
||||
template.initialState ? JSON.stringify(template.initialState) : null,
|
||||
template.isBuiltin ? 1 : 0,
|
||||
template.createdAt,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Mapping functions
|
||||
private mapStory(row: any): Story {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
genre: row.genre,
|
||||
templateId: row.template_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
settings: row.settings ? JSON.parse(row.settings) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapStoryEntry(row: any): StoryEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
storyId: row.story_id,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
parentId: row.parent_id,
|
||||
position: row.position,
|
||||
createdAt: row.created_at,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapCharacter(row: any): Character {
|
||||
return {
|
||||
id: row.id,
|
||||
storyId: row.story_id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
relationship: row.relationship,
|
||||
traits: row.traits ? JSON.parse(row.traits) : [],
|
||||
status: row.status,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapLocation(row: any): Location {
|
||||
return {
|
||||
id: row.id,
|
||||
storyId: row.story_id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
visited: row.visited === 1,
|
||||
current: row.current === 1,
|
||||
connections: row.connections ? JSON.parse(row.connections) : [],
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapItem(row: any): Item {
|
||||
return {
|
||||
id: row.id,
|
||||
storyId: row.story_id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
quantity: row.quantity,
|
||||
equipped: row.equipped === 1,
|
||||
location: row.location,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapStoryBeat(row: any): StoryBeat {
|
||||
return {
|
||||
id: row.id,
|
||||
storyId: row.story_id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
triggeredAt: row.triggered_at,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTemplate(row: any): Template {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
genre: row.genre,
|
||||
systemPrompt: row.system_prompt,
|
||||
initialState: row.initial_state ? JSON.parse(row.initial_state) : null,
|
||||
isBuiltin: row.is_builtin === 1,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const database = new DatabaseService();
|
||||
313
src/lib/services/export.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { save, open } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile, readTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { database } from './database';
|
||||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
|
||||
|
||||
export interface AventuraExport {
|
||||
version: string;
|
||||
exportedAt: number;
|
||||
story: Story;
|
||||
entries: StoryEntry[];
|
||||
characters: Character[];
|
||||
locations: Location[];
|
||||
items: Item[];
|
||||
storyBeats: StoryBeat[];
|
||||
}
|
||||
|
||||
class ExportService {
|
||||
private readonly VERSION = '1.0.0';
|
||||
|
||||
// Export to Aventura format (.avt - JSON)
|
||||
async exportToAventura(
|
||||
story: Story,
|
||||
entries: StoryEntry[],
|
||||
characters: Character[],
|
||||
locations: Location[],
|
||||
items: Item[],
|
||||
storyBeats: StoryBeat[]
|
||||
): Promise<boolean> {
|
||||
const exportData: AventuraExport = {
|
||||
version: this.VERSION,
|
||||
exportedAt: Date.now(),
|
||||
story,
|
||||
entries,
|
||||
characters,
|
||||
locations,
|
||||
items,
|
||||
storyBeats,
|
||||
};
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `${this.sanitizeFilename(story.title)}.avt`,
|
||||
filters: [
|
||||
{ name: 'Aventura Story', extensions: ['avt'] },
|
||||
{ name: 'JSON', extensions: ['json'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath) return false;
|
||||
|
||||
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Export to Markdown
|
||||
async exportToMarkdown(
|
||||
story: Story,
|
||||
entries: StoryEntry[],
|
||||
characters: Character[],
|
||||
locations: Location[],
|
||||
includeWorldState: boolean = false
|
||||
): Promise<boolean> {
|
||||
let markdown = `# ${story.title}\n\n`;
|
||||
|
||||
if (story.description) {
|
||||
markdown += `*${story.description}*\n\n`;
|
||||
}
|
||||
|
||||
if (story.genre) {
|
||||
markdown += `**Genre:** ${story.genre}\n\n`;
|
||||
}
|
||||
|
||||
markdown += `---\n\n`;
|
||||
|
||||
// Add story entries
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'user_action') {
|
||||
markdown += `> **You:** ${entry.content}\n\n`;
|
||||
} else if (entry.type === 'narration') {
|
||||
markdown += `${entry.content}\n\n`;
|
||||
} else if (entry.type === 'system') {
|
||||
markdown += `*[System: ${entry.content}]*\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally include world state
|
||||
if (includeWorldState) {
|
||||
markdown += `---\n\n## World State\n\n`;
|
||||
|
||||
if (characters.length > 0) {
|
||||
markdown += `### Characters\n\n`;
|
||||
for (const char of characters) {
|
||||
markdown += `- **${char.name}**`;
|
||||
if (char.relationship) markdown += ` (${char.relationship})`;
|
||||
if (char.description) markdown += `: ${char.description}`;
|
||||
markdown += `\n`;
|
||||
}
|
||||
markdown += `\n`;
|
||||
}
|
||||
|
||||
if (locations.length > 0) {
|
||||
markdown += `### Locations\n\n`;
|
||||
for (const loc of locations) {
|
||||
markdown += `- **${loc.name}**`;
|
||||
if (loc.current) markdown += ` [Current]`;
|
||||
if (loc.visited) markdown += ` [Visited]`;
|
||||
if (loc.description) markdown += `: ${loc.description}`;
|
||||
markdown += `\n`;
|
||||
}
|
||||
markdown += `\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add export metadata
|
||||
markdown += `---\n\n`;
|
||||
markdown += `*Exported from Aventura on ${new Date().toLocaleDateString()}*\n`;
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `${this.sanitizeFilename(story.title)}.md`,
|
||||
filters: [
|
||||
{ name: 'Markdown', extensions: ['md'] },
|
||||
{ name: 'Text', extensions: ['txt'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath) return false;
|
||||
|
||||
await writeTextFile(filePath, markdown);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Export to plain text
|
||||
async exportToText(story: Story, entries: StoryEntry[]): Promise<boolean> {
|
||||
let text = `${story.title}\n${'='.repeat(story.title.length)}\n\n`;
|
||||
|
||||
if (story.description) {
|
||||
text += `${story.description}\n\n`;
|
||||
}
|
||||
|
||||
text += `---\n\n`;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'user_action') {
|
||||
text += `> ${entry.content}\n\n`;
|
||||
} else if (entry.type === 'narration') {
|
||||
text += `${entry.content}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: `${this.sanitizeFilename(story.title)}.txt`,
|
||||
filters: [
|
||||
{ name: 'Text', extensions: ['txt'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath) return false;
|
||||
|
||||
await writeTextFile(filePath, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import from Aventura format (.avt)
|
||||
async importFromAventura(): Promise<{ success: boolean; storyId?: string; error?: string }> {
|
||||
const filePath = await open({
|
||||
filters: [
|
||||
{ name: 'Aventura Story', extensions: ['avt'] },
|
||||
{ name: 'JSON', extensions: ['json'] },
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readTextFile(filePath);
|
||||
const data: AventuraExport = JSON.parse(content);
|
||||
|
||||
// Validate the import data
|
||||
if (!data.version || !data.story || !data.entries) {
|
||||
return { success: false, error: 'Invalid Aventura file format' };
|
||||
}
|
||||
|
||||
// Generate new IDs to avoid conflicts
|
||||
const oldToNewId = new Map<string, string>();
|
||||
|
||||
// Create new story ID
|
||||
const newStoryId = crypto.randomUUID();
|
||||
oldToNewId.set(data.story.id, newStoryId);
|
||||
|
||||
// Create the story with a modified title to indicate it was imported
|
||||
const importedStory: Omit<Story, 'createdAt' | 'updatedAt'> = {
|
||||
id: newStoryId,
|
||||
title: `${data.story.title} (Imported)`,
|
||||
description: data.story.description,
|
||||
genre: data.story.genre,
|
||||
templateId: data.story.templateId,
|
||||
settings: data.story.settings,
|
||||
};
|
||||
|
||||
await database.createStory(importedStory);
|
||||
|
||||
// Import entries
|
||||
for (const entry of data.entries) {
|
||||
const newEntryId = crypto.randomUUID();
|
||||
oldToNewId.set(entry.id, newEntryId);
|
||||
|
||||
await database.addStoryEntry({
|
||||
id: newEntryId,
|
||||
storyId: newStoryId,
|
||||
type: entry.type,
|
||||
content: entry.content,
|
||||
parentId: entry.parentId ? oldToNewId.get(entry.parentId) ?? null : null,
|
||||
position: entry.position,
|
||||
metadata: entry.metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Import characters
|
||||
if (data.characters) {
|
||||
for (const char of data.characters) {
|
||||
const newCharId = crypto.randomUUID();
|
||||
oldToNewId.set(char.id, newCharId);
|
||||
|
||||
await database.addCharacter({
|
||||
id: newCharId,
|
||||
storyId: newStoryId,
|
||||
name: char.name,
|
||||
description: char.description,
|
||||
relationship: char.relationship,
|
||||
traits: char.traits,
|
||||
status: char.status,
|
||||
metadata: char.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Import locations
|
||||
if (data.locations) {
|
||||
for (const loc of data.locations) {
|
||||
const newLocId = crypto.randomUUID();
|
||||
oldToNewId.set(loc.id, newLocId);
|
||||
|
||||
await database.addLocation({
|
||||
id: newLocId,
|
||||
storyId: newStoryId,
|
||||
name: loc.name,
|
||||
description: loc.description,
|
||||
visited: loc.visited,
|
||||
current: loc.current,
|
||||
connections: loc.connections.map(c => oldToNewId.get(c) ?? c),
|
||||
metadata: loc.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Import items
|
||||
if (data.items) {
|
||||
for (const item of data.items) {
|
||||
const newItemId = crypto.randomUUID();
|
||||
oldToNewId.set(item.id, newItemId);
|
||||
|
||||
await database.addItem({
|
||||
id: newItemId,
|
||||
storyId: newStoryId,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
equipped: item.equipped,
|
||||
location: item.location,
|
||||
metadata: item.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Import story beats
|
||||
if (data.storyBeats) {
|
||||
for (const beat of data.storyBeats) {
|
||||
const newBeatId = crypto.randomUUID();
|
||||
oldToNewId.set(beat.id, newBeatId);
|
||||
|
||||
await database.addStoryBeat({
|
||||
id: newBeatId,
|
||||
storyId: newStoryId,
|
||||
title: beat.title,
|
||||
description: beat.description,
|
||||
type: beat.type,
|
||||
status: beat.status,
|
||||
triggeredAt: beat.triggeredAt,
|
||||
metadata: beat.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, storyId: newStoryId };
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to import file',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, '_')
|
||||
.slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
export const exportService = new ExportService();
|
||||
296
src/lib/services/templates.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import type { Template, TemplateInitialState } from '$lib/types';
|
||||
import { database } from './database';
|
||||
|
||||
export const BUILTIN_TEMPLATES: Omit<Template, 'createdAt'>[] = [
|
||||
{
|
||||
id: 'fantasy-adventure',
|
||||
name: 'Fantasy Adventure',
|
||||
description: 'Epic quests, magic, mythical creatures, and heroic journeys in a medieval fantasy world.',
|
||||
genre: 'Fantasy',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are a master storyteller crafting an immersive fantasy adventure. The world is filled with magic, mythical creatures, ancient prophecies, and epic quests.
|
||||
|
||||
## World Elements:
|
||||
- Magic exists and can be learned or innate
|
||||
- Various races (elves, dwarves, orcs, etc.) may exist
|
||||
- Medieval-esque technology with magical enhancements
|
||||
- Gods and divine forces may influence events
|
||||
- Ancient ruins, enchanted forests, and mystical locations abound
|
||||
|
||||
## Narrative Style:
|
||||
- Rich, descriptive prose with vivid imagery
|
||||
- Balance action, dialogue, and atmosphere
|
||||
- Include moments of wonder and discovery
|
||||
- Build tension through challenges and mysteries
|
||||
- Reward clever thinking and brave actions
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You see...", "You feel...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Include sensory details (sights, sounds, smells)
|
||||
- NPCs should have distinct personalities
|
||||
- Combat should be exciting but not gratuitously violent
|
||||
- Magic should feel wondrous, not mundane`,
|
||||
initialState: {
|
||||
protagonist: {
|
||||
name: 'The Adventurer',
|
||||
description: 'A brave soul seeking glory and purpose',
|
||||
traits: ['brave', 'curious', 'determined'],
|
||||
},
|
||||
startingLocation: {
|
||||
name: 'The Crossroads Inn',
|
||||
description: 'A weathered tavern where travelers share tales of distant lands and rumors of adventure.',
|
||||
},
|
||||
initialItems: [
|
||||
{ name: 'Worn Traveler\'s Pack', description: 'A sturdy leather pack containing basic supplies' },
|
||||
{ name: 'Copper Coins', description: 'A small pouch of coins, enough for a few meals', quantity: 15 },
|
||||
],
|
||||
openingScene: 'The hearth crackles warmly as you push open the heavy oak door of the Crossroads Inn. The smell of roasted meat and spiced ale fills your nostrils. Travelers huddle at worn tables, their conversations a low murmur punctuated by occasional laughter. The innkeeper, a stout woman with kind eyes, looks up from polishing a tankard and nods in your direction. Through the smoky air, you notice a cloaked figure in the corner booth, and a notice board near the bar covered in weathered parchments.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'scifi-exploration',
|
||||
name: 'Sci-Fi Exploration',
|
||||
description: 'Explore the cosmos, encounter alien civilizations, and unravel the mysteries of the universe.',
|
||||
genre: 'Sci-Fi',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are crafting a science fiction adventure set in a vast, explorable universe. Technology is advanced but grounded in plausible science fiction concepts.
|
||||
|
||||
## World Elements:
|
||||
- Faster-than-light travel exists (warp drives, jump gates, etc.)
|
||||
- Multiple alien species with unique cultures
|
||||
- Advanced AI, cybernetics, and biotechnology
|
||||
- Corporate factions, space stations, and colony worlds
|
||||
- Mysteries of ancient civilizations and cosmic phenomena
|
||||
|
||||
## Narrative Style:
|
||||
- Blend hard sci-fi concepts with accessible storytelling
|
||||
- Create a sense of scale and wonder at the cosmos
|
||||
- Include technical details that enhance immersion
|
||||
- Balance exploration, social interaction, and action
|
||||
- Explore themes of humanity, consciousness, and discovery
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You see...", "You feel...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Make technology feel lived-in, not sterile
|
||||
- Alien cultures should feel genuinely foreign
|
||||
- Include ethical dilemmas and moral complexity
|
||||
- Space should feel vast and sometimes dangerous`,
|
||||
initialState: {
|
||||
protagonist: {
|
||||
name: 'The Captain',
|
||||
description: 'Commander of a small independent vessel, seeking fortune and discovery among the stars',
|
||||
traits: ['resourceful', 'adaptable', 'ambitious'],
|
||||
},
|
||||
startingLocation: {
|
||||
name: 'Nexus Station',
|
||||
description: 'A bustling space station at the intersection of major trade routes, home to traders, mercenaries, and those seeking to disappear.',
|
||||
},
|
||||
initialItems: [
|
||||
{ name: 'Personal Datapad', description: 'A versatile handheld computer with navigation and communication capabilities' },
|
||||
{ name: 'Standard Sidearm', description: 'A reliable energy pistol, standard issue for spacers' },
|
||||
{ name: 'Credit Chip', description: 'Digital currency storage', quantity: 500 },
|
||||
],
|
||||
openingScene: 'The airlock hisses open, and you step onto Nexus Station\'s main concourse. Holographic advertisements flicker in a dozen languages—some human, some decidedly not. The station\'s artificial gravity feels slightly off, a common quirk of older installations. Your ship, the *Vagrant Star*, sits in docking bay 47, her hull still bearing scorch marks from your last job. The message that brought you here promised lucrative work, but the sender remained anonymous. Through the crowd, you spot the designated meeting point: a grimy bar called "The Event Horizon."',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mystery-investigation',
|
||||
name: 'Mystery Investigation',
|
||||
description: 'Solve intricate puzzles, uncover hidden truths, and bring justice to light as a detective.',
|
||||
genre: 'Mystery',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are weaving an intricate mystery narrative where the player takes on the role of an investigator. Clues, red herrings, and revelations drive the story forward.
|
||||
|
||||
## World Elements:
|
||||
- A grounded, realistic setting (modern day, noir, Victorian, etc.)
|
||||
- Complex characters with secrets and motivations
|
||||
- Interconnected clues that reward careful attention
|
||||
- Multiple suspects with means, motive, and opportunity
|
||||
- Atmospheric locations that enhance the mood
|
||||
|
||||
## Narrative Style:
|
||||
- Build tension through uncertainty and discovery
|
||||
- Plant clues naturally within descriptions
|
||||
- Create memorable, morally gray characters
|
||||
- Balance investigation, interrogation, and deduction
|
||||
- Reward player attention and clever thinking
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You notice...", "You deduce...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Include sensory details that might be clues
|
||||
- NPCs should be consistent but may lie or omit
|
||||
- Never solve the mystery for the player
|
||||
- Maintain fair play—all clues should be available`,
|
||||
initialState: {
|
||||
protagonist: {
|
||||
name: 'The Detective',
|
||||
description: 'A sharp-minded investigator with an eye for detail and a nose for deception',
|
||||
traits: ['observant', 'persistent', 'analytical'],
|
||||
},
|
||||
startingLocation: {
|
||||
name: 'The Crime Scene',
|
||||
description: 'An elegant study in a wealthy estate, now cordoned off with police tape.',
|
||||
},
|
||||
initialItems: [
|
||||
{ name: 'Detective\'s Notebook', description: 'A well-worn leather notebook for recording observations and theories' },
|
||||
{ name: 'Magnifying Glass', description: 'A quality lens for examining fine details' },
|
||||
{ name: 'Business Cards', description: 'Your professional credentials', quantity: 10 },
|
||||
],
|
||||
openingScene: 'The grandfather clock in the corner reads 11:47 PM—presumably the time it stopped when knocked over during the struggle. You duck under the yellow tape and survey the scene. Edmund Blackwood, textile magnate and philanthropist, lies face-down on the Persian rug, a letter opener protruding from his back. The French windows are ajar, curtains stirring in the night breeze. A half-empty glass of brandy sits on the desk beside scattered papers. The household staff waits in the parlor, and Officer Chen hands you a preliminary report. "No forced entry," she notes. "Someone he knew."',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'horror-survival',
|
||||
name: 'Horror Survival',
|
||||
description: 'Face your fears, survive the night, and confront the darkness that lurks in the shadows.',
|
||||
genre: 'Horror',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are crafting a horror narrative designed to create tension, dread, and fear. The player must survive against supernatural or mundane terrors.
|
||||
|
||||
## World Elements:
|
||||
- An atmosphere of creeping dread and unease
|
||||
- Threats that are initially hidden or poorly understood
|
||||
- Safe spaces that gradually become compromised
|
||||
- Limited resources and difficult choices
|
||||
- Psychological elements alongside physical danger
|
||||
|
||||
## Narrative Style:
|
||||
- Build tension slowly, then release in bursts
|
||||
- Use sensory details to create unease
|
||||
- Leave things to imagination—suggestion over explicit
|
||||
- Create a sense of isolation and vulnerability
|
||||
- Subvert expectations to maintain fear
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("Your heart pounds...", "You hear...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Never let the player feel completely safe
|
||||
- Horror should unsettle, not merely disgust
|
||||
- Give the player agency but make choices difficult
|
||||
- Include moments of hope to make darkness darker`,
|
||||
initialState: {
|
||||
protagonist: {
|
||||
name: 'The Survivor',
|
||||
description: 'An ordinary person thrust into extraordinary horror',
|
||||
traits: ['resourceful', 'frightened', 'determined'],
|
||||
},
|
||||
startingLocation: {
|
||||
name: 'The Old House',
|
||||
description: 'A decrepit Victorian mansion on the outskirts of town, long abandoned—until tonight.',
|
||||
},
|
||||
initialItems: [
|
||||
{ name: 'Flashlight', description: 'A reliable flashlight with fading batteries' },
|
||||
{ name: 'Cell Phone', description: 'No signal, but the screen provides some light' },
|
||||
],
|
||||
openingScene: 'The front door slams shut behind you. You spin around, pulling at the handle, but it won\'t budge. Through the grimy windows, your car sits in the overgrown driveway, impossibly far away. The dare was simple—spend one hour in the Ashford House. Your friends are probably laughing right now. But the laughter you heard just before the door closed... that didn\'t come from outside. Dust motes drift through your flashlight beam as you scan the entry hall. A grand staircase leads up into darkness. Doors lead left and right. Somewhere above, a floorboard creaks.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'slice-of-life',
|
||||
name: 'Slice of Life',
|
||||
description: 'Experience everyday moments, build relationships, and find meaning in the ordinary.',
|
||||
genre: 'Slice of Life',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are crafting a warm, character-driven narrative focused on everyday life, relationships, and personal growth. The magic is in the mundane.
|
||||
|
||||
## World Elements:
|
||||
- A grounded, realistic contemporary setting
|
||||
- Rich supporting characters with their own lives
|
||||
- Meaningful locations that feel lived-in
|
||||
- Seasonal changes and passage of time
|
||||
- Small stakes that feel personally significant
|
||||
|
||||
## Narrative Style:
|
||||
- Focus on character emotions and relationships
|
||||
- Find beauty and meaning in ordinary moments
|
||||
- Develop characters through small interactions
|
||||
- Balance humor, warmth, and gentle melancholy
|
||||
- Create a sense of community and belonging
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You smile...", "You remember...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Focus on internal experience as much as external
|
||||
- NPCs should feel like real people with real lives
|
||||
- Embrace quiet moments and comfortable silences
|
||||
- Growth comes from relationships and self-reflection`,
|
||||
initialState: {
|
||||
protagonist: {
|
||||
name: 'You',
|
||||
description: 'Someone at a crossroads in life, seeking connection and meaning',
|
||||
traits: ['thoughtful', 'kind', 'searching'],
|
||||
},
|
||||
startingLocation: {
|
||||
name: 'Your New Apartment',
|
||||
description: 'A small but cozy apartment in a new city, boxes still waiting to be unpacked.',
|
||||
},
|
||||
initialItems: [
|
||||
{ name: 'Moving Boxes', description: 'Cardboard boxes containing your belongings and memories', quantity: 12 },
|
||||
{ name: 'Favorite Mug', description: 'A chipped ceramic mug that\'s been with you through everything' },
|
||||
],
|
||||
openingScene: 'Sunlight streams through the bare windows of your new apartment, catching the dust you\'ve stirred up while unpacking. The space is small—a studio with a kitchenette—but it\'s yours. Outside, you can hear the unfamiliar sounds of your new neighborhood: a dog barking, someone practicing piano badly, the rumble of traffic. Your phone buzzes with a text from your mother asking if you\'ve eaten. You haven\'t. There\'s a café on the corner you noticed while moving in, and your stomach reminds you that coffee and human contact might both be good ideas. First day in a new city. Everything feels possible.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom Adventure',
|
||||
description: 'Start with a blank slate and define your own world, setting, and narrative style.',
|
||||
genre: 'Custom',
|
||||
isBuiltin: true,
|
||||
systemPrompt: `You are a collaborative storyteller helping to craft an interactive narrative. Adapt your style to match the world and tone the player establishes.
|
||||
|
||||
## Guidelines:
|
||||
- Write in second person ("You see...", "You feel...")
|
||||
- Responses should be 2-4 paragraphs
|
||||
- Match the tone and genre the player establishes
|
||||
- Be responsive to player choices and creativity
|
||||
- Create interesting characters and situations
|
||||
- Maintain consistency with established world details`,
|
||||
initialState: {
|
||||
openingScene: 'A blank page awaits your story. Where would you like to begin? Describe the world, your character, or simply start with an action—the narrative will follow your lead.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
class TemplateService {
|
||||
private initialized = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const existingTemplates = await database.getTemplates();
|
||||
const existingIds = new Set(existingTemplates.map(t => t.id));
|
||||
|
||||
// Add any missing built-in templates
|
||||
for (const template of BUILTIN_TEMPLATES) {
|
||||
if (!existingIds.has(template.id)) {
|
||||
await database.addTemplate({
|
||||
...template,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async getTemplates(): Promise<Template[]> {
|
||||
await this.init();
|
||||
return database.getTemplates();
|
||||
}
|
||||
|
||||
async getTemplate(id: string): Promise<Template | null> {
|
||||
await this.init();
|
||||
const templates = await database.getTemplates();
|
||||
return templates.find(t => t.id === id) ?? null;
|
||||
}
|
||||
|
||||
getBuiltinTemplates(): Omit<Template, 'createdAt'>[] {
|
||||
return BUILTIN_TEMPLATES;
|
||||
}
|
||||
}
|
||||
|
||||
export const templateService = new TemplateService();
|
||||
96
src/lib/stores/settings.svelte.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { APISettings, UISettings } from '$lib/types';
|
||||
import { database } from '$lib/services/database';
|
||||
|
||||
// Settings Store using Svelte 5 runes
|
||||
class SettingsStore {
|
||||
apiSettings = $state<APISettings>({
|
||||
openrouterApiKey: null,
|
||||
defaultModel: 'anthropic/claude-3.5-sonnet',
|
||||
temperature: 0.8,
|
||||
maxTokens: 1024,
|
||||
});
|
||||
|
||||
uiSettings = $state<UISettings>({
|
||||
theme: 'dark',
|
||||
fontSize: 'medium',
|
||||
showWordCount: true,
|
||||
autoSave: true,
|
||||
});
|
||||
|
||||
initialized = $state(false);
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
// Load API settings
|
||||
const apiKey = await database.getSetting('openrouter_api_key');
|
||||
const defaultModel = await database.getSetting('default_model');
|
||||
const temperature = await database.getSetting('temperature');
|
||||
const maxTokens = await database.getSetting('max_tokens');
|
||||
|
||||
if (apiKey) this.apiSettings.openrouterApiKey = apiKey;
|
||||
if (defaultModel) this.apiSettings.defaultModel = defaultModel;
|
||||
if (temperature) this.apiSettings.temperature = parseFloat(temperature);
|
||||
if (maxTokens) this.apiSettings.maxTokens = parseInt(maxTokens);
|
||||
|
||||
// Load UI settings
|
||||
const theme = await database.getSetting('theme');
|
||||
const fontSize = await database.getSetting('font_size');
|
||||
const showWordCount = await database.getSetting('show_word_count');
|
||||
const autoSave = await database.getSetting('auto_save');
|
||||
|
||||
if (theme) this.uiSettings.theme = theme as 'dark' | 'light';
|
||||
if (fontSize) this.uiSettings.fontSize = fontSize as 'small' | 'medium' | 'large';
|
||||
if (showWordCount) this.uiSettings.showWordCount = showWordCount === 'true';
|
||||
if (autoSave) this.uiSettings.autoSave = autoSave === 'true';
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
this.initialized = true; // Mark as initialized even on error to prevent infinite retries
|
||||
}
|
||||
}
|
||||
|
||||
async setApiKey(key: string) {
|
||||
this.apiSettings.openrouterApiKey = key;
|
||||
await database.setSetting('openrouter_api_key', key);
|
||||
}
|
||||
|
||||
async setDefaultModel(model: string) {
|
||||
this.apiSettings.defaultModel = model;
|
||||
await database.setSetting('default_model', model);
|
||||
}
|
||||
|
||||
async setTemperature(temp: number) {
|
||||
this.apiSettings.temperature = temp;
|
||||
await database.setSetting('temperature', temp.toString());
|
||||
}
|
||||
|
||||
async setMaxTokens(tokens: number) {
|
||||
this.apiSettings.maxTokens = tokens;
|
||||
await database.setSetting('max_tokens', tokens.toString());
|
||||
}
|
||||
|
||||
async setTheme(theme: 'dark' | 'light') {
|
||||
this.uiSettings.theme = theme;
|
||||
await database.setSetting('theme', theme);
|
||||
// Update the document class for Tailwind dark mode
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
async setFontSize(size: 'small' | 'medium' | 'large') {
|
||||
this.uiSettings.fontSize = size;
|
||||
await database.setSetting('font_size', size);
|
||||
}
|
||||
|
||||
get hasApiKey(): boolean {
|
||||
return !!this.apiSettings.openrouterApiKey;
|
||||
}
|
||||
}
|
||||
|
||||
export const settings = new SettingsStore();
|
||||
344
src/lib/stores/story.svelte.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
|
||||
import { database } from '$lib/services/database';
|
||||
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
|
||||
|
||||
// Story Store using Svelte 5 runes
|
||||
class StoryStore {
|
||||
// Current active story
|
||||
currentStory = $state<Story | null>(null);
|
||||
entries = $state<StoryEntry[]>([]);
|
||||
|
||||
// World state for current story
|
||||
characters = $state<Character[]>([]);
|
||||
locations = $state<Location[]>([]);
|
||||
items = $state<Item[]>([]);
|
||||
storyBeats = $state<StoryBeat[]>([]);
|
||||
|
||||
// Story library
|
||||
allStories = $state<Story[]>([]);
|
||||
|
||||
// Derived states
|
||||
get currentLocation(): Location | undefined {
|
||||
return this.locations.find(l => l.current);
|
||||
}
|
||||
|
||||
get activeCharacters(): Character[] {
|
||||
return this.characters.filter(c => c.status === 'active');
|
||||
}
|
||||
|
||||
get inventoryItems(): Item[] {
|
||||
return this.items.filter(i => i.location === 'inventory');
|
||||
}
|
||||
|
||||
get equippedItems(): Item[] {
|
||||
return this.items.filter(i => i.equipped);
|
||||
}
|
||||
|
||||
get pendingQuests(): StoryBeat[] {
|
||||
return this.storyBeats.filter(b => b.status === 'pending' || b.status === 'active');
|
||||
}
|
||||
|
||||
get wordCount(): number {
|
||||
return this.entries.reduce((count, entry) => {
|
||||
return count + entry.content.split(/\s+/).filter(Boolean).length;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Load all stories for library view
|
||||
async loadAllStories(): Promise<void> {
|
||||
this.allStories = await database.getAllStories();
|
||||
}
|
||||
|
||||
// Load a specific story with all its data
|
||||
async loadStory(storyId: string): Promise<void> {
|
||||
const story = await database.getStory(storyId);
|
||||
if (!story) {
|
||||
throw new Error(`Story not found: ${storyId}`);
|
||||
}
|
||||
|
||||
this.currentStory = story;
|
||||
|
||||
// Load all related data in parallel
|
||||
const [entries, characters, locations, items, storyBeats] = await Promise.all([
|
||||
database.getStoryEntries(storyId),
|
||||
database.getCharacters(storyId),
|
||||
database.getLocations(storyId),
|
||||
database.getItems(storyId),
|
||||
database.getStoryBeats(storyId),
|
||||
]);
|
||||
|
||||
this.entries = entries;
|
||||
this.characters = characters;
|
||||
this.locations = locations;
|
||||
this.items = items;
|
||||
this.storyBeats = storyBeats;
|
||||
}
|
||||
|
||||
// Create a new story
|
||||
async createStory(title: string, templateId?: string, genre?: string): Promise<Story> {
|
||||
const storyData = await database.createStory({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
description: null,
|
||||
genre: genre ?? null,
|
||||
templateId: templateId ?? null,
|
||||
settings: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
return storyData;
|
||||
}
|
||||
|
||||
// Create a new story from a template with initialization
|
||||
async createStoryFromTemplate(title: string, templateId: string, genre?: string): Promise<Story> {
|
||||
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
|
||||
|
||||
// Create the base story
|
||||
const storyData = await database.createStory({
|
||||
id: crypto.randomUUID(),
|
||||
title,
|
||||
description: template?.description ?? null,
|
||||
genre: genre ?? null,
|
||||
templateId,
|
||||
settings: null,
|
||||
});
|
||||
|
||||
this.allStories = [storyData, ...this.allStories];
|
||||
|
||||
// Initialize with template data if available
|
||||
if (template?.initialState) {
|
||||
const state = template.initialState;
|
||||
|
||||
// Add protagonist as a character if defined
|
||||
if (state.protagonist) {
|
||||
const protagonist: Character = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: storyData.id,
|
||||
name: state.protagonist.name ?? 'Protagonist',
|
||||
description: state.protagonist.description ?? null,
|
||||
relationship: 'self',
|
||||
traits: state.protagonist.traits ?? [],
|
||||
status: 'active',
|
||||
metadata: null,
|
||||
};
|
||||
await database.addCharacter(protagonist);
|
||||
}
|
||||
|
||||
// Add starting location if defined
|
||||
if (state.startingLocation) {
|
||||
const location: Location = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: storyData.id,
|
||||
name: state.startingLocation.name ?? 'Starting Location',
|
||||
description: state.startingLocation.description ?? null,
|
||||
visited: true,
|
||||
current: true,
|
||||
connections: [],
|
||||
metadata: null,
|
||||
};
|
||||
await database.addLocation(location);
|
||||
}
|
||||
|
||||
// Add initial items if defined
|
||||
if (state.initialItems) {
|
||||
for (const itemData of state.initialItems) {
|
||||
const item: Item = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: storyData.id,
|
||||
name: itemData.name ?? 'Item',
|
||||
description: itemData.description ?? null,
|
||||
quantity: itemData.quantity ?? 1,
|
||||
equipped: false,
|
||||
location: 'inventory',
|
||||
metadata: null,
|
||||
};
|
||||
await database.addItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Add opening scene as first narration entry
|
||||
if (state.openingScene) {
|
||||
await database.addStoryEntry({
|
||||
id: crypto.randomUUID(),
|
||||
storyId: storyData.id,
|
||||
type: 'narration',
|
||||
content: state.openingScene,
|
||||
parentId: null,
|
||||
position: 0,
|
||||
metadata: { source: 'template' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return storyData;
|
||||
}
|
||||
|
||||
// Add a new story entry
|
||||
async addEntry(type: StoryEntry['type'], content: string, metadata?: StoryEntry['metadata']): Promise<StoryEntry> {
|
||||
if (!this.currentStory) {
|
||||
throw new Error('No story loaded');
|
||||
}
|
||||
|
||||
const position = await database.getNextEntryPosition(this.currentStory.id);
|
||||
const entry = await database.addStoryEntry({
|
||||
id: crypto.randomUUID(),
|
||||
storyId: this.currentStory.id,
|
||||
type,
|
||||
content,
|
||||
parentId: null,
|
||||
position,
|
||||
metadata: metadata ?? null,
|
||||
});
|
||||
|
||||
this.entries = [...this.entries, entry];
|
||||
|
||||
// Update story's updatedAt
|
||||
await database.updateStory(this.currentStory.id, {});
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Update a story entry
|
||||
async updateEntry(entryId: string, content: string): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
await database.updateStoryEntry(entryId, { content });
|
||||
this.entries = this.entries.map(e =>
|
||||
e.id === entryId ? { ...e, content } : e
|
||||
);
|
||||
|
||||
// Update story's updatedAt
|
||||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
// Delete a story entry
|
||||
async deleteEntry(entryId: string): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
await database.deleteStoryEntry(entryId);
|
||||
this.entries = this.entries.filter(e => e.id !== entryId);
|
||||
|
||||
// Update story's updatedAt
|
||||
await database.updateStory(this.currentStory.id, {});
|
||||
}
|
||||
|
||||
// Add a character
|
||||
async addCharacter(name: string, description?: string, relationship?: string): Promise<Character> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const character: Character = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: this.currentStory.id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
relationship: relationship ?? null,
|
||||
traits: [],
|
||||
status: 'active',
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
await database.addCharacter(character);
|
||||
this.characters = [...this.characters, character];
|
||||
return character;
|
||||
}
|
||||
|
||||
// Add a location
|
||||
async addLocation(name: string, description?: string, makeCurrent = false): Promise<Location> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const location: Location = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: this.currentStory.id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
visited: makeCurrent,
|
||||
current: makeCurrent,
|
||||
connections: [],
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
await database.addLocation(location);
|
||||
|
||||
if (makeCurrent) {
|
||||
// Update other locations to not be current
|
||||
this.locations = this.locations.map(l => ({ ...l, current: false }));
|
||||
}
|
||||
|
||||
this.locations = [...this.locations, location];
|
||||
return location;
|
||||
}
|
||||
|
||||
// Set current location
|
||||
async setCurrentLocation(locationId: string): Promise<void> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
await database.setCurrentLocation(this.currentStory.id, locationId);
|
||||
this.locations = this.locations.map(l => ({
|
||||
...l,
|
||||
current: l.id === locationId,
|
||||
visited: l.id === locationId ? true : l.visited,
|
||||
}));
|
||||
}
|
||||
|
||||
// Add an item to inventory
|
||||
async addItem(name: string, description?: string, quantity = 1): Promise<Item> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const item: Item = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: this.currentStory.id,
|
||||
name,
|
||||
description: description ?? null,
|
||||
quantity,
|
||||
equipped: false,
|
||||
location: 'inventory',
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
await database.addItem(item);
|
||||
this.items = [...this.items, item];
|
||||
return item;
|
||||
}
|
||||
|
||||
// Add a story beat
|
||||
async addStoryBeat(title: string, type: StoryBeat['type'], description?: string): Promise<StoryBeat> {
|
||||
if (!this.currentStory) throw new Error('No story loaded');
|
||||
|
||||
const beat: StoryBeat = {
|
||||
id: crypto.randomUUID(),
|
||||
storyId: this.currentStory.id,
|
||||
title,
|
||||
description: description ?? null,
|
||||
type,
|
||||
status: 'pending',
|
||||
triggeredAt: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
await database.addStoryBeat(beat);
|
||||
this.storyBeats = [...this.storyBeats, beat];
|
||||
return beat;
|
||||
}
|
||||
|
||||
// Clear current story (when switching or closing)
|
||||
clearCurrentStory(): void {
|
||||
this.currentStory = null;
|
||||
this.entries = [];
|
||||
this.characters = [];
|
||||
this.locations = [];
|
||||
this.items = [];
|
||||
this.storyBeats = [];
|
||||
}
|
||||
|
||||
// Delete a story
|
||||
async deleteStory(storyId: string): Promise<void> {
|
||||
await database.deleteStory(storyId);
|
||||
this.allStories = this.allStories.filter(s => s.id !== storyId);
|
||||
|
||||
if (this.currentStory?.id === storyId) {
|
||||
this.clearCurrentStory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const story = new StoryStore();
|
||||
59
src/lib/stores/ui.svelte.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { ActivePanel, SidebarTab, UIState } from '$lib/types';
|
||||
|
||||
// UI State using Svelte 5 runes
|
||||
class UIStore {
|
||||
activePanel = $state<ActivePanel>('story');
|
||||
sidebarTab = $state<SidebarTab>('characters');
|
||||
sidebarOpen = $state(true);
|
||||
settingsModalOpen = $state(false);
|
||||
isGenerating = $state(false);
|
||||
|
||||
// Streaming state
|
||||
streamingContent = $state('');
|
||||
isStreaming = $state(false);
|
||||
|
||||
setActivePanel(panel: ActivePanel) {
|
||||
this.activePanel = panel;
|
||||
}
|
||||
|
||||
setSidebarTab(tab: SidebarTab) {
|
||||
this.sidebarTab = tab;
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
}
|
||||
|
||||
openSettings() {
|
||||
this.settingsModalOpen = true;
|
||||
}
|
||||
|
||||
closeSettings() {
|
||||
this.settingsModalOpen = false;
|
||||
}
|
||||
|
||||
setGenerating(value: boolean) {
|
||||
this.isGenerating = value;
|
||||
}
|
||||
|
||||
// Streaming methods
|
||||
startStreaming() {
|
||||
this.isStreaming = true;
|
||||
this.streamingContent = '';
|
||||
}
|
||||
|
||||
appendStreamContent(content: string) {
|
||||
this.streamingContent += content;
|
||||
}
|
||||
|
||||
endStreaming() {
|
||||
this.isStreaming = false;
|
||||
this.streamingContent = '';
|
||||
}
|
||||
|
||||
getStreamingContent(): string {
|
||||
return this.streamingContent;
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = new UIStore();
|
||||
125
src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Core entity types for Aventura
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
genre: string | null;
|
||||
templateId: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
settings: StorySettings | null;
|
||||
}
|
||||
|
||||
export interface StorySettings {
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
systemPromptOverride?: string;
|
||||
}
|
||||
|
||||
export interface StoryEntry {
|
||||
id: string;
|
||||
storyId: string;
|
||||
type: 'user_action' | 'narration' | 'system' | 'retry';
|
||||
content: string;
|
||||
parentId: string | null;
|
||||
position: number;
|
||||
createdAt: number;
|
||||
metadata: EntryMetadata | null;
|
||||
}
|
||||
|
||||
export interface EntryMetadata {
|
||||
tokenCount?: number;
|
||||
model?: string;
|
||||
generationTime?: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string;
|
||||
storyId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
relationship: string | null;
|
||||
traits: string[];
|
||||
status: 'active' | 'inactive' | 'deceased';
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
storyId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visited: boolean;
|
||||
current: boolean;
|
||||
connections: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
storyId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
equipped: boolean;
|
||||
location: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface StoryBeat {
|
||||
id: string;
|
||||
storyId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
type: 'milestone' | 'quest' | 'revelation' | 'event';
|
||||
status: 'pending' | 'active' | 'completed' | 'failed';
|
||||
triggeredAt: number | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
genre: string | null;
|
||||
systemPrompt: string;
|
||||
initialState: TemplateInitialState | null;
|
||||
isBuiltin: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface TemplateInitialState {
|
||||
protagonist?: Partial<Character>;
|
||||
startingLocation?: Partial<Location>;
|
||||
initialItems?: Partial<Item>[];
|
||||
openingScene?: string;
|
||||
}
|
||||
|
||||
// UI State types
|
||||
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates';
|
||||
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests';
|
||||
|
||||
export interface UIState {
|
||||
activePanel: ActivePanel;
|
||||
sidebarTab: SidebarTab;
|
||||
sidebarOpen: boolean;
|
||||
settingsModalOpen: boolean;
|
||||
}
|
||||
|
||||
// API Settings
|
||||
export interface APISettings {
|
||||
openrouterApiKey: string | null;
|
||||
defaultModel: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
export interface UISettings {
|
||||
theme: 'dark' | 'light';
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
showWordCount: boolean;
|
||||
autoSave: boolean;
|
||||
}
|
||||
9
src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-screen w-screen overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
5
src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
export const ssr = false;
|
||||
50
src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { database } from '$lib/services/database';
|
||||
import { settings } from '$lib/stores/settings.svelte';
|
||||
import AppShell from '$lib/components/layout/AppShell.svelte';
|
||||
|
||||
let initialized = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// Initialize database connection
|
||||
await database.init();
|
||||
|
||||
// Initialize settings from database
|
||||
await settings.init();
|
||||
|
||||
initialized = true;
|
||||
} catch (e) {
|
||||
console.error('Initialization error:', e);
|
||||
error = e instanceof Error ? e.message : 'Failed to initialize application';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
|
||||
<div class="card max-w-md text-center">
|
||||
<h1 class="text-xl font-semibold text-red-400">Initialization Error</h1>
|
||||
<p class="mt-2 text-surface-400">{error}</p>
|
||||
<button
|
||||
class="btn btn-primary mt-4"
|
||||
onclick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !initialized}
|
||||
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
|
||||
<p class="text-surface-400">Loading Aventura...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<AppShell>
|
||||
<!-- Default slot content if needed -->
|
||||
</AppShell>
|
||||
{/if}
|
||||
BIN
static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
static/svelte.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
6
static/tauri.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
1
static/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Tauri doesn't have a Node.js server to do proper SSR
|
||||
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
45
tailwind.config.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Custom dark theme colors
|
||||
surface: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
850: '#141b25',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
accent: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
story: ['Georgia', 'Cambria', 'serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
19
tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
31
vite.config.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [sveltekit()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||