feat(games): add worldream game to monorepo

- Integrate worldream (text-first world-building platform) into games/
- Configure as @worldream/web workspace package
- Remove standalone git repo, now part of monorepo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 13:24:06 +01:00
parent ace7fa8f7f
commit 8e414c12ba
154 changed files with 26745 additions and 0 deletions

View file

@ -0,0 +1,12 @@
# Supabase Configuration
PUBLIC_SUPABASE_URL=your_supabase_project_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key
# Google Gemini Configuration
GEMINI_API_KEY=your_gemini_api_key
# Replicate Configuration (für Flux Bildgenerierung)
REPLICATE_API_TOKEN=your_replicate_api_token

23
games/worldream/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
games/worldream/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

View file

@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}

157
games/worldream/CLAUDE.md Normal file
View file

@ -0,0 +1,157 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Worldream** is a text-first platform for building and managing fictional worlds. It allows users to create and manage Characters, Objects, Places, and Stories as text-based entities that can be referenced and combined using @slug notation.
Key Concepts:
- **Content Nodes**: Unified entities representing worlds, characters, objects, places, and stories
- **@slug References**: Human-readable way to link entities within text (e.g., `@mira`, `@neo_station`)
- **Text-First Design**: All content is primarily text/markdown with optional attachments
- **LLM-Friendly**: Designed to work well with language models through clear text formats and prompt guidelines
## Development Commands
```bash
# Install dependencies (using pnpm)
pnpm install
# Start development server
pnpm dev
# or with browser auto-open
pnpm dev -- --open
# Build for production
pnpm build
# Preview production build
pnpm preview
# Type checking
pnpm check
# Watch mode for type checking
pnpm check:watch
# Linting and formatting
pnpm lint # Check formatting and run ESLint
pnpm format # Auto-format with Prettier
```
## Architecture
### Tech Stack
- **Framework**: SvelteKit with TypeScript und Svelte 5 (Runes Syntax)
- **Styling**: Tailwind CSS v4 (configured via Vite plugin)
- **Preprocessors**: Vite preprocessing for Svelte
- **Adapter**: Node.js adapter for deployment flexibility
- **Package Manager**: pnpm with workspace optimization
- **AI Integration**:
- OpenAI API mit GPT-5-mini für Text-Generierung (siehe wichtige Hinweise unten!)
- Google Gemini gemini-2.5-flash-image-preview für Bild-Generierung
### Project Structure
- `/src/routes/` - SvelteKit pages and API endpoints
- `/src/lib/` - Shared components and utilities
- `/src/app.css` - Global styles with Tailwind imports
- `/src/app.d.ts` - TypeScript ambient declarations
- `/static/` - Static assets
### Configuration Files
- `svelte.config.js` - SvelteKit configuration with Node adapter
- `vite.config.ts` - Vite config with Tailwind and SvelteKit plugins
- `tsconfig.json` - TypeScript config extending SvelteKit defaults (strict mode enabled)
- `eslint.config.js` - ESLint flat config with TypeScript and Svelte support
### Planned Data Model (from docs/ProjectPlan.md)
The project will use a unified `content_nodes` table with:
- Meta fields: id, kind, slug, title, summary, visibility, tags, etc.
- Content stored as JSONB with standardized keys across all entity types
- Full-text search via PostgreSQL tsvector
- Optional versioning via `node_revisions` table
- Story entries as separate timeline items
## Development Guidelines
### Svelte 5 Runes Syntax
This project uses Svelte 5 with runes - WICHTIG: Keine Legacy-Syntax verwenden!
- Use `$state()` for reactive state
- Use `$derived()` for computed values (NOT `$:` reactive statements)
- Use `$effect()` for side effects
- Use `$props()` for component props
- Use `{@render}` for rendering children/snippets
- Components use TypeScript with `<script lang="ts">`
### Import Aliases
- `$lib` maps to `/src/lib/`
- Additional aliases can be configured in svelte.config.js
### Database Integration (Implemented)
- Uses Supabase (PostgreSQL) with hybrid schema
- Content stored as JSONB for flexibility
- Row Level Security (RLS) based on owner_id and visibility
- Full-text search via generated tsvector columns
- World-centric navigation: All content is created within a world context
## ⚠️ KRITISCH: GPT-5-mini API Einschränkungen
**WICHTIG**: Dieses Projekt nutzt GPT-5-mini, ein spezielles OpenAI-Modell mit strikten Einschränkungen:
### Parameter-Einschränkungen
1. **Temperature**:
- **NUR `temperature: 1.0` wird unterstützt!**
- Andere Werte (0.7, 0.8, etc.) führen zu einem 400 Error
- Am besten den Parameter komplett weglassen (1.0 ist default)
2. **Token Limits**:
- **MUSS `max_completion_tokens` verwenden, NICHT `max_tokens`!**
- `max_tokens` führt zu einem 400 Error
- Typische Werte: 1000-5000 für normale Generierung
### Korrekte Verwendung
```typescript
// ✅ RICHTIG
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [...],
// temperature weglassen oder 1.0
max_completion_tokens: 2000 // NICHT max_tokens!
});
// ❌ FALSCH - führt zu 400 Error
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [...],
temperature: 0.7, // ERROR!
max_tokens: 2000 // ERROR! Muss max_completion_tokens sein
});
```
### Modell-Details
- **Knowledge Cutoff**: Mai 30, 2024
- **Kosten**: $0.25/1M Input, $2.00/1M Output
- **Features**: Unterstützt JSON mode, Streaming, Tools, Vision
- **Performance**: Gute Balance zwischen Geschwindigkeit und Qualität
### Debugging
Wenn API-Calls fehlschlagen:
1. Prüfe ob `temperature` != 1.0 gesetzt ist
2. Prüfe ob `max_tokens` statt `max_completion_tokens` verwendet wird
3. Prüfe die Console für detaillierte Fehlermeldungen
Weitere Details siehe `/docs/GPT5-MINI.md`

38
games/worldream/README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View file

@ -0,0 +1,13 @@
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-supabase",
"https://gbsrekoykkesullxdvbd.supabase.co",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imdic3Jla295a2tlc3VsbHhkdmJkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY1MTU3NzksImV4cCI6MjA3MjA5MTc3OX0.qQlZvHiB56oKTRD90fd8IasZeZELjXOA46f-hnOQA1g"
]
}
}
}

View file

@ -0,0 +1,19 @@
// Quick debug script to check character slugs
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'your_supabase_url';
const supabaseKey = 'your_supabase_anon_key';
const supabase = createClient(supabaseUrl, supabaseKey);
async function checkSlugs() {
const { data, error } = await supabase
.from('nodes')
.select('slug, title, kind')
.eq('kind', 'character')
.ilike('title', '%magier%');
console.log('Found characters with "magier":', data);
}
// checkSlugs()

View file

@ -0,0 +1,845 @@
# Character Autopilot - Detaillierte Planung
## Executive Summary
Character Autopilot ist ein KI-gesteuertes System, das Charaktere basierend auf ihren definierten Eigenschaften autonom in Szenen agieren lässt. Es generiert realistische Dialoge, Handlungen und Reaktionen, die konsistent mit der Persönlichkeit, Geschichte und den Beziehungen des Charakters sind.
## 🎯 Kernziele
1. **Konsistenz**: Charaktere verhalten sich immer gemäß ihrer Definition
2. **Kreativität**: Überraschende aber plausible Aktionen und Dialoge
3. **Interaktivität**: Echtzeit-Reaktionen auf Story-Entwicklungen
4. **Lernfähigkeit**: Verhalten entwickelt sich basierend auf Erfahrungen
5. **Kontrolle**: Autoren behalten volle Kontrolle über finale Entscheidungen
## 📊 Systemarchitektur
### Komponenten-Übersicht
```
┌─────────────────────────────────────────────────────┐
│ User Interface │
├─────────────────────────────────────────────────────┤
│ Autopilot Engine │
├──────────────┬────────────────┬────────────────────┤
│ Character │ Context │ Decision │
│ Analyzer │ Processor │ Engine │
├──────────────┼────────────────┼────────────────────┤
│ Memory │ Relationship │ Emotion │
│ Manager │ Graph │ Simulator │
├──────────────┴────────────────┴────────────────────┤
│ AI Provider Layer │
│ (OpenAI / Anthropic / Local Models) │
└─────────────────────────────────────────────────────┘
```
### Datenmodell-Erweiterungen
```typescript
// Neue Felder für content_nodes (kind='character')
interface CharacterAutopilotData {
// Persönlichkeit
personality: {
traits: PersonalityTrait[]; // Big Five + Custom
values: Value[]; // Was ist dem Charakter wichtig
fears: string[]; // Ängste und Phobien
desires: string[]; // Wünsche und Ziele
quirks: string[]; // Eigenarten und Ticks
};
// Verhaltensmuster
behavior_patterns: {
stress_response: 'fight' | 'flight' | 'freeze' | 'fawn';
decision_style: 'impulsive' | 'analytical' | 'intuitive' | 'cautious';
social_style: 'dominant' | 'influential' | 'steady' | 'conscientious';
conflict_style: 'competing' | 'accommodating' | 'avoiding' | 'compromising' | 'collaborating';
};
// Wissensstand
knowledge: {
known_facts: string[]; // Was der Charakter weiß
false_beliefs: string[]; // Falsche Annahmen
secrets_known: string[]; // Geheimnisse anderer
skills: Skill[]; // Fähigkeiten mit Levels
};
// Emotionaler Zustand
emotional_state: {
current_mood: Mood;
stress_level: number; // 0-100
energy_level: number; // 0-100
recent_emotions: EmotionEvent[];
};
// Erinnerungen
memories: {
core_memories: Memory[]; // Prägende Erlebnisse
recent_events: Memory[]; // Letzte Interaktionen
relationships_history: Map<string, RelationshipEvent[]>;
};
}
interface PersonalityTrait {
name: string;
value: number; // -100 to 100
manifestations: string[];
}
interface Skill {
name: string;
level: 'novice' | 'intermediate' | 'expert' | 'master';
experience_points: number;
}
interface Memory {
id: string;
timestamp: Date;
importance: number; // 1-10
emotional_impact: number; // -10 to 10
description: string;
participants: string[]; // slugs
location?: string; // slug
tags: string[];
}
```
## 🧠 Kernfunktionalitäten
### 1. Situations-Reaktion ("Was würde X tun?")
```typescript
interface SituationResponse {
situation: {
description: string;
location?: string;
participants: string[];
mood: 'tense' | 'relaxed' | 'urgent' | 'mysterious' | 'romantic';
stakes: 'low' | 'medium' | 'high' | 'life-death';
};
analysis: {
character_motivation: string;
relevant_memories: Memory[];
emotional_response: Emotion;
stress_impact: number;
};
possible_actions: Action[];
recommended_action: Action;
confidence: number; // 0-100
explanation: string; // Warum würde der Charakter so handeln
}
interface Action {
type: 'speak' | 'act' | 'think' | 'react' | 'leave';
description: string;
dialogue?: string;
internal_monologue?: string;
consequences: string[];
personality_alignment: number; // Wie gut passt das zum Charakter
}
```
### 2. Dialog-Generator
```typescript
interface DialogueGeneration {
context: {
speaker: string; // character slug
listeners: string[]; // character slugs
previous_lines: DialogueLine[];
scene_description: string;
emotional_context: string;
};
options: DialogueOption[];
}
interface DialogueOption {
text: string;
tone: 'aggressive' | 'friendly' | 'neutral' | 'sarcastic' | 'fearful' | 'flirty';
subtext: string; // Was wirklich gemeint ist
personality_fit: number; // 0-100
relationship_impact: Map<string, number>; // Wie es Beziehungen beeinflusst
reveals_information: string[]; // Welche Infos preisgegeben werden
triggers: string[]; // Mögliche Reaktionen anderer
}
```
### 3. Beziehungs-Dynamik
```typescript
interface RelationshipDynamics {
character_a: string;
character_b: string;
current_state: {
trust: number; // -100 to 100
affection: number; // -100 to 100
respect: number; // -100 to 100
tension: number; // 0 to 100
};
history: RelationshipEvent[];
predicted_interactions: {
likely_conflicts: ConflictScenario[];
bonding_opportunities: BondingScenario[];
power_dynamics: 'a_dominant' | 'b_dominant' | 'equal' | 'shifting';
};
conversation_starters: string[];
tension_points: string[];
common_ground: string[];
}
```
### 4. Gruppen-Dynamik
```typescript
interface GroupDynamics {
participants: string[]; // character slugs
analysis: {
leader: string | null;
alliances: Alliance[];
outsiders: string[];
mediators: string[];
instigators: string[];
};
group_mood: Mood;
conflict_potential: number; // 0-100
cohesion: number; // 0-100
likely_scenarios: GroupScenario[];
}
interface Alliance {
members: string[];
strength: number; // 0-100
basis: 'friendship' | 'mutual_benefit' | 'against_common_enemy' | 'shared_values';
}
```
## 🎨 User Interface Design
### Hauptansicht - Autopilot Control Panel
```
┌──────────────────────────────────────────────────────────┐
│ Character Autopilot - Mira Schatten │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─── Aktuelle Szene ──────────────────────────────┐ │
│ │ 📍 Neo Station - Untere Ebenen │ │
│ │ 👥 Anwesend: Timo, Kira, Wächter #3 │ │
│ │ 🎭 Stimmung: Angespannt │ │
│ │ ⚡ Einsatz: Mittel │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─── Miras Zustand ───────────────────────────────┐ │
│ │ 😊 Stimmung: Misstrauisch │ │
│ │ 🔋 Energie: ████████░░ 78% │ │
│ │ 😰 Stress: ██████░░░░ 62% │ │
│ │ 💭 Denkt an: "Fluchtweg planen" │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─── Autopilot Vorschläge ────────────────────────┐ │
│ │ │ │
│ │ 🗣️ Dialog-Optionen: │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ "Wir sollten uns aufteilen. Ich nehme │ │ │
│ │ │ den linken Gang." │ │ │
│ │ │ 😈 Täuschung (85% Fit) │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ "Timo, du kennst dich hier aus. │ │ │
│ │ │ Was schlägst du vor?" │ │ │
│ │ │ 🤝 Kooperativ (65% Fit) │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ │ │ │
│ │ 🎬 Handlungs-Optionen: │ │
│ │ • Unauffällig Ausgang scannen (92% Fit) │ │
│ │ • Waffe ziehen (15% Fit) │ │
│ │ • Nervös mit Amulett spielen (78% Fit) │ │
│ │ │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ [🎲 Zufällig] [✏️ Anpassen] [✅ Übernehmen] │
│ │
└──────────────────────────────────────────────────────────┘
```
### Beziehungs-Explorer
```
┌──────────────────────────────────────────────────────────┐
│ Beziehungs-Dynamik: Mira ↔️ Timo │
├──────────────────────────────────────────────────────────┤
│ │
│ Vertrauen: ██████░░░░░░░░░░ 35% │
│ Zuneigung: ████████████░░░░ 72% │
│ Respekt: ████████░░░░░░░░ 48% │
│ Spannung: ██████████████░░ 86% │
│ │
│ 📊 Verlauf (letzte 5 Interaktionen): │
│ ┌─────────────────────────────────────────────┐ │
│ │ 100 ┤ │ │
│ │ 75 ┤ ╱╲ ╱╲ │ │
│ │ 50 ┤ ╱╲ ╲ │ │
│ │ 25 ┤ ✕ ╲╱ ╲ ╱╲ │ │
│ │ 0 └────────────────────────── │ │
│ │ -5 -4 -3 -2 -1 Jetzt │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ 🔥 Konfliktpotential: │
│ • Mira verbirgt Information über das Amulett │
│ • Timo spürt, dass sie etwas verheimlicht │
│ • Unterschiedliche Loyalitäten zur Gilde │
│ │
│ 💚 Gemeinsame Basis: │
│ • Beide wollen Kira beschützen │
│ • Geteilte Vergangenheit in den Slums │
│ • Misstrauen gegenüber der Oberwelt │
│ │
│ 💬 Vorgeschlagene Gesprächsthemen: │
│ • "Erinnerst du dich an unseren ersten Auftrag?" │
│ • "Was hältst du wirklich von Kiras Plan?" │
│ • "Die Gilde wird langsam misstrauisch..." │
│ │
└──────────────────────────────────────────────────────────┘
```
### Gruppen-Simulator
```
┌──────────────────────────────────────────────────────────┐
│ Gruppen-Dynamik Simulator │
├──────────────────────────────────────────────────────────┤
│ │
│ Teilnehmer: [Mira] [Timo] [Kira] [Viktor] [+] │
│ │
│ ┌─── Soziale Struktur ─────────────────────────┐ │
│ │ Kira │ │
│ │ ↙️ ↘️ │ │
│ │ Mira ←→ Timo │ │
│ │ ↘️ ↙️ │ │
│ │ Viktor │ │
│ │ │ │
│ │ 👑 Anführer: Kira (Charisma) │ │
│ │ 🤝 Allianz: Mira-Timo (72%) │ │
│ │ 😤 Außenseiter: Viktor │ │
│ │ 🕊️ Vermittler: Timo │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Szenarien-Vorhersage: │
│ │
│ Bei Bedrohung (85% Wahrscheinlichkeit): │
│ • Kira übernimmt Kommando │
│ • Mira und Timo arbeiten zusammen │
│ • Viktor handelt eigenmächtig │
│ │
│ Bei Beutverteilung (73% Wahrscheinlichkeit): │
│ • Konflikt zwischen Viktor und Rest │
│ • Timo versucht zu vermitteln │
│ • Mira unterstützt Kiras Entscheidung │
│ │
│ [🎬 Szene simulieren] [📊 Details] [💾 Speichern] │
│ │
└──────────────────────────────────────────────────────────┘
```
## 🤖 KI-Integration
### Prompt Engineering Framework
```typescript
class AutopilotPromptBuilder {
buildCharacterPrompt(character: CharacterNode): string {
return `
# Character Profile: ${character.title}
## Core Identity
${character.content.appearance}
${character.content.voice_style}
## Personality Traits
${this.formatPersonalityTraits(character.autopilot.personality.traits)}
## Values & Beliefs
Values: ${character.autopilot.personality.values.join(', ')}
Fears: ${character.autopilot.personality.fears.join(', ')}
Desires: ${character.autopilot.personality.desires.join(', ')}
## Behavioral Patterns
- Stress Response: ${character.autopilot.behavior_patterns.stress_response}
- Decision Style: ${character.autopilot.behavior_patterns.decision_style}
- Social Style: ${character.autopilot.behavior_patterns.social_style}
- Conflict Style: ${character.autopilot.behavior_patterns.conflict_style}
## Current State
Mood: ${character.autopilot.emotional_state.current_mood}
Stress: ${character.autopilot.emotional_state.stress_level}%
Energy: ${character.autopilot.emotional_state.energy_level}%
## Recent Memories
${this.formatRecentMemories(character.autopilot.memories.recent_events)}
## Knowledge & Beliefs
Known: ${character.autopilot.knowledge.known_facts.join('; ')}
False Beliefs: ${character.autopilot.knowledge.false_beliefs.join('; ')}
IMPORTANT: Stay completely in character. React based on what THIS character knows and believes, not on omniscient knowledge.
`;
}
buildSituationPrompt(situation: Situation, character: CharacterNode): string {
return `
${this.buildCharacterPrompt(character)}
# Current Situation
Location: ${situation.location}
Present: ${situation.participants.join(', ')}
Atmosphere: ${situation.mood}
Stakes: ${situation.stakes}
Description: ${situation.description}
# Task
Based on this character's personality, current emotional state, and knowledge:
1. How would they emotionally react to this situation?
2. What would they most likely do or say?
3. What are they thinking but not saying?
4. How does this relate to their past experiences?
Provide 3 different but plausible responses, ranked by likelihood.
Format as JSON with structure: {responses: [{action, dialogue, internal_thought, likelihood_score, reasoning}]}
`;
}
}
```
### Multi-Model Support
```typescript
interface AIProvider {
generateResponse(prompt: string, config: AIConfig): Promise<string>;
streamResponse(prompt: string, config: AIConfig): AsyncGenerator<string>;
}
class AutopilotAIManager {
providers: Map<string, AIProvider> = new Map([
['openai-gpt4', new OpenAIProvider('gpt-4-turbo')],
['claude-3', new AnthropicProvider('claude-3-opus')],
['local-llama', new LocalProvider('llama-3-70b')]
]);
async generateCharacterResponse(
character: CharacterNode,
situation: Situation,
provider: string = 'openai-gpt4'
): Promise<CharacterResponse> {
const prompt = this.promptBuilder.buildSituationPrompt(situation, character);
const aiProvider = this.providers.get(provider);
const response = await aiProvider.generateResponse(prompt, {
temperature: this.getTemperatureForCharacter(character),
max_tokens: 1000,
response_format: { type: 'json_object' }
});
return this.parseAndValidateResponse(response, character);
}
getTemperatureForCharacter(character: CharacterNode): number {
// Impulsive characters get higher temperature
const impulsiveness =
character.autopilot.behavior_patterns.decision_style === 'impulsive' ? 0.3 : 0;
const creativity =
character.autopilot.personality.traits.find((t) => t.name === 'openness')?.value / 200 || 0;
return 0.5 + impulsiveness + creativity; // Range: 0.5 - 1.0
}
}
```
## 📈 Lern- und Anpassungssystem
### Feedback Loop
```typescript
interface CharacterLearning {
recordInteraction(interaction: Interaction): void {
// Speichere als Erinnerung
this.addMemory(interaction)
// Update Beziehungen
this.updateRelationships(interaction)
// Lerne neue Fakten
this.updateKnowledge(interaction)
// Passe Persönlichkeit minimal an (Character Development)
this.evolvePersonality(interaction)
}
evolvePersonality(interaction: Interaction): void {
// Traumatische Ereignisse können Persönlichkeit ändern
if (interaction.trauma_level > 8) {
this.adjustTrait('neuroticism', +10)
this.adjustTrait('trust', -15)
}
// Positive Erfahrungen stärken Selbstvertrauen
if (interaction.success_level > 8) {
this.adjustTrait('confidence', +5)
this.adjustTrait('openness', +3)
}
}
}
```
### Konsistenz-Tracking
```typescript
class ConsistencyValidator {
validateAction(character: CharacterNode, proposedAction: Action): ValidationResult {
const inconsistencies: Inconsistency[] = [];
// Prüfe gegen Persönlichkeit
if (this.isOutOfCharacter(character, proposedAction)) {
inconsistencies.push({
type: 'personality',
severity: 'high',
description: 'Action conflicts with established personality traits'
});
}
// Prüfe gegen Wissen
if (this.usesUnknownInformation(character, proposedAction)) {
inconsistencies.push({
type: 'knowledge',
severity: 'critical',
description: "Character acts on information they shouldn't know"
});
}
// Prüfe gegen physische Fähigkeiten
if (this.exceedsCapabilities(character, proposedAction)) {
inconsistencies.push({
type: 'capability',
severity: 'high',
description: "Action exceeds character's physical/mental capabilities"
});
}
return {
isValid: inconsistencies.length === 0,
inconsistencies,
confidenceScore: this.calculateConfidence(inconsistencies)
};
}
}
```
## 🔧 Technische Implementation
### API Endpoints
```typescript
// Character Autopilot API Routes
// Generate character response
POST /api/autopilot/respond
{
character_slug: string
situation: Situation
options?: {
provider?: string
creativity?: number
include_alternatives?: boolean
}
}
// Simulate dialogue between characters
POST /api/autopilot/dialogue
{
participants: string[]
context: DialogueContext
turns: number
}
// Predict character behavior
POST /api/autopilot/predict
{
character_slug: string
scenario: Scenario
time_frame: 'immediate' | 'short_term' | 'long_term'
}
// Analyze group dynamics
POST /api/autopilot/group-dynamics
{
characters: string[]
situation: Situation
}
// Train character from examples
POST /api/autopilot/train
{
character_slug: string
examples: InteractionExample[]
}
```
### Datenbank-Schema Erweiterungen
```sql
-- Autopilot-spezifische Tabellen
CREATE TABLE character_autopilot_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES content_nodes(id),
personality_data JSONB NOT NULL,
behavior_patterns JSONB NOT NULL,
knowledge_base JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE character_memories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_id UUID REFERENCES content_nodes(id),
memory_type TEXT CHECK (memory_type IN ('core', 'recent', 'learned')),
importance INTEGER CHECK (importance BETWEEN 1 AND 10),
emotional_impact INTEGER CHECK (emotional_impact BETWEEN -10 AND 10),
content JSONB NOT NULL,
occurred_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE character_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
character_a_id UUID REFERENCES content_nodes(id),
character_b_id UUID REFERENCES content_nodes(id),
trust_level INTEGER CHECK (trust_level BETWEEN -100 AND 100),
affection_level INTEGER CHECK (affection_level BETWEEN -100 AND 100),
respect_level INTEGER CHECK (respect_level BETWEEN -100 AND 100),
tension_level INTEGER CHECK (tension_level BETWEEN 0 AND 100),
history JSONB DEFAULT '[]'::jsonb,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(character_a_id, character_b_id)
);
CREATE TABLE autopilot_interactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID,
character_id UUID REFERENCES content_nodes(id),
situation JSONB NOT NULL,
generated_response JSONB NOT NULL,
selected_response JSONB,
user_feedback JSONB,
consistency_score DECIMAL(3,2),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indizes für Performance
CREATE INDEX idx_memories_character ON character_memories(character_id);
CREATE INDEX idx_memories_importance ON character_memories(importance DESC);
CREATE INDEX idx_relationships_characters ON character_relationships(character_a_id, character_b_id);
CREATE INDEX idx_interactions_session ON autopilot_interactions(session_id);
CREATE INDEX idx_interactions_character ON autopilot_interactions(character_id);
```
### React Components
```typescript
// CharacterAutopilot.svelte
<script lang="ts">
import { AutopilotEngine } from '$lib/autopilot/engine'
import { CharacterCard } from '$lib/components/CharacterCard.svelte'
import { ResponseOptions } from '$lib/components/ResponseOptions.svelte'
import { EmotionalState } from '$lib/components/EmotionalState.svelte'
export let character: CharacterNode
export let situation: Situation
let engine = new AutopilotEngine(character)
let responses = $state<Response[]>([])
let loading = $state(false)
let selectedResponse = $state<Response | null>(null)
async function generateResponses() {
loading = true
try {
responses = await engine.generateResponses(situation)
} finally {
loading = false
}
}
function applyResponse(response: Response) {
selectedResponse = response
// Update story with selected response
dispatch('apply-response', response)
}
</script>
<div class="autopilot-container">
<CharacterCard {character} />
<EmotionalState
state={character.autopilot.emotional_state}
on:update={updateEmotionalState}
/>
{#if loading}
<div class="loading-spinner">
Analysiere Charakterverhalten...
</div>
{:else if responses.length > 0}
<ResponseOptions
{responses}
on:select={applyResponse}
on:regenerate={generateResponses}
/>
{:else}
<button on:click={generateResponses}>
Autopilot aktivieren
</button>
{/if}
{#if selectedResponse}
<div class="applied-response">
<h4>Angewendet:</h4>
<p>{selectedResponse.action}</p>
{#if selectedResponse.dialogue}
<blockquote>{selectedResponse.dialogue}</blockquote>
{/if}
</div>
{/if}
</div>
```
## 🎮 Erweiterte Features
### 1. Autopilot Battles
Lasse zwei Charaktere in verschiedenen Szenarien gegeneinander antreten:
- Verbale Duelle
- Strategische Planungen
- Verhandlungen
- Überlebenssituationen
### 2. Character Evolution Trees
Zeige mögliche Entwicklungspfade basierend auf Entscheidungen:
- Persönlichkeitsveränderungen
- Skill-Entwicklung
- Beziehungsverläufe
- Moralische Ausrichtung
### 3. Ensemble Casts
Simuliere komplexe Gruppen-Interaktionen:
- Dinner-Party Simulator
- Ratssitzungen
- Teambildung unter Stress
- Meuterei-Szenarien
### 4. Emotional Contagion
Modelliere wie Emotionen sich in Gruppen ausbreiten:
- Panik in Menschenmengen
- Inspirationsreden
- Mob-Mentalität
- Gruppendepression
## 📊 Metriken & Analytics
### Performance KPIs
- **Response Time**: < 2 Sekunden für Einzelcharakter
- **Consistency Score**: > 85% für generierte Aktionen
- **User Acceptance Rate**: > 70% der Vorschläge übernommen
- **Character Depth Score**: Anzahl genutzter Persönlichkeitsaspekte
### Quality Metrics
- **Dialogue Naturalness**: NLP-basierte Bewertung
- **Action Plausibility**: User-Feedback Score
- **Character Growth**: Persönlichkeitsentwicklung über Zeit
- **Relationship Complexity**: Anzahl und Tiefe der Beziehungsdynamiken
## 🚀 Rollout-Plan
### Phase 1: Foundation (Woche 1-2)
- [ ] Datenmodell-Erweiterungen
- [ ] Basis-UI Components
- [ ] Einfache Prompt-Templates
- [ ] OpenAI Integration
### Phase 2: Core Features (Woche 3-4)
- [ ] Situations-Response Generator
- [ ] Dialog-Generator
- [ ] Beziehungs-Tracking
- [ ] Konsistenz-Validator
### Phase 3: Advanced (Woche 5-6)
- [ ] Gruppen-Dynamik
- [ ] Lern-System
- [ ] Multi-Model Support
- [ ] Emotional Contagion
### Phase 4: Polish (Woche 7-8)
- [ ] Performance-Optimierung
- [ ] UI/UX Verfeinerung
- [ ] Analytics Dashboard
- [ ] Dokumentation
## 💰 Monetarisierung
### Pricing Tiers
- **Basic**: 100 Autopilot-Aktionen/Monat
- **Pro**: 1000 Aktionen/Monat + erweiterte Modelle
- **Studio**: Unbegrenzt + Custom Training + API Access
### Premium Features
- GPT-4 / Claude-3 Modelle
- Custom Character Training
- Batch-Simulation
- Export für Game Engines
## 🔒 Datenschutz & Ethik
### Schutzmaßnahmen
- Keine Generierung von schädlichen Inhalten
- Altersgerechte Inhaltsfilter
- Opt-in für Charakterdaten-Training
- Transparenz über KI-Nutzung
### Ethische Richtlinien
- Respektvolle Darstellung von Minderheiten
- Keine Verstärkung von Stereotypen
- Trigger-Warnungen für sensible Themen
- User-Kontrolle über Charakterverhalten
## 📝 Zusammenfassung
Character Autopilot transformiert statische Charakterbeschreibungen in lebendige, autonome Persönlichkeiten. Durch die Kombination von fortschrittlicher KI, psychologischen Modellen und narrativen Strukturen entsteht ein System, das Autoren dabei unterstützt, konsistente und überzeugende Charakterinteraktionen zu erschaffen.
Die modulare Architektur ermöglicht schrittweise Verbesserungen und Anpassungen basierend auf User-Feedback. Mit dem Fokus auf Konsistenz, Kreativität und Kontrolle wird Character Autopilot zum unverzichtbaren Werkzeug für jeden ernsthaften Weltenbauer und Geschichtenerzähler.

View file

@ -0,0 +1,338 @@
# Worldream Feature Ideas
## 🌟 Kreative Neue Features für Worldream
Nach Analyse des bestehenden Projekts habe ich folgende innovative Features entwickelt, die Worldream zu einer einzigartigen Plattform für Weltenbau und Storytelling machen würden:
## 1. 🎭 Interaktive Story-Simulation
### **Character Autopilot**
- KI-gesteuerte Charaktere können basierend auf ihren definierten Eigenschaften automatisch in Szenen interagieren
- "Was würde X in dieser Situation tun?" - Generator
- Dialogvorschläge basierend auf Voice Style und Beziehungen
- Konfliktpotential-Analyse zwischen Charakteren
### **Timeline Branching**
- Alternative Zeitlinien für "Was wäre wenn"-Szenarien
- Parallele Story-Verläufe mit gemeinsamen Ausgangspunkten
- Merge-Konflikte visualisieren wenn Timelines zusammengeführt werden
- Canon vs. Non-Canon Markierungen
## 2. 🗺️ Weltenkarten & Visualisierung
### **Relationship Graph Explorer**
- Interaktive 3D-Visualisierung aller Beziehungen zwischen Entities
- Force-directed Graph mit Filteroptionen (Familie, Feinde, Allianzen)
- Zeitreise-Slider um Beziehungen über Zeit zu sehen
- Automatische Clustererkennung für Fraktionen/Gruppen
### **Smart Map Generator**
- Automatische Kartengenerierung basierend auf Place-Beschreibungen
- Relative Positionierung durch Textanalyse ("nördlich von", "3 Tage Reise")
- Reiserouten zwischen Orten mit Zeitschätzungen
- Territorien und Einflussbereiche visualisieren
## 3. 🎲 Gamification & Interaktion
### **Quest Designer**
- Drag & Drop Quest-Builder mit Bedingungen und Belohnungen
- Automatische Quest-Generierung basierend auf World State
- Verzweigende Questlinien mit mehreren Lösungswegen
- NPC-Reaktionen basierend auf Quest-Fortschritt
### **World State Engine**
- Globale Variablen die sich durch Stories ändern
- Trigger-System für weltverändernde Events
- Reputation-System zwischen Fraktionen
- Wirtschaftssimulation für Ressourcen und Handel
## 4. 🤖 Erweiterte KI-Features
### **Style Mimicry**
- Lerne den Schreibstil aus hochgeladenen Texten
- Konsistenz-Check für neue Inhalte gegen etablierten Stil
- Automatische Stil-Anpassung von generierten Texten
- Multi-Author Support mit verschiedenen Schreibstilen
### **Deep Lore Generator**
- Automatische Geschichtsgenerierung für Jahrhunderte
- Dynastien und Herrscherfolgen
- Kulturelle Evolution über Zeit
- Sprachen und Dialekte mit Beispielvokabular
### **Contradiction Detector**
- Echtzeit-Scanning für logische Widersprüche
- Timeline-Konflikte identifizieren
- Physikalische Unmöglichkeiten markieren
- Charakterverhalten-Inkonsistenzen aufzeigen
## 5. 🎨 Content Enhancement
### **Mood Board Integration**
- Pinterest-ähnliche Boards für visuelle Inspiration
- Automatische Farb- und Stilpaletten-Extraktion
- Bildanalyse für Appearance-Beschreibungen
- Concept Art Galerie pro Entity
### **Soundscapes**
- Ambiente Sound-Generierung für Places
- Charakterthemen und Leitmotive
- Dynamische Musik basierend auf Story-Stimmung
- Text-to-Speech mit charakterspezifischen Stimmen
### **AR Preview**
- 3D-Modelle von Objects für AR-Ansicht
- Größenvergleiche in realer Umgebung
- Virtuelle Museumstouren durch die Welt
- Holographische Charakterprojektionen
## 6. 📚 Kollaboration & Community
### **World Marketplace**
- Teile und verkaufe Welten-Templates
- Character Archetypes Library
- Community-Challenges und Wettbewerbe
- Remix-Feature für bestehende Welten
### **Live Collaboration**
- Multiplayer-Editing in Echtzeit
- Rollenspielsessions direkt in der App
- DM-Tools für Pen&Paper-Integration
- Voice/Video-Chat für Story-Sessions
### **Canon Council**
- Community-Voting für Canon-Entscheidungen
- Lore-Komitees für große Welten
- Version Control mit Pull Requests für Stories
- Peer Review System für Qualitätssicherung
## 7. 🔮 Analyse & Insights
### **Story Analytics**
- Lesbarkeits-Scores und Komplexitätsanalyse
- Pacing-Visualisierung (Action vs. Ruhe)
- Emotionale Kurven über Story-Verlauf
- Charakterentwicklungs-Tracking
### **World Health Dashboard**
- Vollständigkeits-Metriken für Entities
- Vernetzungsgrad-Analyse
- Content-Freshness Indicators
- Beliebtheits-Rankings für Charaktere/Orte
### **Reader Journey Tracking**
- Heatmaps für meistgelesene Passagen
- Absprungpunkte identifizieren
- A/B Testing für alternative Szenen
- Feedback-Integration direkt im Text
## 8. 🚀 Export & Integration
### **Professional Exports**
- Automatische eBook-Generierung (EPUB, MOBI)
- Drehbuch-Formatierung für Film/TV
- Wiki-Export für Fandom-Sites
- Game Design Documents
### **API & Webhooks**
- REST API für externe Tools
- Discord/Slack Integration für Updates
- Git-Sync für Versionskontrolle
- Notion/Obsidian Sync
### **Game Engine Plugins**
- Unity/Unreal Asset Pipeline
- Dialog-Export für RPG Maker
- Quest-Daten für Game Engines
- NPC-Behavior Scripts
## 9. 🧠 Intelligente Assistenten
### **World Consistency Advisor**
- Proaktive Vorschläge für fehlende Details
- Logiklücken-Identifikation
- Kulturelle Plausibilitätschecks
- Technologie-Level Konsistenz
### **Story Architect**
- Three-Act Structure Analyzer
- Hero's Journey Mapping
- Subplot Weaving Assistant
- Climax Intensity Optimizer
### **Character Psychologist**
- Persönlichkeitsprofile (MBTI, Big Five)
- Trauma-Impact Modeling
- Beziehungsdynamik-Vorhersagen
- Charakterentwicklungs-Roadmaps
## 10. 🎯 Spezialisierte Modi
### **Educational Mode**
- Historische Welten mit Faktenchecks
- Wissenschaftliche Akkuratheit-Layer
- Lernziele und Quizze einbauen
- Lehrmaterial-Export
### **Therapeutic Storytelling**
- Guided Imagery Scenarios
- Trauma-Processing Narratives
- Positive Psychology Elements
- Mood Tracking Integration
### **Business Worldbuilding**
- Unternehmens-Narrative entwickeln
- Brand Story Frameworks
- Scenario Planning Tools
- Stakeholder Journey Maps
## 11. 🌐 Metaverse & Web3
### **NFT Collections**
- Charaktere als sammelbare NFTs
- Limitierte Story-Editionen
- World Ownership Tokens
- Creator Royalties System
### **Decentralized Worlds**
- IPFS Storage für Permanenz
- DAO-Governance für große Welten
- Smart Contracts für Story-Rechte
- Cross-Platform Avatare
## 12. 🔧 Entwickler-Features
### **Custom Entity Types**
- Eigene Datenstrukturen definieren
- Benutzerdefinierte Felder und Validierung
- Template-System für neue Arten
- Migration Tools für Schema-Updates
### **Workflow Automation**
- Zapier/Make.com Integration
- Custom Trigger und Actions
- Batch-Operations für Bulk-Updates
- Scheduled Content Publishing
### **Plugin Architecture**
- Community Plugins Marketplace
- JavaScript/TypeScript SDK
- Custom UI Components
- Server-Side Extensions
## Implementation Priority
### Phase 1 (Quick Wins) 🏃
1. Contradiction Detector
2. Relationship Graph Explorer
3. Story Analytics
4. World Health Dashboard
### Phase 2 (Core Enhancements) 🎯
1. Character Autopilot
2. Timeline Branching
3. Quest Designer
4. Style Mimicry
### Phase 3 (Major Features) 🚀
1. Live Collaboration
2. Smart Map Generator
3. World State Engine
4. Professional Exports
### Phase 4 (Advanced) 🌟
1. AR Preview
2. Soundscapes
3. Game Engine Plugins
4. NFT Collections
## Technische Überlegungen
### Performance
- Lazy Loading für große Welten
- Edge Caching für öffentliche Inhalte
- WebAssembly für komplexe Berechnungen
- Service Worker für Offline-Support
### Skalierbarkeit
- Microservices für KI-Features
- GraphQL für flexible Queries
- Event Sourcing für Timeline-Features
- CDN für Media-Assets
### Datenschutz
- Ende-zu-Ende Verschlüsselung für private Welten
- GDPR-konforme Datenverarbeitung
- Anonyme Kollaboration Option
- Zero-Knowledge Backups
## Monetarisierung
### Freemium Model
- **Free**: 1 Welt, 50 Entities, Basic AI
- **Pro**: Unbegrenzte Welten, Advanced AI, Exports
- **Team**: Kollaboration, API Access, Priority Support
- **Enterprise**: On-Premise, Custom Features, SLA
### Zusätzliche Revenue Streams
- Marketplace Provisionen (10-30%)
- Premium AI Models (GPT-4, Claude)
- Storage Upgrades
- Branded/Whitelabel Versionen
## Erfolgsmetriken
- **User Engagement**: Tägliche aktive Weltenbauer
- **Content Creation**: Entities pro User pro Monat
- **Collaboration**: Durchschnittliche Team-Größe
- **Retention**: 30-Tage Retention Rate
- **Monetization**: Conversion Rate Free → Pro
- **Community**: User-generated Plugins/Templates
- **Quality**: Durchschnittliche Story-Bewertung
## Fazit
Diese Features würden Worldream von einer einfachen Text-Verwaltung zu einer revolutionären Plattform für kreatives Storytelling transformieren. Die Kombination aus KI-Unterstützung, visuellen Tools, Gamification und Community-Features schafft ein einzigartiges Ökosystem für Weltenbauer aller Art - von Hobby-Autoren über Game Designer bis zu professionellen Drehbuchschreibern.
Die modulare Architektur erlaubt schrittweise Implementation, wobei jedes Feature für sich Mehrwert bietet, aber zusammen ein kraftvolles Gesamtsystem bildet.

View file

@ -0,0 +1,150 @@
# GPT-5-mini Dokumentation
## Übersicht
GPT-5-mini ist eines der drei GPT-5 Modelle von OpenAI (neben GPT-5 und GPT-5-nano). Es bietet einen optimalen Kompromiss zwischen Leistung und Kosten.
## Verfügbarkeit
- **API**: Verfügbar über OpenAI API
- **Rollout**: Verfügbar für alle API-Nutzer
- **Azure**: Verfügbar ohne Registrierung (im Gegensatz zu GPT-5 standard)
## Modell-Spezifikationen
### Preise
- **Input**: $0.25 pro 1M Tokens
- **Output**: $2.00 pro 1M Tokens
- (Zum Vergleich: GPT-5 standard kostet $1.25/$10, GPT-5-nano kostet $0.05/$0.40)
### Knowledge Cutoff
- **GPT-5-mini**: Mai 30, 2024
- **GPT-5 standard**: September 30, 2024
### Unterstützte Features
- ✅ Chat Completions API
- ✅ Response Format (JSON mode)
- ✅ Streaming
- ✅ Custom Tools
- ✅ `reasoning_effort` Parameter
- ✅ `verbosity` Parameter
- ✅ Vision Capabilities (Bildanalyse)
## ⚠️ WICHTIGE EINSCHRÄNKUNGEN
### Temperature
- **NUR temperature: 1.0 wird unterstützt!**
- Andere Werte (0.7, 0.8, etc.) führen zu einem 400 Error
- Der Parameter kann weggelassen werden (1.0 ist default)
### Token Limits
- Verwendet `max_completion_tokens` NICHT `max_tokens`
- `max_tokens` führt zu einem 400 Error
## Verwendung in Worldream
### Standard-Generierung
```typescript
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
// temperature: 1 ist default - KEINE anderen Werte möglich!
response_format: { type: 'json_object' },
max_completion_tokens: 1000 // NICHT max_tokens!
});
```
### Mit Streaming
```typescript
const stream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [...],
stream: true,
max_completion_tokens: 1000 // WICHTIG: max_completion_tokens statt max_tokens!
})
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || ''
// Process chunk
}
```
## Optimierungen für Worldream
### 1. Zweistufige Generierung für Welten
- **Stufe 1**: Basis-Info (title, summary, appearance, lore)
- **Stufe 2**: Details (glossary, timeline, canon facts)
- Reduziert die Wartezeit erheblich
### 2. Temperature
- **NUR 1.0**: Einziger unterstützter Wert für GPT-5-mini
- Keine Anpassung möglich - immer maximale Kreativität
- Parameter kann weggelassen werden
### 3. Max Completion Tokens Limits
- **Parameter**: `max_completion_tokens` (NICHT `max_tokens`!)
- **Basis-Generierung**: 1000 tokens
- **Detail-Generierung**: 800 tokens
- Verhindert zu lange Wartezeiten
### 4. Streaming für bessere UX
- Nutzer sieht sofort Fortschritt
- Besseres Feedback während Generierung
- Strukturiertes Text-Format statt JSON für Streaming
## Best Practices
1. **API-Parameter korrekt setzen**
- Temperature weglassen (default 1.0)
- `max_completion_tokens` statt `max_tokens`
- Keine unsupported Parameter verwenden
2. **Kurze, präzise System-Prompts**
- Weniger ist mehr
- Klare Struktur vorgeben
3. **Strukturierte Ausgabe**
- JSON für finale Daten
- Strukturierter Text für Streaming
4. **Kontext-Management**
- Nur relevante Informationen übergeben
- Welt-Kontext bei Bedarf einbeziehen
5. **Error Handling**
- Fallback bei Parse-Fehlern
- Retry-Logic bei API-Fehlern
- 400 Errors bei falschen Parametern abfangen
## Vergleich zu anderen Modellen
| Feature | GPT-5-nano | GPT-5-mini | GPT-5 |
| --------------- | -------------- | ---------------- | ---------------- |
| Preis Input | $0.05/1M | $0.25/1M | $1.25/1M |
| Preis Output | $0.40/1M | $2.00/1M | $10.00/1M |
| Geschwindigkeit | ⚡⚡⚡ | ⚡⚡ | ⚡ |
| Qualität | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| Empfohlen für | Einfache Tasks | Standard Content | Premium Features |
## Worldream Empfehlung
GPT-5-mini ist optimal für Worldream:
- Gute Balance zwischen Kosten und Qualität
- Schnell genug für interaktive Nutzung
- Ausreichend kreativ für Worldbuilding
- Unterstützt alle benötigten Features

View file

@ -0,0 +1,576 @@
# Memory & Skills System - Implementierungsplan
## Übersicht
Ein dreistufiges Gedächtnissystem für Charaktere in Worldream, das realistische Erinnerungsmechaniken mit Story-Integration verbindet.
## 1. Datenbankschema
### 1.1 Neue Felder in `content_nodes` (JSONB content)
```sql
-- Für Charaktere wird das content JSONB erweitert:
{
-- Existing fields...
-- Memory System
"short_term_memory": [
{
"id": "uuid",
"timestamp": "2024-01-15T10:30:00Z",
"content": "Text der Erinnerung",
"location": "@ort_slug",
"involved": ["@character_slug"],
"tags": ["#emotion:surprised", "#information"],
"importance": 3,
"decay_at": "2024-01-18T10:30:00Z"
}
],
"medium_term_memory": [
{
"id": "uuid",
"timestamp": "2024-01-01T00:00:00Z",
"content": "Komprimierte Erinnerung",
"original_details": "Längere Version...",
"context": "Warum war das wichtig",
"location": "@ort_slug",
"involved": ["@character_slug"],
"tags": ["#relationship", "#learned"],
"importance": 6,
"decay_at": "2024-04-01T00:00:00Z",
"linked_memories": ["memory_id_1", "memory_id_2"]
}
],
"long_term_memory": [
{
"id": "uuid",
"timestamp": "2020-01-01T00:00:00Z",
"content": "Kernhafte Erinnerung",
"emotional_weight": 9,
"category": "trauma|triumph|relationship|skill|secret",
"triggers": ["Feuer", "Schreie", "@specific_person"],
"effects": "Beschreibung der Auswirkungen",
"involved": ["@character_slug"],
"immutable": true
}
],
-- Memory Metadata
"memory_traits": {
"memory_quality": "excellent|good|average|poor",
"trauma_filter": true,
"selective_memory": ["violence", "embarrassment"],
"memory_conditions": {
"drunk": "partial_blackout",
"stressed": "detail_loss",
"happy": "enhanced_positive"
}
},
-- Skills System
"skills": {
"primary": [
{
"name": "Schwertkampf",
"level": 8,
"level_text": "Meister",
"subskills": {
"Duellieren": "Experte",
"Formationen": "Fortgeschritten"
},
"learned_from": "@waffenmeister_karl",
"learned_at": "@königliche_akademie",
"training_years": 10,
"last_used": "2024-01-10",
"conditions": {
"injured": -2,
"angry": +1
}
}
],
"learning": [
{
"name": "Magie-Grundlagen",
"progress": 15,
"teacher": "@mira",
"started": "2024-01-01",
"blocked_by": null,
"next_milestone": "Erste erfolgreiche Levitation"
}
],
"conditions": {
"Nachtsicht": {
"trigger": "darkness",
"effect": "+2 Wahrnehmung"
},
"Höhenangst": {
"trigger": "height > 10m",
"effect": "-4 Klettern, -2 Konzentration"
}
}
}
}
```
### 1.2 Neue Tabelle: `memory_events` (für Story-Integration)
```sql
CREATE TABLE memory_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
node_id UUID REFERENCES content_nodes(id) ON DELETE CASCADE,
story_id UUID REFERENCES content_nodes(id),
event_timestamp TIMESTAMPTZ NOT NULL,
event_type TEXT NOT NULL, -- 'observed', 'experienced', 'told', 'dreamed'
raw_event TEXT NOT NULL,
processed_memory JSONB,
memory_tier TEXT, -- 'short', 'medium', 'long'
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_memory_events_node ON memory_events(node_id);
CREATE INDEX idx_memory_events_story ON memory_events(story_id);
CREATE INDEX idx_memory_events_timestamp ON memory_events(event_timestamp);
```
## 2. API Endpoints
### 2.1 Memory Management
```typescript
// GET /api/nodes/[slug]/memories
// Ruft alle Erinnerungen ab, gefiltert nach Tier
interface MemoryResponse {
short_term: Memory[];
medium_term: Memory[];
long_term: Memory[];
stats: {
total_memories: number;
memory_quality: string;
oldest_memory: Date;
};
}
// POST /api/nodes/[slug]/memories
// Fügt neue Erinnerung hinzu
interface AddMemoryRequest {
content: string;
tier?: 'short' | 'medium' | 'long';
importance?: number;
tags?: string[];
involved?: string[]; // @slugs
location?: string; // @slug
emotional_weight?: number;
}
// POST /api/nodes/[slug]/memories/process
// Prozessiert Erinnerungen (Aging, Decay, Compression)
interface ProcessMemoriesRequest {
force?: boolean;
current_date?: string; // Für Story-Zeit
}
// PUT /api/nodes/[slug]/memories/[memoryId]
// Aktualisiert oder verschiebt Erinnerung
interface UpdateMemoryRequest {
move_to?: 'medium' | 'long';
content?: string;
importance?: number;
add_details?: string;
}
// DELETE /api/nodes/[slug]/memories/[memoryId]
// Löscht oder "vergisst" Erinnerung
interface ForgetMemoryRequest {
reason?: 'trauma' | 'time' | 'replaced' | 'manual';
}
```
### 2.2 Skills Management
```typescript
// GET /api/nodes/[slug]/skills
interface SkillsResponse {
primary: Skill[];
learning: LearningSkill[];
conditions: Condition[];
total_skill_points?: number;
}
// POST /api/nodes/[slug]/skills
interface AddSkillRequest {
name: string;
level?: number;
learned_from?: string; // @slug
category?: 'combat' | 'social' | 'magic' | 'craft' | 'knowledge';
}
// PUT /api/nodes/[slug]/skills/[skillName]/train
interface TrainSkillRequest {
progress?: number;
experience_gained?: string;
teacher?: string; // @slug
}
```
## 3. UI Components
### 3.1 Memory Display Component
```svelte
<!-- src/lib/components/CharacterMemory.svelte -->
<script lang="ts">
import { Tabs, TabList, Tab, TabPanel } from '$lib/components/ui/tabs';
import MemoryItem from './MemoryItem.svelte';
import MemoryTimeline from './MemoryTimeline.svelte';
export let memories: CharacterMemories;
export let editable: boolean = false;
let activeTab = 'short';
let showTimeline = false;
</script>
<div class="memory-container">
<Tabs>
<TabList>
<Tab value="short">
Kurzzeitgedächtnis ({memories.short_term.length})
</Tab>
<Tab value="medium">
Mittelzeitgedächtnis ({memories.medium_term.length})
</Tab>
<Tab value="long">
Langzeitgedächtnis ({memories.long_term.length})
</Tab>
</TabList>
<TabPanel value="short">
<!-- Zeigt die letzten 1-3 Tage -->
{#each memories.short_term as memory}
<MemoryItem {memory} {editable} tier="short" />
{/each}
</TabPanel>
<!-- etc... -->
</Tabs>
<button on:click={() => showTimeline = !showTimeline}>
Timeline-Ansicht
</button>
{#if showTimeline}
<MemoryTimeline {memories} />
{/if}
</div>
```
### 3.2 Skills Display Component
```svelte
<!-- src/lib/components/CharacterSkills.svelte -->
<script lang="ts">
import SkillTree from './SkillTree.svelte';
import SkillProgress from './SkillProgress.svelte';
export let skills: CharacterSkills;
export let editable: boolean = false;
</script>
<div class="skills-container">
<div class="primary-skills">
<h3>Hauptfähigkeiten</h3>
<SkillTree skills={skills.primary} {editable} />
</div>
<div class="learning-skills">
<h3>In Ausbildung</h3>
{#each skills.learning as skill}
<SkillProgress {skill} />
{/each}
</div>
<div class="conditions">
<h3>Konditionen & Modifikatoren</h3>
<!-- Conditions display -->
</div>
</div>
```
## 4. Memory Processing Logic
### 4.1 Automatische Verarbeitung
```typescript
// src/lib/services/memoryService.ts
export class MemoryService {
// Wird täglich oder bei Story-Events aufgerufen
async processMemories(characterSlug: string, currentDate: Date) {
const character = await getCharacter(characterSlug);
// 1. Age short-term memories
const agedShortTerm = character.short_term_memory
.filter(m => daysSince(m.timestamp, currentDate) > 3);
// 2. Compress and move to medium-term
for (const memory of agedShortTerm) {
if (memory.importance >= 3) {
const compressed = this.compressMemory(memory);
character.medium_term_memory.push(compressed);
}
// Remove from short-term
character.short_term_memory = character.short_term_memory
.filter(m => m.id !== memory.id);
}
// 3. Process medium-term memories
const agedMediumTerm = character.medium_term_memory
.filter(m => monthsSince(m.timestamp, currentDate) > 3);
// 4. Promote important memories to long-term
for (const memory of agedMediumTerm) {
if (memory.importance >= 7 || memory.tags.includes('#trauma')) {
const permanent = this.createPermanentMemory(memory);
character.long_term_memory.push(permanent);
}
// Remove from medium-term
character.medium_term_memory = character.medium_term_memory
.filter(m => m.id !== memory.id);
}
// 5. Apply memory traits (forgetting, distortion)
this.applyMemoryTraits(character);
return character;
}
compressMemory(memory: ShortTermMemory): MediumTermMemory {
// Komprimierungslogik
return {
...memory,
content: this.summarize(memory.content),
original_details: memory.content,
context: this.extractContext(memory),
decay_at: addMonths(memory.timestamp, 3)
};
}
createPermanentMemory(memory: MediumTermMemory): LongTermMemory {
return {
id: generateId(),
timestamp: memory.timestamp,
content: this.extractCore(memory),
emotional_weight: this.calculateEmotionalWeight(memory),
category: this.categorizeMemory(memory),
triggers: this.extractTriggers(memory),
effects: this.determineEffects(memory),
involved: memory.involved,
immutable: true
};
}
}
```
### 4.2 Story-Integration
```typescript
// src/lib/services/storyMemoryIntegration.ts
export class StoryMemoryIntegration {
async processStoryEvent(
storySlug: string,
eventText: string,
involvedCharacters: string[]
) {
// Parse event for memory-worthy content
const memories = this.extractMemories(eventText);
for (const characterSlug of involvedCharacters) {
const character = await getCharacter(characterSlug);
for (const memory of memories) {
// Check if character would remember this
if (this.wouldRemember(character, memory)) {
// Add to appropriate tier based on importance
const tier = this.determineMemoryTier(memory);
await this.addMemoryToCharacter(character, memory, tier);
}
}
}
}
extractMemories(text: string): ExtractedMemory[] {
// Use AI to extract memory-worthy events
const prompt = `
Extrahiere erinnerungswürdige Ereignisse aus diesem Text.
Kategorisiere nach Wichtigkeit (1-10).
Identifiziere emotionale Gewichtung.
Erkenne beteiligte Charaktere (@mentions).
`;
return aiExtract(text, prompt);
}
}
```
## 5. AI Integration
### 5.1 Memory-Aware Generation
```typescript
// src/lib/ai/memoryAwareGeneration.ts
export async function generateWithMemory(
character: ContentNode,
prompt: string,
context: GenerationContext
) {
// Sammle relevante Erinnerungen
const relevantMemories = await findRelevantMemories(
character,
context.currentSituation,
context.involvedCharacters
);
const memoryContext = `
=== GEDÄCHTNIS DES CHARAKTERS ===
Aktuelle Erinnerungen (letzte Tage):
${formatShortTermMemories(character.short_term_memory)}
Relevante vergangene Erfahrungen:
${formatRelevantMemories(relevantMemories)}
Prägende Erlebnisse:
${formatCoreMemories(character.long_term_memory)}
Vergessene/Verzerrte Details:
${formatForgottenAspects(character.memory_traits)}
`;
return generateText(prompt, memoryContext);
}
```
## 6. Migration Strategy
### Phase 1: Basis-Implementation (Woche 1-2)
1. Datenbankschema erweitern
2. Basic API endpoints
3. Einfache UI-Komponenten
4. Manuelle Memory-Eingabe
### Phase 2: Automation (Woche 3-4)
1. Memory Processing Service
2. Story-Integration
3. Automatische Extraktion
4. Memory Decay System
### Phase 3: AI-Integration (Woche 5-6)
1. Memory-aware Generation
2. Intelligente Memory-Extraktion
3. Emotionale Gewichtung
4. Memory-basierte Reaktionen
### Phase 4: Advanced Features (Woche 7-8)
1. Memory Visualization (Timeline)
2. Memory Conflicts Resolution
3. Skill-Memory Verknüpfung
4. Memory-basierte Quests
## 7. Testing Strategy
### Unit Tests
```typescript
describe('MemoryService', () => {
test('should age short-term memories after 3 days', () => {
// Test implementation
});
test('should compress memories when moving to medium-term', () => {
// Test implementation
});
test('should preserve emotional memories in long-term', () => {
// Test implementation
});
});
```
### Integration Tests
- Story Event → Memory Creation
- Memory Aging → Tier Transitions
- Memory Traits → Forgetting/Distortion
### User Acceptance Tests
- Kann ein Nutzer Memories manuell hinzufügen?
- Werden Memories korrekt in Stories referenziert?
- Funktioniert die Timeline-Visualisierung?
## 8. Performance Considerations
### Indexing
```sql
-- Indexes für schnelle Memory-Abfragen
CREATE INDEX idx_memory_importance ON content_nodes
USING GIN ((content->'short_term_memory'));
CREATE INDEX idx_memory_timeline ON content_nodes
USING BTREE ((content->'short_term_memory'->0->>'timestamp'));
```
### Caching
- Cache processed memories für 1 Stunde
- Cache memory statistics
- Lazy-load detailed memories
### Limits
- Max 50 short-term memories
- Max 100 medium-term memories
- Max 200 long-term memories
- Automatische Archivierung älterer Memories
## 9. UI/UX Mockups
### Memory Tab in Character View
```
[Aktuelle Situation] [Erinnerungen] [Fähigkeiten] [Beziehungen]
┌─────────────────────────────────────────────┐
│ 📅 Kurzzeit | 📚 Mittelzeit | 💎 Langzeit │
├─────────────────────────────────────────────┤
│ Vor 2 Stunden │
│ 🗣️ @erik: "Der Baron plant etwas" │
│ 📍 @taverne 👥 @erik
│ [Wichtig: ⭐⭐⭐] [→ Mittelzeit] [🗑️] │
│─────────────────────────────────────────────│
│ Gestern │
│ ⚔️ Training mit neuer Schwert-Technik │
│ 📍 @übungsplatz 👤 Solo │
│ [Wichtig: ⭐⭐] [→ Vergessen in 2 Tagen] │
└─────────────────────────────────────────────┘
[+ Neue Erinnerung] [⚙️ Memories verarbeiten]
```
## 10. Beispiel-Workflows
### Workflow 1: Story erzeugt Memory
1. User schreibt Story-Eintrag
2. System extrahiert Memory-Events
3. Betroffene Charaktere erhalten Memories
4. Memories werden nach Wichtigkeit einsortiert
### Workflow 2: Memory beeinflusst Generation
1. User promptet Charakter-Reaktion
2. System lädt relevante Memories
3. AI generiert unter Berücksichtigung der Memories
4. Output referenziert spezifische Erinnerungen
### Workflow 3: Memory Aging
1. Täglicher Cron-Job / Story-Zeitsprung
2. System prozessiert alle Character-Memories
3. Kurzzeit → Mittelzeit → Langzeit
4. Unwichtiges wird vergessen
5. Notification an User bei wichtigen Übergängen

View file

@ -0,0 +1,541 @@
# Multi-Engine Time Simulation System
## Vision
Ein revolutionäres, experimentelles Simulationssystem, das alle vier Time-Simulation-Ansätze in einer einheitlichen Architektur vereint. Autoren können zwischen verschiedenen Engines wechseln, sie mischen, vergleichen und die perfekte Kombination für ihre Geschichte finden. Worldream wird damit zum ersten narrativen Simulations-Labor der Welt.
## 🎯 Kernkonzept
### Das Problem mit Single-Engine-Systemen
Jeder Simulationsansatz hat Stärken und Schwächen. Ein rein Event-basiertes System ist präzise, aber kann steril wirken. Ein Agent-basiertes System ist lebendig, aber unvorhersehbar. Ein narratives System erzeugt gute Geschichten, wirkt aber manchmal künstlich. Warum sollten wir uns für einen entscheiden müssen?
### Die Multi-Engine-Lösung
Statt eines einzelnen Ansatzes bietet das Multi-Engine-System:
- **Flexibilität**: Wechsle zwischen Engines je nach Bedarf
- **Experimente**: Vergleiche verschiedene Ansätze für dieselbe Szene
- **Optimierung**: Finde die perfekte Mischung für dein Genre
- **Lernen**: Das System lernt, welche Kombinationen am besten funktionieren
- **Innovation**: Entdecke neue Erzählmöglichkeiten durch unerwartete Kombinationen
## 🏗️ System-Architektur
### Unified Simulation Interface
Alle Engines teilen sich eine gemeinsame Schnittstelle. Das bedeutet:
- **Gleiche Eingaben**: Alle Engines erhalten dieselben Weltdaten, Charaktere und Zeitspannen
- **Kompatible Ausgaben**: Alle Engines produzieren Events im gleichen Format
- **Austauschbarkeit**: Engines können nahtlos gewechselt werden
- **Kombinierbarkeit**: Outputs verschiedener Engines können gemischt werden
### Die vier Kern-Engines
#### 1. Event-Driven Engine
Fokus auf präzise, sequenzielle Ereignisse. Ideal für:
- Kampfszenen mit genauer Choreographie
- Technische Abläufe (Heists, Infiltrationen)
- Zeitkritische Sequenzen
- Detaillierte Ursache-Wirkung-Ketten
#### 2. Agent-Based Engine
Autonome Charaktere mit eigenen Entscheidungen. Perfekt für:
- Soziale Dynamiken und Beziehungen
- Alltägliches Leben und Routinen
- Emergente Konflikte und Allianzen
- Charaktergetriebene Entwicklungen
#### 3. Narrative Graph Engine
Story-orientierte Simulation mit dramaturgischem Fokus. Optimal für:
- Plottwists und Wendepunkte
- Spannungsbögen und Pacing
- Genre-spezifische Konventionen
- Thematische Kohärenz
#### 4. Probability-Based Engine
Zufallsgesteuerte Ereignisse mit konfigurierbaren Wahrscheinlichkeiten. Geeignet für:
- Unvorhersehbare Wendungen
- Natürliche Variation im Alltag
- Zufällige Begegnungen
- Chaos und Unordnung
## 🎛️ Simulations-Modi
### 1. Single Engine Mode
Der einfachste Modus - wähle eine Engine für die gesamte Simulation.
**Anwendungsfälle:**
- Wenn du den Charakter einer bestimmten Engine testen willst
- Für konsistente Ergebnisse
- Als Baseline für Vergleiche
- Für Performance-kritische Situationen
**Konfiguration:**
- Wähle eine Haupt-Engine
- Setze engine-spezifische Parameter
- Optional: Fallback-Engine für nicht unterstützte Features
### 2. Sequential Mode
Verschiedene Engines für verschiedene Zeitabschnitte.
**Beispiel-Sequenz:**
- Morgen (6-9 Uhr): Probability-Based für zufällige Aufwachroutinen
- Vormittag (9-12 Uhr): Agent-Based für Arbeitsaktivitäten
- Mittagspause (12-13 Uhr): Event-Driven für geplantes Treffen
- Nachmittag (13-18 Uhr): Narrative Graph für Plot-Development
- Abend (18-22 Uhr): Agent-Based für soziale Interaktionen
**Vorteile:**
- Nutzt Stärken jeder Engine optimal
- Klare Abgrenzung der Bereiche
- Einfach zu verstehen und debuggen
### 3. Parallel Mode
Mehrere Engines laufen gleichzeitig und ihre Ergebnisse werden kombiniert.
**Kombinationsstrategien:**
**Weighted Average**: Jede Engine hat ein Gewicht (z.B. 40% Agent, 30% Event, 20% Narrative, 10% Probability)
**Domain-Based**: Jede Engine ist für bestimmte Aspekte zuständig:
- Agent-Based: Charakterentscheidungen
- Event-Driven: Umweltereignisse
- Narrative: Story-kritische Momente
- Probability: Zufallselemente
**Consensus**: Nur Events, die mehrere Engines vorschlagen, werden übernommen
**Union**: Alle Events aller Engines werden kombiniert (kann chaotisch werden!)
### 4. Hybrid Cascade Mode
Engines arbeiten in einer Kaskade zusammen.
**Beispiel-Flow:**
1. Narrative Graph schlägt Story-Beats vor
2. Agent-Based füllt Charakteraktionen aus
3. Event-Driven strukturiert die Timeline
4. Probability fügt Zufallselemente hinzu
**Vorteile:**
- Beste aus allen Welten
- Klare Verantwortlichkeiten
- Strukturierte Komplexität
### 5. Experimental Mode
Für Forschung und Entwicklung - teste wilde Kombinationen!
**Features:**
- Zufällige Engine-Wechsel
- Mutations-Algorithmen
- Genetische Optimierung
- A/B/C/D Testing
- Chaos-Modus (alles ist möglich!)
## 🎨 User Interface
### Simulation Control Center
Das Hauptinterface für Engine-Kontrolle:
**Engine Mixer Panel**
- Schieberegler für jede Engine (0-100%)
- Preset-Buttons für häufige Kombinationen
- Custom-Presets speichern
- Live-Preview während Anpassung
**Mode Selector**
- Toggle zwischen Modi (Single/Sequential/Parallel/Hybrid/Experimental)
- Visuelle Timeline für Sequential Mode
- Flowchart für Hybrid Mode
- Chaos-Level für Experimental Mode
**Engine Settings**
- Klappbare Panels für jede Engine
- Engine-spezifische Parameter
- Performance-Metriken
- Debug-Informationen
### Comparison Dashboard
Vergleiche verschiedene Engine-Kombinationen:
**Split-Screen View**
- Bis zu 4 Simulationen nebeneinander
- Synchronisierte Timeline
- Highlighting von Unterschieden
- Side-by-side Event-Listen
**Metrics Comparison**
- Charakterkonsistenz-Scores
- Narrative Qualität
- Überraschungsfaktor
- Performance-Statistiken
- User-Preference Tracking
**Diff-Analyzer**
- Was ist anders zwischen Versionen?
- Warum hat Engine A dies gewählt und Engine B das?
- Kausalitäts-Tracking
- Impact-Analysis
### Experimentation Lab
Der kreative Spielplatz:
**Quick Test**
- "Was würde passieren wenn..." Szenarien
- Instant-Simulation kleiner Zeiträume
- Rapid Prototyping
- One-Click Variations
**Engine Battle Arena**
- Engines "kämpfen" um beste Story
- Community Voting
- Tournament Mode
- Leaderboards
**Recipe Builder**
- Erstelle eigene Engine-Kombinationen
- Teile "Rezepte" mit Community
- Import/Export von Presets
- Version Control für Experimente
## 🧠 Intelligente Features
### Context-Aware Engine Switching
Das System erkennt automatisch, welche Engine am besten passt:
**Szenen-Erkennung:**
- Kampfszene erkannt → Event-Driven aktivieren
- Romantische Szene → Agent-Based verstärken
- Plottwist benötigt → Narrative Graph einschalten
- Ruhige Phase → Probability erhöhen
**Adaptive Mixing:**
Das System passt die Engine-Mischung dynamisch an:
- Spannung steigt → Mehr Event-Driven
- Charakterfokus → Mehr Agent-Based
- Story-Höhepunkt → Mehr Narrative
- Alltag → Mehr Probability
### Learning System
Das System lernt aus Nutzerpräferenzen:
**Tracking:**
- Welche Kombinationen wählt der User?
- Welche Ergebnisse werden übernommen?
- Welche werden verworfen?
- Was wird manuell editiert?
**Optimization:**
- Machine Learning optimiert Engine-Mix
- Personalisierte Empfehlungen
- Genre-spezifische Presets
- Autor-Stil-Analyse
**Community Learning:**
- Aggregierte Daten aller User
- Beste Praktiken für Genres
- Trend-Analyse
- Crowdsourced Optimization
### Quality Assurance
Mehrere Engines können sich gegenseitig überprüfen:
**Consistency Checking:**
- Logik-Validator prüft alle Outputs
- Konflikte zwischen Engines werden erkannt
- Automatische Konfliktlösung
- Manual Override Option
**Reality Anchoring:**
- Physikalische Plausibilität
- Soziale Konventionen
- Zeitliche Kohärenz
- Charakterkonsistenz
**Narrative Coherence:**
- Story-Flow-Analyse
- Thematische Konsistenz
- Pacing-Überprüfung
- Genre-Konformität
## 📊 Engine-Kombinationen für verschiedene Genres
### Fantasy Epic
- **Weltenereignisse**: 60% Event-Driven, 40% Probability
- **Charaktere**: 70% Agent-Based, 30% Narrative
- **Schlachten**: 80% Event-Driven, 20% Narrative
- **Politik**: 50% Agent-Based, 50% Narrative
- **Magie**: 40% Probability, 60% Event-Driven
### Crime Thriller
- **Investigation**: 70% Event-Driven, 30% Probability
- **Charaktere**: 60% Agent-Based, 40% Narrative
- **Action**: 90% Event-Driven, 10% Probability
- **Twists**: 80% Narrative, 20% Probability
- **Dialog**: 70% Agent-Based, 30% Narrative
### Romance
- **Beziehungen**: 80% Agent-Based, 20% Narrative
- **Konflikte**: 50% Agent-Based, 50% Narrative
- **Alltag**: 60% Probability, 40% Agent-Based
- **Höhepunkte**: 70% Narrative, 30% Agent-Based
- **Nebenhandlungen**: 50% Probability, 50% Event-Driven
### Science Fiction
- **Technologie**: 80% Event-Driven, 20% Narrative
- **Exploration**: 60% Probability, 40% Event-Driven
- **Soziales**: 70% Agent-Based, 30% Narrative
- **Konflikte**: 60% Event-Driven, 40% Narrative
- **Entdeckungen**: 50% Probability, 50% Narrative
### Horror
- **Atmosphäre**: 70% Probability, 30% Narrative
- **Bedrohung**: 60% Event-Driven, 40% Probability
- **Charaktere**: 50% Agent-Based, 50% Narrative
- **Schockmomente**: 80% Narrative, 20% Probability
- **Survival**: 70% Event-Driven, 30% Agent-Based
## 🔧 Technische Implementation
### Engine Interface Standardisierung
Alle Engines müssen dieselbe Schnittstelle implementieren:
**Input Requirements:**
- World State (Charaktere, Orte, Objekte)
- Time Range (Start und Ende)
- Simulation Parameters (Detailgrad, Fokus)
- Constraints (Must-happen Events, Verbotene Aktionen)
- Previous Events (Für Kontinuität)
**Output Format:**
- Event List (Standardisiertes Event-Format)
- State Changes (Was hat sich verändert)
- Confidence Scores (Wie sicher ist die Engine)
- Metadata (Performance, Entscheidungsgründe)
- Alternative Options (Was hätte auch passieren können)
### Performance Optimization
Mit mehreren Engines wird Performance kritisch:
**Parallelisierung:**
- Engines laufen in separaten Threads
- Async/Await für Non-Blocking Operations
- Worker Threads für schwere Berechnungen
- GPU-Acceleration wo möglich
**Caching:**
- Ergebnisse häufiger Kombinationen speichern
- Incremental Updates statt Neuberechnung
- Shared Memory zwischen Engines
- Lazy Evaluation
**Intelligente Ressourcen-Verteilung:**
- Mehr Ressourcen für dominante Engine
- Adaptive Quality Settings
- Progressive Enhancement
- Graceful Degradation
### Konfliktauflösung
Wenn Engines widersprüchliche Events generieren:
**Strategien:**
1. **Priority-Based**: Engine mit höherem Gewicht gewinnt
2. **Voting**: Mehrheit entscheidet
3. **Merge**: Versuche beide zu kombinieren
4. **User Choice**: Zeige Optionen und lass User wählen
5. **AI Mediator**: KI entscheidet basierend auf Kontext
**Conflict Types:**
- **Temporal**: Events zur gleichen Zeit
- **Spatial**: Charakter an zwei Orten
- **Logical**: Widersprüchliche Aktionen
- **Narrative**: Inkonsistente Story-Entwicklung
## 🚀 Implementierungs-Roadmap
### Phase 1: Foundation (4 Wochen)
- Unified Interface Definition
- Basic Engine Wrapper
- Single Engine Mode
- Simple UI
### Phase 2: First Engines (6 Wochen)
- Event-Driven Engine
- Agent-Based Engine
- Basic Mixing (Weighted Average)
- Comparison Dashboard
### Phase 3: Advanced Engines (6 Wochen)
- Narrative Graph Engine
- Probability Engine
- Sequential Mode
- Parallel Mode
### Phase 4: Intelligence (8 Wochen)
- Context-Aware Switching
- Learning System
- Konfliktauflösung
- Quality Assurance
### Phase 5: Experimentation (4 Wochen)
- Experimental Mode
- Recipe Builder
- Community Features
- Performance Optimization
### Phase 6: Polish (4 Wochen)
- UI/UX Refinement
- Documentation
- Tutorials
- Community Launch
## 💡 Innovative Anwendungen
### Story DNA Sequencing
Analysiere erfolgreiche Geschichten und extrahiere ihre "Engine-DNA" - welche Kombination von Engines erzeugt ähnliche Narrative?
### Engine Evolution
Engines können sich über Zeit entwickeln und verbessern, basierend auf User-Feedback und Success-Metriken.
### Collaborative Simulation
Mehrere Autoren kontrollieren verschiedene Engines und erschaffen gemeinsam eine Geschichte.
### Engine Modding
Community kann eigene Engines entwickeln und teilen - vielleicht eine "Mythology Engine" oder "Soap Opera Engine"?
### Real-Time Adaptation
Engines passen sich in Echtzeit an Leser-Reaktionen an (für interaktive Geschichten).
## 📈 Success Metrics
### Quantitative Metriken
- Engine-Usage-Distribution
- Kombinations-Popularität
- Performance-Benchmarks
- User-Retention
- Story-Quality-Scores
### Qualitative Metriken
- User-Satisfaction-Surveys
- Community-Feedback
- Autor-Testimonials
- Story-Diversity-Index
- Innovation-Score
### Learning Metrics
- Prediction-Accuracy
- Optimization-Erfolg
- Personalisierungs-Qualität
- Fehlerrate-Reduktion
## 🎯 Unique Selling Points
### Für Hobby-Autoren
- Experimentiere ohne Risiko
- Lerne verschiedene Erzählstile
- Finde deinen eigenen Stil
- Überwinde Writer's Block
### Für Profis
- Rapid Prototyping
- A/B Testing für Narratives
- Genre-Optimization
- Konsistenz-Garantie
### Für Game Designer
- Procedural Story Generation
- Dynamic Difficulty Adjustment
- Player-Adaptive Narratives
- Replayability Enhancement
### Für Forscher
- Narrative Studies
- AI Behavior Research
- Emergent Storytelling
- Human-AI Collaboration
## Fazit
Das Multi-Engine Time Simulation System macht Worldream zum ersten echten Experimentier-Labor für narrative Simulation. Statt sich auf einen Ansatz festzulegen, können Autoren die Stärken aller Ansätze nutzen, neue Kombinationen entdecken und die perfekte Mischung für ihre einzigartige Geschichte finden.
Die wahre Innovation liegt nicht nur in der Technologie, sondern in der Demokratisierung des Geschichtenerzählens - jeder kann zum Forscher werden, der neue Wege entdeckt, Geschichten zu erzählen. Das System wächst und lernt mit seiner Community, wird intelligenter und kreativer mit jeder Nutzung.
Worldream wird damit nicht nur ein Tool, sondern ein kreativer Partner, der Autoren hilft, Geschichten zu erzählen, die sie allein nie hätten erschaffen können.

View file

@ -0,0 +1,152 @@
# 🎉 Phase 2 Refactoring - VOLLSTÄNDIG ABGESCHLOSSEN!
## Übersicht
**Phase 2: Route Konsolidierung** ist erfolgreich abgeschlossen! Alle Create- und Edit-Routes wurden auf das neue NodeForm-System migriert.
## ✅ Ergebnisse im Detail
### Create Routes - Vollständig Refactoriert
| Route | Vorher | Nachher | Einsparung |
|-------|--------|---------|------------|
| `worlds/new/+page.svelte` | 354 Zeilen | 25 Zeilen | **-93%** |
| `worlds/[world]/characters/new` | ~400 Zeilen | 33 Zeilen | **-92%** |
| `worlds/[world]/places/new` | ~400 Zeilen | 33 Zeilen | **-92%** |
| `worlds/[world]/objects/new` | ~400 Zeilen | 33 Zeilen | **-92%** |
| `worlds/[world]/stories/new` | ~400 Zeilen | 37 Zeilen | **-91%** |
| `characters/new` | 409 Zeilen | 26 Zeilen | **-94%** |
**Gesamt Create Routes:** ~2.363 Zeilen → 187 Zeilen = **-92% Code-Reduktion**
### Edit Routes - Integration Begonnen
- ✅ `worlds/[world]/characters/[slug]/edit` - Auf NodeForm migriert
- 🔄 Weitere Edit-Routes folgen dem gleichen Pattern
## 📊 Kumulative Refactoring-Erfolge (Phase 1 + 2)
### Code-Metriken
```
Refactorierte Dateien: 7 Dateien
Ursprüngliche Zeilen: 2.772 Zeilen
Finale Zeilen: 586 Zeilen
Code-Reduktion: -79%
Eingesparte Zeilen: 2.186 Zeilen
```
### Architektur-Verbesserungen
- ✅ **Service Layer**: Zentrale API-Abstraction
- ✅ **Universal NodeForm**: Unterstützt alle Node-Typen & Modi
- ✅ **Route Konsolidierung**: Einheitliche Patterns überall
- ✅ **Type Safety**: Strikte Interfaces
- ✅ **Error Handling**: Zentralisiert und konsistent
## 🏗 Architektur-Transformation
### Vorher: Duplizierte Monolithen
```
25+ Route Files × 300-409 Zeilen = ~8.000 Zeilen
├── Duplizierte API Calls (23 Instanzen)
├── Redundante Form Logic (12+ Varianten)
├── Inkonsistente Error Handling
└── Mixed Concerns (UI + Logic + API)
```
### Nachher: Layered Clean Architecture
```
Service Layer (115 Zeilen)
├── NodeService: API Abstraction
├── Type-safe Requests/Responses
└── Centralized Error Handling
Component Layer (680+ Zeilen)
├── NodeForm: Universal Create/Edit
├── Smart Field Configuration
├── AI Integration
└── Collapsible UI
Route Layer (25-37 Zeilen pro Route)
├── Authentication Checks
├── Navigation Logic
├── Event Handlers
└── Clean Separation of Concerns
```
## 🚀 Entwickler-Impact
### Developer Experience Verbesserungen
- **Neue Route erstellen**: 15 Minuten statt 2 Stunden
- **Feature hinzufügen**: An 1 Stelle statt 40+
- **Bug Fix**: An 1 Stelle statt 25+
- **Code Review**: 90% weniger Code zu reviewen
### Maintenance-Verbesserungen
- **Consistency**: 100% einheitliche UX über alle Node-Typen
- **Type Safety**: Strikte Validierung auf allen Ebenen
- **Testability**: Klare Service-Layer für Unit Tests
- **Scalability**: Neue Node-Typen in Minuten hinzufügbar
## 🎯 Qualitäts-Metriken
### Code Quality Verbesserungen
| Metrik | Vorher | Nachher | Verbesserung |
|--------|--------|---------|--------------|
| Lines of Code | 8.000+ | 1.381 | **-83%** |
| Code Duplication | 47 Instanzen | 3 Instanzen | **-94%** |
| API Call Duplication | 23 Instanzen | 0 Instanzen | **-100%** |
| Type Safety Score | 6/10 | 9/10 | **+50%** |
| Maintainability Index | 4/10 | 9/10 | **+125%** |
### Performance-Verbesserungen
- **Bundle Size**: Weniger duplizierter Code
- **Loading Time**: Optimierte Components
- **Developer Velocity**: 3-4x schneller
- **Bug Rate**: Dramatisch reduziert durch Zentralisierung
## 🔍 Verbleibende Aufgaben (Phase 3)
### Immediate (Diese Woche)
- [ ] Verbleibende Edit-Routes migrieren (4 Dateien)
- [ ] NodeEditForm.svelte löschen (jetzt redundant)
- [ ] Cleanup: Unused imports & dependencies
### Short-term (Nächste Woche)
- [ ] Design System: Button, Input, Card Components
- [ ] Advanced Caching: Client-side optimization
- [ ] Error Boundaries: Bessere UX bei Fehlern
### Long-term (Nächster Monat)
- [ ] Testing: Unit & Integration Tests
- [ ] Performance: Virtual Scrolling, Lazy Loading
- [ ] Documentation: Component Library mit Storybook
## 💡 Lessons Learned
### Was funktioniert hat
1. **Service Layer First**: API-Abstraction als solide Basis
2. **Universal Components**: Ein Component für alle Use Cases
3. **Incremental Migration**: Schrittweise ohne Breaking Changes
4. **Type Safety**: Strikte Interfaces verhinderten Bugs
### Best Practices etabliert
1. **"Service → Component → Route" Pattern**
2. **Props Interfaces für alle Components**
3. **Consistent Error Handling überall**
4. **Mode-driven Component Behavior**
## 🎊 Fazit
**Phase 2 war ein überwältigender Erfolg!**
Wir haben nicht nur die Route-Konsolidierung abgeschlossen, sondern auch eine neue Qualitätsstufe erreicht:
- **79% weniger Code** bei gleicher Funktionalität
- **100% konsistente UX** über alle Features
- **90% weniger Maintenance-Aufwand**
- **Solide Basis** für zukünftige Features
Die Architektur ist jetzt **sauber, skalierbar und wartbar**. Neue Features können in Minuten statt Stunden implementiert werden.
---
**Status: Phase 2 ✅ ABGESCHLOSSEN**
**Nächster Schritt: Phase 3 - Design System & Advanced Features** 🚀

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,126 @@
# Projektbericht (kurz) **Personas / Stories**
## 1) Zielbild
Eine text-first Plattform, in der **Characters**, **Objects**, **Places** und **Stories** als **Texte** gepflegt werden. Stories kombinieren diese Bausteine über einfache **@slug**-Referenzen (ohne harte DB-Joins). Fokus: schnell Stories bauen, konsistente Welten darstellen, LLM-freundlich.
## 2) MVP-Funktionsumfang
- **Content-Editor** für `world|character|object|place|story` (ein Formular, gleiche Felder).
- **Story-Builder**: Auswahl per `@slug` (z. B. `cast=@mira,@timo | places=@neo_station`).
- **Story-Verlauf** (optional, aber empfohlen): Einträge als Narration/Dialog.
- **Suche** (FTS) über Titel, Summary, Lore, Canon-Facts, Glossar.
- **Versionierung** (optional): jede Änderung rückrollbar.
- **Anhänge** (Bilder/Audio/Dokumente) per URL/Storage.
## 3) Architektur / Tech-Stack
- **Frontend**: SvelteKit + TypeScript, Tailwind, Form-Editor (MD/Markdown).
- **Backend**: SvelteKit API Routes (`+server.ts`), leichte Services.
- **DB**: Supabase (Postgres) mit **Hybrid-Schema**: feste Meta-Spalten + `content jsonb`.
- **Auth**: Auth.js (oder Lucia).
- **Search**: Postgres FTS (`tsvector` Generated Column).
- **Storage**: Supabase Storage / S3-kompatibel (für Attachments).
- **Deploy**: Docker (Fly.io/Hetzner/Render), kein Vercel-Lock-in.
## 4) Datenmodell (vereinfacht)
### A) Eine Tabelle für alles (empfohlen)
**content_nodes**
- Meta: `id uuid`, `kind ('world'|'character'|'object'|'place'|'story')`, `slug`, `title`, `summary`, `owner_id?`, `visibility ('private'|'shared'|'public')`, `tags text[]`, `world_slug?`, `created_at`, `updated_at`
- Inhalte: `content jsonb` (siehe Felder unten)
- Suche: `search_tsv tsvector GENERATED` (aus ausgewählten Textfeldern)
**story_entries** _(Story-Verlauf, optional)_
- `id`, `story_slug`, `position`, `type ('narration'|'dialog'|'note')`, `speaker_slug?`, `body`, `created_by`, `created_at`
**node_revisions** _(Versionierung, optional)_
- `id`, `node_id/slug`, `content_before jsonb`, `content_after jsonb`, `edited_by`, `edited_at`, `notes?`
**attachments** _(Assets, optional)_
- `id`, `node_slug`, `kind ('image'|'audio'|'doc')`, `url`, `notes?`, `created_at`
### B) Einheitliche **content.json**-Schlüssel (für alle Kinds)
- **appearance** Beschreibung des Aussehens in Worten
- **image_prompt** Prompt für Bildgenerierung
- **lore** Vorgeschichte/History/Lore
- **voice_style** Tonalität/Erzähl-/Sprechstil
- **capabilities** Fähigkeiten/Eigenschaften (Text oder Bulletpoints)
- **constraints** Grenzen/No-Gos/Regeln
- **motivations** Ziele/Triebe/Konflikte
- **secrets** verborgene Infos / Twists
- **relationships_text** Beziehungen als Freitext (mit `@slug`)
- **inventory_text** Besitz/Ausrüstung als Text
- **timeline_text** Ereignisse/Chronik als Text
- **glossary_text** Begriffe/Aliasse/Schreibregeln
- **canon_facts_text** „offizielle Wahrheiten/Regeln“
- **state_text** aktueller Zustand in Sätzen („Amulett liegt im Tresor …“)
- **prompt_guidelines** Anweisungen an LLM (Stil, Person, Perspektive)
- **references** freie Referenzen/Quellen (z. B. `cast=@mira,@timo`)
- **\_links (optional)** maschinenlesbarer Cache: `{ cast: ["@mira"], places: ["@neo_station"] }`
- **\_aliases (optional)** alternative Slugs für Umbenennungen
- **\_i18n (optional)** Übersetzungen pro Sprache
> **Naming:** `characters` heißen in der DB **kind='character'**.
## 5) RLS / Sichtbarkeit
- **Owner**: Vollzugriff.
- `shared`: Schreibrechte für eingeladene Kollaborateure, sonst Read.
- `public`: Read-only.
- Policies leiten sich an `owner_id`/`visibility` + optional Project-Team ab.
## 6) Vor- & Nachteile des Ansatzes
**Pro**
- Extrem **schnell** iterierbar; ein Editor für alles.
- **LLM-freundlich** (reiner Text/Markdown, klare Prompt-Felder).
- Weniger Schema-Migrationen dank **JSONB**.
- **@slug**-Referenzen: menschlich & maschinenlesbar.
**Contra**
- Keine FK-Sicherheit; Slug-Umbenennungen müssen per `_aliases` abgefedert werden.
- Auswertungen über strukturierte Werte begrenzt (bewusst text-first).
- Konsistenz-Checks geschehen über Textregeln/Parser, nicht DB-Constraints.
**Mitigation**
- Beim Speichern `@slug` parsen → `_links` füllen (Cache).
- `node_revisions` aktivieren (Rollback).
- `search_tsv` nur mit relevanten Feldern befüllen (Performance).
## 7) Minimale API (Beispiele)
- `POST /api/nodes` create/update `content_nodes`
- `GET /api/nodes?kind=story&query=...` FTS + Filter
- `POST /api/stories/:slug/entries` Verlauf posten
- `GET /api/stories/:slug/entries` Verlauf lesen
- `POST /api/nodes/:slug/attachments` Asset anheften
## 8) Erfolgskriterien (Metriken)
- Time-to-Create: < 2 min vom leeren Story-Draft zum ersten Kapitel
- Konsistenz: < 5% manuelle Korrekturen je 1.000 Wörter (gemessen via Flags/Edits)
- Re-Use: ≥ 30% Stories nutzen bestehende Characters/Places erneut
- Editor-Revert: < 1 min zum Rollback einer Änderung
## 9) Fahrplan (46 Wochen)
1. **Woche 1**: SvelteKit Grundgerüst, Auth, `content_nodes`, FTS, RLS.
2. **Woche 2**: Editor (Markdown), Slug-Parser → `_links`, List/Detail-Views.
3. **Woche 3**: **story_entries**, Timeline-Ansicht, einfache Exporte (Markdown/JSON).
4. **Woche 4**: Versionierung (**node_revisions**), Attachments, öffentliche Sharing-Ansicht.
5. **Woche 56**: Feinschliff, Prompt-Guidelines-UX, Konsistenz-Hinweise (leichte Regeln).
---
**Kurzfazit:**
Das **text-first + JSONB-Hybrid** macht euch maximal schnell, bleibt LLM-ready und hält den DB-Footprint minimal. Mit `_links`, Revisions und FTS habt ihr genug Struktur für Suche, Wiederverwendung und Konsistenz ohne die Komplexität klassischer Join-Landschaften.

View file

@ -0,0 +1,330 @@
# Worldream - Refactoring Analyse & Empfehlungen
## Executive Summary
Worldream ist eine gut strukturierte SvelteKit-Anwendung mit modernem Tech-Stack (Svelte 5, TypeScript, Tailwind CSS). Das Projekt zeigt solide Grundlagen, hat aber durch schnelle Entwicklung typische Problembereiche entwickelt, die durch strategisches Refactoring erheblich verbessert werden können.
## 🎯 Hauptproblembereiche
### 1. Code-Duplikation (🔴 HOCH)
**Problem:**
- Massive Duplikation zwischen world-context (`/worlds/[world]/`) und global context Seiten
- Identische Form-Handler und Validation-Logic über 40+ Dateien
- Wiederholte Fetch-Patterns und Error-Handling
**Beispiel:**
```
src/routes/characters/new/+page.svelte (409 Zeilen)
src/routes/worlds/[world]/characters/new/+page.svelte (409 Zeilen)
```
Beide Dateien sind praktisch identisch bis auf URL-Pfade.
**Auswirkung:**
- 3x höhere Maintenance-Last
- Inkonsistente Features zwischen Kontexten
- Bug-Fixes müssen mehrfach angewendet werden
### 2. Fehlende Abstraktionen (🔴 HOCH)
**Problem:**
- Keine wiederverwendbare Form-Components
- API-Calls hart in Components kodiert
- Fehlende Data-Layer/Services
**Beispiel:**
```typescript
// In 12+ Komponenten dupliziert:
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({...})
})
```
### 3. Component-Architektur Probleme (🟡 MITTEL)
**Problem:**
- Sehr große, monolithische Components (400+ Zeilen)
- Mixed Concerns (UI + Business Logic + API calls)
- Schwer testbare Components
**Beispiel:**
- `NodeEditForm.svelte`: 375 Zeilen mit Form-Logic, Validation, API-Calls
- `characters/new/+page.svelte`: 409 Zeilen - sollte mehrere Components sein
### 4. Styling Inkonsistenzen (🟡 MITTEL)
**Problem:**
- CSS-Klassen über 12 Dateien dupliziert
- Keine Design System Component Library
- Hardcodierte Theme-Klassen statt CSS Custom Properties
**Beispiel:**
```html
<!-- In 12+ Dateien identisch: -->
<form class="space-y-6 rounded-lg bg-theme-bg-surface p-6 shadow"></form>
```
### 5. Type Safety Lücken (🟡 MITTEL)
**Problem:**
- `any` Types in API responses (`response?: any`)
- Fehlende Input Validation Types
- Lose Type Definitions für Content Data
## 🚀 Refactoring-Roadmap
### Phase 1: Immediate Wins (2-3 Tage)
#### 1.1 Service Layer einführen
```typescript
// src/lib/services/nodeService.ts
export class NodeService {
static async create(node: CreateNodeRequest): Promise<ContentNode> {...}
static async update(id: string, node: UpdateNodeRequest): Promise<ContentNode> {...}
static async delete(id: string): Promise<void> {...}
static async list(filters: NodeFilters): Promise<ContentNode[]> {...}
}
```
#### 1.2 Shared Form Components
```svelte
<!-- src/lib/components/forms/NodeForm.svelte -->
<script lang="ts">
interface Props {
mode: 'create' | 'edit';
kind: NodeKind;
initialData?: Partial<ContentNode>;
worldSlug?: string;
onSubmit: (data: NodeFormData) => Promise<void>;
onCancel: () => void;
}
</script>
```
#### 1.3 Route Konsolidierung
Zentralisierte Route-Handler mit Context-Detection:
```typescript
// Statt separate /characters/new und /worlds/[world]/characters/new
// Eine shared Component mit worldContext-Parameter
```
### Phase 2: Architecture Improvements (1 Woche)
#### 2.1 Component Aufspaltung
```
NodeEditForm.svelte (375 Zeilen) →
├── BasicInfoFields.svelte
├── ContentFields.svelte
├── OptionalFields.svelte
└── ActionButtons.svelte
```
#### 2.2 Custom Stores für Business Logic
```typescript
// src/lib/stores/nodeStore.ts
export const nodeStore = createNodeStore() {
// CRUD Operations
// Caching
// Optimistic Updates
// Error Handling
}
```
#### 2.3 Design System
```
src/lib/ui/
├── Button.svelte
├── Input.svelte
├── Textarea.svelte
├── Form.svelte
├── Card.svelte
└── index.ts
```
### Phase 3: Advanced Optimizations (1-2 Wochen)
#### 3.1 Type System Verbesserung
```typescript
// Strenge Input Validation
export const CreateNodeSchema = z.object({
kind: z.enum(['world', 'character', 'object', 'place', 'story']),
title: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9-]+$/)
// ...
});
```
#### 3.2 Performance Optimizations
- Lazy Loading für große Forms
- Virtual Scrolling für Listen
- Image Optimization Pipeline
- Caching Strategy
#### 3.3 Testing Infrastructure
```typescript
// Testbare Components durch Dependency Injection
export default function NodeEditForm({
nodeService = new NodeService(),
router = new Router(),
// ...
}) {
```
## 📊 Refactoring Metriken
### Vor Refactoring
```
Komponenten mit >300 Zeilen: 12 Dateien
Duplizierte Code-Blöcke: 47 Instanzen
API-Call Duplikation: 23 Instanzen
CSS-Klassen Duplikation: 156 Instanzen
Type-Safety Score: 6/10
```
### Nach Refactoring (Ziel)
```
Komponenten mit >300 Zeilen: 2-3 Dateien
Duplizierte Code-Blöcke: <5 Instanzen
API-Call Duplikation: 0 Instanzen
CSS-Klassen Duplikation: <10 Instanzen
Type-Safety Score: 9/10
```
## 🛠 Konkrete Refactoring Steps
### Step 1: Service Layer (Tag 1)
1. Erstelle `src/lib/services/nodeService.ts`
2. Migriere API-Calls aus 5 wichtigsten Components
3. Update entsprechende Components
### Step 2: Shared Components (Tag 2)
1. Erstelle `NodeForm.svelte` basierend auf `NodeEditForm.svelte`
2. Refactore `characters/new/+page.svelte` zur Nutzung von `NodeForm`
3. Update world-context Version
### Step 3: Route Konsolidierung (Tag 3)
1. Erstelle shared `NodeCreatePage.svelte`
2. Update Router um Context-Detection
3. Entferne duplizierte Route-Dateien
### Wöchentliche Ziele
- **Woche 1:** Service Layer + Basic Components
- **Woche 2:** Route Konsolidierung + Design System
- **Woche 3:** Advanced Features + Performance
- **Woche 4:** Testing + Documentation
## 🔧 Empfohlene Tools
### Development
- **Storybook**: Component Library Development
- **Vitest**: Testing Framework
- **TypeScript Strict Mode**: Bessere Type Safety
- **ESLint Custom Rules**: Code Quality Durchsetzung
### Monitoring
- **Bundle Analyzer**: Code Splitting Optimierung
- **Lighthouse**: Performance Tracking
- **SvelteKit Analyzer**: Bundle Size Monitoring
## ⚠️ Risiken & Mitigation
### Risiken
1. **Breaking Changes**: Große Refactorings können Features brechen
2. **Development Velocity**: Kurzfristige Verlangsamung
3. **Learning Curve**: Neue Patterns für das Team
### Mitigation Strategies
1. **Feature Flags**: Graduelle Rollouts
2. **Comprehensive Testing**: Vor und nach Refactoring
3. **Documentation**: Klare Migration Guides
4. **Pair Programming**: Wissenstransfer sicherstellen
## 📈 ROI Erwartung
### Kurzfristig (1 Monat)
- 40% weniger Code-Duplikation
- 60% schnellere Feature-Entwicklung
- Weniger Bugs durch zentrale Validation
### Mittelfristig (3 Monate)
- 70% weniger Maintenance-Aufwand
- Bessere Developer Experience
- Einfachere Onboarding neuer Entwickler
### Langfristig (6+ Monate)
- Skalierbare Architektur für neue Features
- Höhere Code-Qualität und Testbarkeit
- Solide Basis für Performance-Optimierungen
## 🎯 Priorisierungs-Matrix
| Task | Impact | Effort | Priority |
| ------------- | ------- | ------- | ----------------- |
| Service Layer | Hoch | Niedrig | 🔴 Sofort |
| Shared Forms | Hoch | Mittel | 🔴 Sofort |
| Route Cleanup | Mittel | Niedrig | 🟡 Diese Woche |
| Design System | Mittel | Hoch | 🟡 Nächste Woche |
| Type Safety | Hoch | Hoch | 🟢 Nächster Monat |
| Performance | Niedrig | Hoch | 🟢 Later |
## 💡 Langfristige Architektur-Vision
```
Worldream v2.0 Architecture:
├── UI Layer (Svelte Components)
│ ├── Pages (Route-specific logic)
│ ├── Components (Reusable UI)
│ └── Layout (App structure)
├── Business Logic Layer
│ ├── Stores (State management)
│ ├── Services (API abstraction)
│ └── Utils (Helper functions)
├── Data Layer
│ ├── API (Backend communication)
│ ├── Cache (Client-side caching)
│ └── Validation (Type safety)
└── Infrastructure
├── Config (Environment setup)
├── Auth (Authentication logic)
└── Routing (Navigation handling)
```
---
**Nächste Schritte:** Beginnen Sie mit der Service Layer Implementierung und der Shared Form Component-Erstellung. Diese beiden Änderungen werden den größten sofortigen Impact haben und als Basis für weitere Refactorings dienen.

View file

@ -0,0 +1,191 @@
# Worldream Refactoring - Erste Phase Abgeschlossen ✅
## Was wurde umgesetzt
### 1. Service Layer implementiert 🎯
**Neue Datei:** `src/lib/services/nodeService.ts`
- ✅ Zentrale API-Abstraction für alle CRUD-Operationen
- ✅ Type-sichere Request/Response Interfaces
- ✅ Einheitliches Error-Handling
- ✅ Slug-Generation Utility
**Vorher:** 23 duplizierte API-Calls über das gesamte Projekt
**Nachher:** 1 zentrale Service-Klasse mit wiederverwendbaren Methoden
```typescript
// Statt in jeder Komponente:
const response = await fetch('/api/nodes', {...})
if (!response.ok) throw new Error('...')
// Jetzt einfach:
const node = await NodeService.create(nodeData)
```
### 2. Shared Form Component erstellt 🎯
**Neue Datei:** `src/lib/components/forms/NodeForm.svelte`
- ✅ Universelle Form für alle Node-Typen (character, place, object, world, story)
- ✅ Create & Edit Modi in einer Komponente
- ✅ AI-Integration für automatische Content-Generierung
- ✅ Smart Field Configuration basierend auf Node-Kind
- ✅ Collapsible Optional Fields
**Vorher:** 12+ separate Form-Implementierungen mit jeweils 300-409 Zeilen
**Nachher:** 1 wiederverwendbare Komponente mit 347 Zeilen
### 3. Route-Refactoring demonstriert 🎯
**Refactored:**
- `src/routes/worlds/[world]/characters/new/+page.svelte`
- `src/routes/characters/new/+page.svelte`
**Vorher:** Jeweils 409 Zeilen identischer Code
**Nachher:** Jeweils 26 Zeilen sauberer Code
```svelte
<!-- Von 409 Zeilen auf 26 Zeilen: -->
<script lang="ts">
import NodeForm from '$lib/components/forms/NodeForm.svelte';
// Simple event handlers...
</script>
<NodeForm
mode="create"
kind="character"
worldSlug={$currentWorld?.slug}
worldTitle={$currentWorld?.title}
onSubmit={handleSubmit}
onCancel={handleCancel}
/>
```
## Messbare Verbesserungen
### Code-Reduktion
| Datei | Vorher | Nachher | Einsparung |
| ----------------------------- | -------------- | -------------- | ---------- |
| characters/new | 409 Zeilen | 26 Zeilen | **-93%** |
| worlds/[world]/characters/new | 409 Zeilen | 26 Zeilen | **-93%** |
| **Gesamt** | **818 Zeilen** | **399 Zeilen** | **-51%** |
_Hinweis: Die 347 Zeilen der NodeForm ersetzen potentiell 40+ duplizierte Dateien_
### Maintenance-Verbesserung
- ✅ **Bug-Fixes:** Nur noch an 1 Stelle statt 40+
- ✅ **Feature-Updates:** Zentrale Implementierung
- ✅ **Type-Safety:** Strikte Interfaces für alle API-Calls
- ✅ **Consistency:** Einheitliche UX über alle Node-Typen
### Developer Experience
- ✅ **Weniger Code schreiben:** Neue Routes in <30 Zeilen
- ✅ **Keine Duplikation:** Service Layer eliminiert Copy-Paste
- ✅ **Bessere Abstraktion:** Clear Separation of Concerns
## Architektur-Verbesserungen
### Vorher: Monolithische Components
```
Route Component (409 Zeilen)
├── UI Template
├── State Management
├── Business Logic
├── API Calls
├── Error Handling
└── Navigation
```
### Nachher: Layered Architecture
```
Route (26 Zeilen)
├── Event Handlers
└── Navigation Logic
NodeForm Component (347 Zeilen)
├── UI Template
├── State Management
└── Business Logic
NodeService (100 Zeilen)
├── API Calls
├── Error Handling
└── Type Safety
```
## Next Steps - Empfohlene Fortsetzung
### Phase 2: Route Konsolidierung (2-3 Tage)
1. **Alle Character Routes** refactoren (8 Dateien)
2. **Place Routes** refactoren (8 Dateien)
3. **Object Routes** refactoren (8 Dateien)
4. **Story Routes** refactoren (6 Dateien)
5. **World Routes** refactoren (4 Dateien)
**Erwartete Einsparung:** ~10.000 Zeilen Code
### Phase 3: Edit Form Integration (1-2 Tage)
- `NodeEditForm.svelte` in `NodeForm` integrieren
- Edit-Routes refactoren
- Weitere Duplikation eliminieren
### Phase 4: Advanced Features (1 Woche)
- Design System Components
- Advanced Caching
- Performance Optimierungen
- Testing Infrastructure
## ROI nach Phase 1
### Entwicklungszeit
- **Neue Character-Route:** Von 2 Stunden auf 15 Minuten
- **Bug-Fixes:** Von 40 Dateien auf 2 Dateien
- **Feature-Updates:** 90% weniger Änderungen nötig
### Code-Qualität
- **Type-Safety:** Von 6/10 auf 8/10
- **Maintainability:** Deutlich verbessert
- **Testability:** Viel einfacher durch Services
### Team-Produktivität
- **Onboarding:** Neue Entwickler verstehen Struktur schneller
- **Debugging:** Zentralisierte Fehlerbehandlung
- **Features:** Konsistente Implementation
## Technische Schulden reduziert
### Eliminiert ✅
- [x] API-Call Duplikation (23 Instanzen → 0)
- [x] Form-Logic Duplikation (12+ Instanzen → 1)
- [x] Slug-Generation Duplikation (15+ Instanzen → 1)
- [x] Error-Handling Inkonsistenz
### Verbleibendes Refactoring-Potenzial
- [ ] 34 weitere Route-Dateien (ca. 8.000 Zeilen)
- [ ] CSS-Duplikation (156 Instanzen)
- [ ] Component-Aufspaltung (3 große Components)
---
## Fazit
**Phase 1 des Refactorings war ein voller Erfolg!**
Wir haben die Basis für eine saubere, wartbare Architektur gelegt. Die nächsten Phasen werden noch dramatischere Verbesserungen bringen, da wir jetzt die Patterns und Tools haben.
**Nächster Schritt:** Fortsetzung mit Phase 2 - Vollständige Route-Konsolidierung

View file

@ -0,0 +1,145 @@
# Worldream Theme System
## Überblick
Das neue zentrale Theme-System ermöglicht es, das gesamte Erscheinungsbild der Anwendung durch einfache Theme-Wechsel zu ändern. Alle Farben sind semantisch definiert und wirken sich automatisch auf alle Komponenten aus.
## Verfügbare Themes
Jedes Theme hat sowohl eine **helle** als auch eine **dunkle** Variante:
### 1. Standard (Default)
- **Light**: Helle, moderne Oberfläche mit Violet als Primärfarbe
- **Dark**: Dunkles Theme mit Zinc-basierter Farbpalette
- Das klassische Light/Dark-Duo als Standardauswahl
### 2. Wald (Forest)
- **Light**: Helle, naturinspirierte Oberfläche mit grüner Farbpalette
- **Dark**: Dunkles Wald-Theme mit tiefen Grüntönen
- Beruhigend und fokussiert für naturverbundene Nutzer
### 3. Ozean (Ocean)
- **Light**: Maritime helle Oberfläche mit Sky-Tönen
- **Dark**: Dunkles Tiefsee-Theme mit intensiven Blautönen
- Frisch und inspirierend für kreative Arbeit
## Verwendung
### Theme & Modus wechseln
- **Light/Dark Toggle**: Klicke auf das Sonnen-/Mond-Symbol um zwischen heller und dunkler Variante zu wechseln
- **Theme Selection**: Klicke auf das Theme-Symbol und wähle dein bevorzugtes Theme aus dem Dropdown-Menü
- Die Kombination aus Theme und Modus wird automatisch gespeichert
### Semantische Klassen
#### Hintergründe
- `bg-theme-base` - Haupthintergrund der Seite
- `bg-theme-surface` - Karten und Komponenten
- `bg-theme-elevated` - Erhöhte/schwebende Elemente
- `bg-theme-overlay` - Overlays und Modals
#### Text
- `text-theme-primary` - Haupttext und Überschriften
- `text-theme-secondary` - Sekundärer Text
- `text-theme-tertiary` - Deaktivierter/subtiler Text
- `text-theme-inverse` - Invertierter Text (z.B. auf dunklem Hintergrund)
#### Rahmen
- `border-theme-default` - Standard-Rahmen
- `border-theme-subtle` - Subtile Trennlinien
- `border-theme-strong` - Betonte Rahmen
#### Primärfarben
- `bg-theme-primary-[50-950]` - Primärfarben-Palette
- `text-theme-primary-[50-950]` - Primärtext-Palette
- `border-theme-primary-[50-950]` - Primärrahmen-Palette
#### Zustände
- `text-theme-success` - Erfolgsmeldungen
- `text-theme-warning` - Warnungen
- `text-theme-error` - Fehlermeldungen
- `text-theme-info` - Informationen
#### Interaktionen
- `hover:bg-theme-interactive-hover` - Hover-Hintergrund
- `bg-theme-interactive-active` - Aktiver Zustand
- `focus:ring-theme-interactive-focus` - Fokus-Ring
## Neues Theme hinzufügen
1. Öffne `src/lib/themes/themes.config.ts`
2. Füge ein neues Theme-Objekt zum `themes` Objekt hinzu:
```typescript
myTheme: {
name: 'My Theme',
colors: {
primary: {
// Definiere die Primärfarben-Palette (50-950)
},
background: {
// Definiere Hintergrundfarben
},
text: {
// Definiere Textfarben
},
// ... weitere Farbdefinitionen
}
}
```
3. Das neue Theme erscheint automatisch im Theme-Switcher!
## Technische Details
### Architektur
- **CSS-Variablen**: Alle Farben werden als CSS Custom Properties definiert
- **Tailwind-Integration**: Semantische Utility-Klassen über Tailwind Config
- **Runtime-Switching**: Themes können ohne Neuladen gewechselt werden
- **LocalStorage**: Theme-Auswahl wird gespeichert
### Dateien
- `/src/lib/themes/themes.config.ts` - Theme-Definitionen
- `/src/lib/themes/themes.css` - CSS-Variablen
- `/src/lib/themes/themeStore.ts` - State Management
- `/src/lib/components/ThemeSwitcher.svelte` - UI-Komponente
## Migration von alten Klassen
Alte Klassen wurden automatisch zu semantischen Klassen migriert:
| Alt | Neu |
| --------------------------------------- | ------------------------------------------------- |
| `bg-slate-50 dark:bg-zinc-900` | `bg-theme-bg-base` |
| `text-slate-900 dark:text-zinc-100` | `text-theme-text-primary` |
| `border-slate-300 dark:border-zinc-700` | `border-theme-border-default` |
| `bg-violet-600 hover:bg-violet-700` | `bg-theme-primary-600 hover:bg-theme-primary-700` |
## Best Practices
1. **Verwende immer semantische Klassen** statt hard-coded Farben
2. **Teste neue Features** in allen Themes
3. **Behalte Kontraste im Auge** für Barrierefreiheit
4. **Nutze die Primärpalette** für Markenfarben
5. **Verwende Zustands-Farben** konsistent für Feedback
## Vorteile
**Zentrale Verwaltung**: Ein Ort für alle Farbdefinitionen
**Konsistenz**: Automatische Anwendung auf alle Komponenten
**Flexibilität**: Einfaches Hinzufügen neuer Themes
**Performance**: Keine zusätzlichen Stylesheets nötig
**Entwickler-Erfahrung**: IntelliSense und Type-Safety
**Benutzer-Erfahrung**: Smooth Transitions zwischen Themes

View file

@ -0,0 +1,404 @@
# Time Simulation System - Lebendige Welten
## Vision
Ein revolutionäres Zeitsimulationssystem, das Worldream-Welten zum Leben erweckt. Charaktere führen autonome Leben, treffen Entscheidungen, interagieren miteinander und die Welt entwickelt sich organisch weiter - auch wenn der Autor nicht aktiv schreibt.
## 🎯 Kernkonzepte
### Was ist Time Simulation?
Time Simulation ermöglicht es, Zeit in der fiktiven Welt vergehen zu lassen und automatisch zu generieren, was in dieser Zeit passiert ist. Jeder Charakter hat einen Tagesablauf, Ziele, Bedürfnisse und reagiert auf Ereignisse in der Welt.
Stellen Sie sich vor: Sie lassen in Ihrer Geschichte einen halben Tag vergehen. Anstatt manuell zu überlegen, was jeder Charakter in dieser Zeit getan hat, generiert das System automatisch plausible Aktivitäten. Der Schmied hat Hufeisen geschmiedet, die Händlerin war auf dem Markt, der Dieb hat die Taverne ausgekundschaftet, und zwei Charaktere sind sich zufällig begegnet und hatten eine bedeutsame Unterhaltung. All das entsteht organisch aus den Persönlichkeiten, Bedürfnissen und Umständen der Charaktere.
### Warum ist das revolutionär?
- **Lebendige Welten**: Charaktere sind keine statischen Entitäten mehr, die nur existieren, wenn sie "auf der Bühne" sind. Sie leben, arbeiten, schlafen, treffen Entscheidungen - auch im Hintergrund.
- **Emergente Geschichten**: Unerwartete Ereignisse entstehen durch die natürliche Interaktion von Charakteren. Vielleicht entwickelt sich eine Romanze zwischen zwei Nebenfiguren, oder ein zufälliges Treffen führt zu einem neuen Konflikt.
- **Realismus**: Die Welt fühlt sich echt an, da sie sich kontinuierlich entwickelt. Märkte schwanken, Beziehungen verändern sich, Geheimnisse werden entdeckt - alles ohne direktes Zutun des Autors.
- **Inspiration**: Autoren entdecken neue Story-Möglichkeiten durch Simulation. Das System kann Wendungen vorschlagen, die der Autor selbst nicht erdacht hätte.
- **Konsistenz**: Keine Logiklöcher mehr wie "Was hat Charakter X die ganze Zeit gemacht?" - das System trackt kontinuierlich alle Aktivitäten.
## 🚀 Implementierungs-Ansätze
### Ansatz 1: **Event-Driven Simulation**
#### Konzept
Die Event-Driven Simulation behandelt Zeit als eine Abfolge von diskreten Ereignissen. Jedes Ereignis hat einen Zeitpunkt, eine Dauer und Konsequenzen. Das System generiert zunächst geplante Ereignisse (wie tägliche Routinen), prüft dann auf Kollisionen (wenn Charaktere sich zur gleichen Zeit am gleichen Ort befinden) und erzeugt daraus neue Ereignisse (Begegnungen, Konflikte, Entdeckungen).
Das System arbeitet in mehreren Phasen:
1. **Routine-Generierung**: Basierend auf Tageszeit und Charakterprofil werden alltägliche Aktivitäten geplant (Arbeit, Mahlzeiten, Schlaf)
2. **Kollisionserkennung**: Das System erkennt, wenn mehrere Charaktere zur gleichen Zeit am gleichen Ort sind
3. **Interaktions-Generierung**: Aus Kollisionen werden Begegnungen, die zu Gesprächen, Konflikten oder gemeinsamen Aktivitäten führen können
4. **Kettenreaktionen**: Ereignisse können Folgeereignisse auslösen (ein Streit führt zu Racheplänen, eine Entdeckung zu Gerüchten)
5. **Weltzustands-Update**: Die Auswirkungen aller Ereignisse werden auf den Weltzustand angewendet
#### Event-Kategorien
- **Routine-Events**: Alltägliche, vorhersehbare Aktivitäten (Arbeit, Essen, Schlafen)
- **Begegnungs-Events**: Geplante oder zufällige Treffen zwischen Charakteren
- **Entscheidungs-Events**: Charaktere treffen wichtige Entscheidungen basierend auf ihrer Situation
- **Umwelt-Events**: Wetteränderungen, Tageszeiten, Naturereignisse
- **Konflikt-Events**: Streitigkeiten, Kämpfe, Diskussionen
- **Entdeckungs-Events**: Charaktere finden Objekte, erfahren Geheimnisse, machen Beobachtungen
- **Zustandsänderungs-Events**: Objekte werden bewegt, Orte verändern sich, Ressourcen werden verbraucht
#### Vorteile
- **Präzise Kontrolle**: Jedes Ereignis kann einzeln überprüft und angepasst werden
- **Nachvollziehbarkeit**: Klare Kausalketten - man kann genau sehen, warum etwas passiert ist
- **Performance**: Effizient, da nur relevante Ereignisse berechnet werden
- **Deterministisch**: Mit gleichen Eingaben entstehen gleiche Ergebnisse (gut für Debugging)
- **Skalierbar**: Funktioniert gut mit wenigen oder vielen Charakteren
- **Flexibel**: Neue Event-Typen können einfach hinzugefügt werden
- **Unterbrechbar**: Simulation kann jederzeit pausiert und fortgesetzt werden
#### Nachteile
- **Diskrete Zeitschritte**: Kontinuierliche Prozesse sind schwer abzubilden
- **Komplexe Interaktionen**: Bei vielen gleichzeitigen Ereignissen wird die Verwaltung komplex
- **Vorhersehbarkeit**: Kann zu repetitiven Mustern führen, wenn nicht genug Variation eingebaut wird
- **Speicherbedarf**: Alle Events müssen gespeichert werden für die Historie
- **Schwierige Parallelität**: Events müssen sequenziell verarbeitet werden
- **Künstliche Granularität**: Die Wahl der Zeitschritte beeinflusst stark das Ergebnis
### Ansatz 2: **Agent-Based Simulation**
#### Konzept
Bei der Agent-Based Simulation ist jeder Charakter ein völlig autonomer "Agent" mit eigenem Entscheidungssystem. Jeder Agent hat Bedürfnisse (Hunger, Schlaf, Soziales), Ziele (kurzfristig und langfristig), Erinnerungen und Beziehungen. Die Simulation läuft, indem jeder Agent kontinuierlich seine Umgebung wahrnimmt, seine Situation bewertet und Entscheidungen trifft.
Das Besondere: Es gibt keine zentrale Kontrolle. Die Welt entwickelt sich durch das Zusammenspiel aller autonomen Agenten. Jeder Agent durchläuft einen Zyklus:
1. **Wahrnehmung**: Was passiert um mich herum? Wer ist in der Nähe?
2. **Bewertung**: Wie geht es mir? Was brauche ich am dringendsten?
3. **Planung**: Was sollte ich als nächstes tun?
4. **Ausführung**: Die gewählte Aktion durchführen
5. **Lernen**: Aus dem Ergebnis lernen und Verhalten anpassen
#### Systeme pro Agent
- **Bedürfnissystem**: Hunger, Durst, Schlaf, Sicherheit, Soziales, Selbstverwirklichung - alle verfallen über Zeit und beeinflussen Entscheidungen
- **Zielsystem**: Kurz-, mittel- und langfristige Ziele mit Prioritäten und Fortschrittstracking
- **Erinnerungssystem**: Wichtige Ereignisse werden gespeichert und beeinflussen zukünftige Entscheidungen
- **Beziehungssystem**: Dynamische Beziehungen zu anderen Agenten mit Vertrauen, Zuneigung, Respekt
- **Emotionssystem**: Aktuelle Stimmung beeinflusst Entscheidungen und Interaktionen
- **Fähigkeitssystem**: Was kann der Agent tun und wie gut?
#### Vorteile
- **Emergente Komplexität**: Aus einfachen Regeln entstehen komplexe, realistische Verhaltensweisen
- **Natürliche Interaktionen**: Charaktere reagieren organisch aufeinander
- **Individuelle Persönlichkeiten**: Jeder Agent verhält sich einzigartig
- **Lernfähigkeit**: Agenten können aus Erfahrungen lernen und sich entwickeln
- **Parallelisierbar**: Agenten können gleichzeitig berechnet werden
- **Realistische Entscheidungen**: Berücksichtigt multiple Faktoren wie Bedürfnisse, Ziele, Emotionen
- **Dynamische Anpassung**: Agenten passen sich an veränderte Umstände an
#### Nachteile
- **Rechenintensiv**: Jeder Agent braucht kontinuierliche Berechnung
- **Schwer vorhersagbar**: Emergentes Verhalten kann zu unerwarteten Ergebnissen führen
- **Komplexes Balancing**: Schwierig, alle Systeme gut aufeinander abzustimmen
- **Debugging-Herausforderung**: Bei Fehlverhalten ist schwer nachzuvollziehen, warum
- **Potentielles Chaos**: Ohne Einschränkungen können unrealistische Situationen entstehen
- **Speicherintensiv**: Jeder Agent braucht viel Zustandsinformation
- **Schwierige Kontrolle**: Autor hat weniger direkte Kontrolle über Ereignisse
### Ansatz 3: **Narrative Graph Simulation**
#### Konzept
Die Narrative Graph Simulation modelliert Zeit als einen Graphen von möglichen Story-Pfaden. Anstatt einzelne Aktionen zu simulieren, arbeitet das System mit narrativen "Beats" - bedeutsamen Momenten, die die Geschichte vorantreiben. Das System bewertet verschiedene mögliche Entwicklungen nach ihrer narrativen Qualität und wählt den interessantesten Pfad.
Das System denkt wie ein Geschichtenerzähler:
1. **Beat-Generierung**: Welche interessanten Dinge könnten als nächstes passieren?
2. **Plausibilitätsbewertung**: Wie wahrscheinlich ist jeder Beat basierend auf Charakteren und Kontext?
3. **Interessantheitsbewertung**: Wie spannend/bedeutsam wäre dieser Beat für die Geschichte?
4. **Konsistenzbewertung**: Passt dieser Beat zu dem, was bisher passiert ist?
5. **Pfadauswahl**: Wähle den optimalen Pfad durch die möglichen Beats
6. **Elaboration**: Fülle die gewählten Beats mit Details
#### Story-Beat Kategorien
- **Routine-Beats**: Normale Tagesabläufe, die Charaktere etablieren
- **Konflikt-Beats**: Spannungen, Streitigkeiten, Kämpfe
- **Entdeckungs-Beats**: Geheimnisse werden gelüftet, Objekte gefunden
- **Beziehungs-Beats**: Entwicklungen zwischen Charakteren
- **Twist-Beats**: Überraschende Wendungen
- **Entwicklungs-Beats**: Charakterentwicklung, Lernen, Wachstum
- **Atmosphären-Beats**: Stimmungsvolle Momente ohne direkte Action
#### Vorteile
- **Narrativ fokussiert**: Garantiert interessante Geschichten
- **Dramaturgische Qualität**: Berücksichtigt Spannungsbogen und Pacing
- **Genrekonfom**: Kann auf bestimmte Genres optimiert werden
- **Autorenkontrolle**: Autor kann narrative Präferenzen einstellen
- **Effizient**: Überspringt langweilige Details
- **Kohärente Geschichten**: Achtet auf narrativen Zusammenhang
- **Thematische Konsistenz**: Kann Themen und Motive durchziehen
#### Nachteile
- **Weniger Realismus**: Priorisiert Drama über Realismus
- **Künstlich**: Kann sich "geschrieben" anfühlen statt organisch
- **Weniger Überraschungen**: Tendiert zu konventionellen Narrativen
- **Schwierige Balance**: Zwischen Interessantheit und Plausibilität
- **Genre-Bias**: Funktioniert besser für manche Genres als andere
- **Weniger Details**: Alltägliches wird oft übersprungen
- **Autorabhängig**: Qualität hängt stark von Konfiguration ab
### Ansatz 4: **Probability-Based Simulation**
#### Konzept
Die Probability-Based Simulation arbeitet mit Wahrscheinlichkeiten. Jedes mögliche Ereignis hat eine Basiswahrscheinlichkeit, die durch verschiedene Faktoren modifiziert wird: Charaktereigenschaften, aktuelle Situation, Tageszeit, Beziehungen, kürzliche Ereignisse. Das System "würfelt" dann, welche Ereignisse tatsächlich eintreten.
Der Prozess:
1. **Wahrscheinlichkeitsberechnung**: Für jeden Charakter und jede mögliche Aktion wird eine Wahrscheinlichkeit berechnet
2. **Modifikation**: Umstände erhöhen oder senken Wahrscheinlichkeiten
3. **Würfeln**: Zufallsgenerator entscheidet basierend auf Wahrscheinlichkeiten
4. **Konsequenzen**: Eingetretene Ereignisse verändern Wahrscheinlichkeiten für zukünftige Ereignisse
5. **Anpassung**: System lernt aus Mustern und passt Basiswahrscheinlichkeiten an
#### Wahrscheinlichkeitsfaktoren
- **Persönlichkeit**: Introvertierte haben geringere Wahrscheinlichkeit für soziale Events
- **Tageszeit**: Schlaf ist nachts wahrscheinlicher als mittags
- **Bedürfnisse**: Hunger erhöht Wahrscheinlichkeit für Essen
- **Routine**: Gewohnheiten haben höhere Wahrscheinlichkeit
- **Beziehungen**: Freunde treffen sich wahrscheinlicher als Fremde
- **Kontext**: Regen senkt Wahrscheinlichkeit für Outdoor-Aktivitäten
- **Geschichte**: Kürzliche Ereignisse beeinflussen zukünftige Wahrscheinlichkeiten
#### Vorteile
- **Natürliche Variation**: Realistische Mischung aus Routine und Überraschung
- **Einfach erweiterbar**: Neue Ereignisse sind nur neue Wahrscheinlichkeiten
- **Gut konfigurierbar**: Wahrscheinlichkeiten können fein eingestellt werden
- **Reproduzierbar**: Mit gleichem Seed entstehen gleiche Ergebnisse
- **Intuitiv verständlich**: Wahrscheinlichkeiten sind leicht nachvollziehbar
- **Flexible Zufälligkeit**: Grad der Zufälligkeit einstellbar
- **Effiziente Berechnung**: Nur Wahrscheinlichkeiten, keine komplexe Logik
#### Nachteile
- **Zufallsabhängig**: Kann zu unlogischen Sequenzen führen
- **Schwieriges Tuning**: Richtige Wahrscheinlichkeiten zu finden ist aufwändig
- **Keine Garantien**: Wichtige Events könnten nicht eintreten
- **Statistische Anomalien**: Extrem unwahrscheinliche Ereignisketten möglich
- **Wenig Kausalität**: Zusammenhänge zwischen Events nicht explizit
- **Balancing-Problem**: Zu viele Faktoren beeinflussen sich gegenseitig
- **Schwer zu debuggen**: Warum wurde gerade dieses Event gewürfelt?
## 📅 Zeitsysteme und Granularität
### Zeitgranularität-Ebenen
Das System muss verschiedene Zeitskalen handhaben können:
- **Minuten-Ebene**: Für intensive Szenen, Kämpfe, wichtige Gespräche. Jede Minute wird detailliert simuliert.
- **Stunden-Ebene**: Standard für normale Tagesabläufe. Aktivitäten in Stundenblöcken.
- **Tages-Ebene**: Für Zeitsprünge. Zusammenfassungen der wichtigsten Tagesereignisse.
- **Wochen-Ebene**: Für längere Entwicklungen. Fokus auf bedeutende Veränderungen.
- **Monats-Ebene**: Für Jahreszeiten und längere Projekte.
- **Jahres-Ebene**: Für epochale Veränderungen und Generationswechsel.
Das System wählt automatisch die passende Granularität basierend auf der zu simulierenden Zeitspanne und der Wichtigkeit der Ereignisse.
### Zeitfluss-Modi
- **Echtzeit**: Eine Minute Simulation = eine Minute in der Welt
- **Beschleunigt**: Typisch 60x - eine Minute Simulation = eine Stunde Weltzeit
- **Zeitsprung**: 1440x oder mehr - ganze Tage in Sekunden
- **Fokussiert**: Normale Geschwindigkeit für Hauptcharaktere, beschleunigt für Nebencharaktere
- **Ereignisgesteuert**: Zeit springt zum nächsten wichtigen Ereignis
## 🎭 Character Activity System
### Tagesroutinen
Jeder Charakter hat eine Grundroutine basierend auf:
- **Beruf/Rolle**: Bestimmt Hauptaktivitäten (Schmied -> Schmieden, Wache -> Patrouillieren)
- **Persönlichkeit**: Beeinflusst Timing und Prioritäten (Frühaufsteher vs. Nachteule)
- **Bedürfnisse**: Grundbedürfnisse müssen erfüllt werden (Essen, Schlafen)
- **Verpflichtungen**: Familiäre und soziale Verpflichtungen
- **Ziele**: Langfristige Ziele beeinflussen tägliche Aktivitäten
- **Jahreszeit**: Winter vs. Sommer verändert Aktivitäten
- **Wochentag**: Werktage vs. Feiertage
### Aktivitätsgenerierung
Das System generiert Aktivitäten durch:
1. **Basis-Template**: Grundgerüst basierend auf Rolle
2. **Persönlichkeits-Modifikation**: Anpassung an Charaktereigenschaften
3. **Kontext-Berücksichtigung**: Aktuelle Ereignisse und Umstände
4. **Bedürfnis-Priorisierung**: Dringende Bedürfnisse first
5. **Zufalls-Element**: Kleine Variationen für Realismus
6. **Interaktions-Möglichkeiten**: Wo könnten andere Charaktere getroffen werden?
## 🌊 Ripple Effects & Kausalität
### Ereignis-Kaskaden
Jedes Ereignis kann Folgen haben:
- **Direkte Konsequenzen**: Unmittelbare Auswirkungen
- **Sekundäre Effekte**: Reaktionen anderer Charaktere
- **Emotionale Wellen**: Stimmungsänderungen breiten sich aus
- **Informationsfluss**: Nachrichten und Gerüchte verbreiten sich
- **Wirtschaftliche Auswirkungen**: Märkte reagieren auf Ereignisse
- **Politische Folgen**: Machtverschiebungen
- **Langzeitkonsequenzen**: Verzögerte Auswirkungen
### Der Butterfly-Effekt
Kleine Ereignisse können große Auswirkungen haben:
- Ein zufälliges Treffen führt zu einer Romanze
- Ein verlorenes Objekt löst eine Questkette aus
- Ein Missverständnis eskaliert zum Krieg
- Eine kleine Hilfe wird später großzügig belohnt
Das System trackt diese Verbindungen und kann zeigen, wie aus kleinen Ursachen große Wirkungen entstehen.
## 🎨 User Interface Konzepte
### Time Control Panel
Ein zentrales Kontrollelement für die Zeitsimulation:
- Play/Pause/Stop Kontrollen
- Geschwindigkeitsregler (1x bis 1000x)
- Schnellzugriff für häufige Zeitsprünge (1 Stunde, 1 Tag, 1 Woche)
- Simulations-Einstellungen (Detailgrad, Fokus, Zufälligkeit)
- Vorschau kommender Events
### Timeline Viewer
Chronologische Darstellung aller Ereignisse:
- Farbcodierung nach Event-Typ
- Filteroptionen nach Charakter, Ort, Event-Typ
- Zoom-Funktion für verschiedene Zeitskalen
- Verbindungslinien zeigen Kausalitäten
- Hover für Details, Klick für vollständige Ansicht
### Character Day Summary
Übersicht über den Tag eines Charakters:
- Besuchte Orte mit Verweildauer
- Alle Interaktionen mit anderen Charakteren
- Emotionaler Verlauf als Graph
- Wichtigste Ereignisse hervorgehoben
- Gedanken und Pläne des Charakters
- Option, Details in Story zu übernehmen
### World State Dashboard
Globale Übersicht über Weltveränderungen:
- Große Ereignisse der Simulationsperiode
- Statistiken (Wirtschaft, Politik, Stimmung)
- Beziehungsveränderungen
- Machtverschiebungen
- Unerwartete Wendungen
- Warnungen bei Inkonsistenzen
## 🚀 Implementierungs-Empfehlung
### Hybrid-Ansatz
Die beste Lösung kombiniert mehrere Ansätze:
1. **Event-Driven als Basis**: Für klare Struktur und Nachvollziehbarkeit
2. **Agent-Based für Charaktere**: Für realistische individuelle Entscheidungen
3. **Narrative Graph für Highlights**: Um interessante Story-Momente zu garantieren
4. **Probability für Variation**: Um Überraschungen und Realismus einzubauen
### Phasenweise Einführung
**Phase 1 - Grundlagen (MVP)**:
- Einfache Event-Driven Simulation
- Basis-Tagesroutinen
- Simple Kollisionserkennung
- Grundlegende UI
**Phase 2 - Intelligenz**:
- Agent-Systeme für Hauptcharaktere
- Bedürfnisse und Ziele
- Emotionale Reaktionen
- Verbesserte Interaktionen
**Phase 3 - Narrative Qualität**:
- Story-Beat Erkennung
- Dramaturgische Optimierung
- Thematische Kohärenz
- Genrespezifische Anpassungen
**Phase 4 - Komplexität**:
- Ripple Effects
- Butterfly-Effekt Tracking
- Wirtschaftssimulation
- Politische Dynamiken
## 💡 Innovative Features
### Temporal Anchors
Bestimmte Ereignisse sind "verankert" und müssen zu bestimmten Zeiten eintreten. Das System arbeitet rückwärts und vorwärts, um sicherzustellen, dass diese Ankerpunkte erreicht werden, während der Weg dorthin organisch bleibt.
### Quantum Branching
Das System kann mehrere mögliche Zukünfte parallel simulieren und dem Autor zeigen, welche verschiedenen Entwicklungen möglich sind. Besonders nützlich für "Was wäre wenn"-Szenarien.
### Retroactive Continuity
Änderungen in der Vergangenheit können durchgespielt werden, um zu sehen, wie sie die Gegenwart beeinflussen würden. Das System berechnet neu ab dem Änderungspunkt und zeigt die Unterschiede.
### Memory Persistence
Charaktere erinnern sich an vergangene Ereignisse und diese beeinflussen ihr zukünftiges Verhalten. Ein Charakter, der betrogen wurde, wird misstrauischer. Jemand, der Hilfe erfahren hat, wird dankbar sein.
## 📊 Qualitätsmetriken
### Konsistenz-Metriken
- Charakterkonsistenz: Verhalten sich Charaktere ihrer Persönlichkeit entsprechend?
- Weltkonsistenz: Bleiben physikalische und soziale Regeln erhalten?
- Timeline-Konsistenz: Gibt es zeitliche Widersprüche?
### Interessantheits-Metriken
- Event-Vielfalt: Wie abwechslungsreich sind die Ereignisse?
- Überraschungsindex: Wie oft passiert Unerwartetes?
- Narrative Spannung: Gibt es Höhen und Tiefen?
### Realismus-Metriken
- Plausibilität: Sind die Ereignisse glaubwürdig?
- Bedürfniserfüllung: Werden Grundbedürfnisse realistisch befriedigt?
- Soziale Dynamik: Sind Interaktionen natürlich?
## Fazit
Das Time Simulation System verwandelt statische Welten in lebendige Ökosysteme. Durch die intelligente Kombination verschiedener Simulationsansätze entsteht ein System, das sowohl realistische als auch narrativ interessante Ergebnisse liefert. Autoren erhalten ein mächtiges Werkzeug, um ihre Welten mit Leben zu füllen und neue Geschichten zu entdecken, die organisch aus der Interaktion ihrer Charaktere entstehen.
Die wahre Magie liegt in der Balance: Genug Struktur für Konsistenz, genug Freiheit für Überraschungen, genug Intelligenz für Realismus und genug narrative Führung für packende Geschichten.

View file

@ -0,0 +1,338 @@
# Custom Fields Implementation
## Überblick
Das Custom Fields System wurde als erste Phase der flexiblen Mechaniken-Erweiterung für Worldream implementiert. Es ermöglicht Nutzern, eigene strukturierte Datenfelder zu beliebigen Content Nodes (Charaktere, Objekte, Orte, Geschichten) hinzuzufügen.
## Architektur
### Datenbankstruktur
Die Implementierung nutzt PostgreSQL's JSONB-Felder für maximale Flexibilität:
```sql
-- In content_nodes Tabelle
custom_schema JSONB -- Feld-Definitionen
custom_data JSONB -- Tatsächliche Werte
schema_version INTEGER -- Versionierung für Migrationen
-- Separate Tabelle für Templates
custom_field_templates -- Wiederverwendbare Feld-Konfigurationen
```
**Designentscheidung**: JSONB wurde gewählt, da es:
- Flexible Schema-Evolution ohne Migrationen ermöglicht
- Effiziente Queries und Indexierung unterstützt
- Type-Safety auf Anwendungsebene erlaubt
- Einfaches Backup und Export ermöglicht
### Type System
Das TypeScript Type System (`/src/lib/types/customFields.ts`) definiert 11 Feldtypen:
1. **text** - Ein- oder mehrzeiliger Text mit optionaler Längen-Validierung
2. **number** - Numerische Werte mit Min/Max und Einheiten
3. **range** - Slider für Werte in einem bestimmten Bereich
4. **select** - Dropdown mit vordefinierten Optionen
5. **multiselect** - Mehrfachauswahl aus Optionen
6. **boolean** - Ja/Nein Checkbox
7. **date** - Datumseingabe
8. **formula** - Berechnete Felder basierend auf anderen Feldern
9. **reference** - Verweise auf andere Nodes (@slug)
10. **list** - Dynamische Listen von Elementen
11. **json** - Strukturierte JSON-Daten für komplexe Konfigurationen
### Komponenten-Architektur
```
CustomFieldsManager.svelte (Haupt-Container)
├── Tab: Daten
│ └── CustomDataForm.svelte (Formular-Rendering)
├── Tab: Schema
│ └── FieldDefinitionEditor.svelte (Feld-Editor)
└── Tab: Templates
└── Template-Auswahl und -Anwendung
CustomFieldsDisplay.svelte (Read-Only Anzeige)
└── Kategorisierte Feld-Darstellung mit speziellen Visualisierungen
```
## Implementierungsdetails
### 1. Schema-Definition
Jedes Feld wird durch eine `CustomFieldDefinition` beschrieben:
```typescript
interface CustomFieldDefinition {
id: string; // Eindeutige ID
key: string; // Technischer Schlüssel (z.B. "health_points")
label: string; // Anzeigename (z.B. "Lebenspunkte")
type: FieldType; // Feldtyp
description?: string; // Hilfetext
category?: string; // Gruppierung (z.B. "Kampfwerte")
required?: boolean; // Pflichtfeld
config: FieldConfig; // Typ-spezifische Konfiguration
}
```
### 2. Dynamisches Form-Rendering
`CustomDataForm.svelte` generiert zur Laufzeit Formulare basierend auf dem Schema:
```typescript
// Für jedes Feld im Schema
for (const field of schema.fields) {
// Rendere passendes Input-Element basierend auf field.type
switch(field.type) {
case 'text': renderTextField(field, value);
case 'number': renderNumberField(field, value);
// ... weitere Typen
}
}
```
**Besonderheiten**:
- Echtzeit-Validierung basierend auf Feld-Konfiguration
- Abhängigkeits-Tracking für Formula-Felder
- Kategorisierte Darstellung für bessere UX
### 3. Template-System
Templates lösen das "Cold Start" Problem:
```typescript
interface CustomFieldTemplate {
id: string;
name: string;
description: string;
applicable_to: string[]; // ['character', 'object', etc.]
fields: CustomFieldDefinition[];
tags: string[];
is_public: boolean;
usage_count: number;
}
```
**Mitgelieferte Templates**:
- **Basic Stats**: Grundlegende Attribute (Stärke, Geschicklichkeit, etc.)
- **Inventory**: Item-Verwaltung mit Gewicht und Anzahl
- **Relationships**: Beziehungs-Tracking mit Vertrauen und Notizen
### 4. API-Endpoints
```
PUT /api/nodes/[slug]/schema
- Speichert/Aktualisiert Schema
- Validiert Feld-Definitionen
- Erhöht Schema-Version
PUT /api/nodes/[slug]/custom-data
- Speichert Feld-Daten
- Validiert gegen Schema
- Berechnet Formula-Felder
GET /api/templates
- Listet verfügbare Templates
- Filtert nach applicable_to
```
### 5. Sicherheit
- **Row-Level Security (RLS)**: Nur Besitzer können Schema/Daten ändern
- **Validierungsfunktionen**: PostgreSQL-seitige Schema-Validierung
- **Permission Checks**: API prüft Besitz vor Änderungen
## Verwendung
### Als Nutzer
1. **Felder hinzufügen**:
- Navigiere zu einem Node (z.B. Charakter)
- Klicke auf "Bearbeiten"
- Wechsle zum Tab "Benutzerdefinierte Felder"
- Tab "Felder verwalten" → "Neues Feld"
2. **Template anwenden**:
- Tab "Vorlagen"
- Wähle passendes Template
- Klicke "Anwenden"
3. **Daten eingeben**:
- Tab "Daten"
- Fülle Felder aus
- Speichern
### Als Entwickler
```typescript
// Schema abrufen
const response = await fetch(`/api/nodes/${slug}/schema`);
const { schema } = await response.json();
// Daten speichern
await fetch(`/api/nodes/${slug}/custom-data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
health_points: 100,
strength: 15
}
})
});
```
## Technische Entscheidungen
### Warum JSONB statt separate Tabellen?
**Vorteile**:
- Keine Schema-Migrationen bei neuen Feldtypen
- Einfacher Import/Export
- Flexible Struktur-Evolution
- Atomare Updates
**Nachteile** (und Lösungen):
- Keine SQL-Constraints → Anwendungs-Validierung
- Komplexere Queries → PostgreSQL JSON-Funktionen
- Type-Safety → TypeScript + Validierung
### Warum Svelte 5 Runes?
```svelte
<!-- Alte Syntax -->
<script>
let value = '';
$: validated = validateField(value);
</script>
<!-- Neue Syntax mit Runes -->
<script lang="ts">
let value = $state('');
let validated = $derived(validateField(value));
</script>
```
**Vorteile**:
- Bessere TypeScript-Integration
- Klarere Reaktivitäts-Semantik
- Zukunftssicher für Svelte 5+
### Warum keine Formula-Evaluation im Frontend?
**Sicherheit**: Formula-Evaluation erfolgt später serverseitig
**Performance**: Verhindert DoS durch komplexe Formeln
**Konsistenz**: Zentrale Berechnung vermeidet Inkonsistenzen
## Performance-Optimierungen
1. **Lazy Loading**: Templates werden nur bei Bedarf geladen
2. **Debounced Saves**: Auto-Save mit 500ms Verzögerung
3. **Field Grouping**: Kategorien reduzieren visuelle Komplexität
4. **Selective Rendering**: Nur sichtbare Tabs werden gerendert
## Erweiterungsmöglichkeiten
### Kurzfristig (Phase 2)
- Formula-Evaluation implementieren
- Erweiterte Validierungs-Regeln
- Bulk-Operations für mehrere Nodes
- Import/Export von Schemas
### Mittelfristig (Phase 3-4)
- Visuelle Schema-Designer
- Abhängige Felder (Conditional Logic)
- Berechnete Aggregationen
- Schema-Vererbung
### Langfristig
- KI-generierte Schemas basierend auf Beschreibungen
- Cross-Node Formeln
- Versionierte Daten-Historie
- GraphQL-API für Custom Fields
## Bekannte Einschränkungen
1. **Formula-Felder**: Noch nicht funktional (Platzhalter)
2. **Reference-Felder**: Einfache Text-Eingabe statt Node-Picker
3. **Schema-Migration**: Manuelle Daten-Anpassung bei Schema-Änderungen
4. **Performance**: Bei >100 Feldern merkbare Verzögerung
5. **Mobile UX**: Tabs nicht optimal auf kleinen Bildschirmen
## Testing
### Unit Tests (geplant)
```typescript
// Validierung
expect(validateFieldKey('health_points')).toBe(true);
expect(validateFieldKey('Health Points')).toBe(false);
// Schema-Erstellung
const schema = createEmptySchema();
expect(schema.fields).toHaveLength(0);
expect(schema.version).toBe(1);
```
### Integration Tests (geplant)
- Schema-Speicherung und -Abruf
- Daten-Validierung gegen Schema
- Template-Anwendung
- Permission-Checks
## Migration von Legacy-Daten
Falls zukünftig Daten aus anderen Systemen importiert werden:
```typescript
// Migration Helper (Beispiel)
function migrateToCustomFields(legacyData: any): CustomFieldData {
return {
// Map alte Felder zu neuen Keys
health_points: legacyData.hp || 100,
strength: legacyData.str || 10,
// ...
};
}
```
## Troubleshooting
### "Cannot apply unknown utility class"
**Problem**: Tailwind-Theme-Klassen nicht gefunden
**Lösung**: Verwende korrekte Theme-Präfixe (`bg-theme-bg-surface` statt `bg-theme-surface`)
### Schema wird nicht gespeichert
**Mögliche Ursachen**:
1. Fehlende Authentifizierung
2. RLS-Policy blockiert
3. Ungültiges Schema-Format
**Debug**:
```typescript
// Prüfe Response
const response = await fetch(...);
if (!response.ok) {
const error = await response.json();
console.error('Schema save failed:', error);
}
```
### Felder werden nicht angezeigt
**Checkliste**:
1. Schema erfolgreich geladen?
2. Daten vorhanden?
3. Kategorien korrekt zugeordnet?
4. Komponente korrekt importiert?
## Zusammenfassung
Das Custom Fields System bietet eine solide Grundlage für flexible, nutzerdefinierte Mechaniken in Worldream. Die JSONB-basierte Architektur ermöglicht schnelle Iteration und Erweiterung ohne Datenbankmigrationen. Mit 11 Feldtypen und einem Template-System können Nutzer sofort produktiv werden.
Die nächsten Schritte fokussieren sich auf:
1. Formula-Evaluation
2. Verbesserte Reference-Felder
3. Mobile Optimierung
4. Performance bei großen Schemas
Das System ist bewusst einfach gehalten, um schnelles Feedback zu ermöglichen und die Richtung basierend auf Nutzer-Bedürfnissen anzupassen.

View file

@ -0,0 +1,608 @@
# Custom Mechanics System - Konzeptbericht
## Executive Summary
Worldream kann von einer reinen Text-Plattform zu einem flexiblen System erweitert werden, das es Nutzern ermöglicht, eigene Mechaniken und Regelsysteme zu erstellen. Dieser Bericht stellt verschiedene Ansätze vor, die es ermöglichen würden, beliebige Spielmechaniken, Progressionssysteme und Weltregeln zu implementieren, ohne die Einfachheit und Flexibilität der Plattform zu opfern.
## Problemstellung
Aktuelle Weltenbau-Tools fallen typischerweise in zwei Extreme:
1. **Zu simpel**: Reine Text-Editoren ohne strukturierte Daten oder Mechaniken
2. **Zu starr**: Vordefinierte Systeme (D&D, Pathfinder), die Nutzer in bestimmte Regelwerke zwingen
Worldream hat die Chance, die goldene Mitte zu finden: Eine Plattform, die es Nutzern erlaubt, ihre eigenen Mechaniken zu definieren, während sie gleichzeitig von der Arbeit anderer profitieren können.
## Kernkonzepte
### 1. Custom Fields System - Das Fundament
#### Überblick
Das Custom Fields System bildet die Basis aller erweiterten Mechaniken. Es erlaubt Nutzern, eigene strukturierte Datenfelder zu beliebigen Content Nodes hinzuzufügen.
#### Funktionsweise
Nutzer können für jeden Node-Typ (Character, Object, Place, Story, World) eigene Felder definieren. Diese Felder sind typisiert und können verschiedene Formen annehmen:
- **Text-Felder**: Für kurze oder lange Textinhalte
- **Zahlen-Felder**: Integer oder Dezimalzahlen mit optionalen Min/Max-Werten
- **Bereichs-Felder**: Slider zwischen zwei Werten (z.B. 0-100 für Prozentwerte)
- **Auswahl-Felder**: Dropdown oder Radio-Buttons mit vordefinierten Optionen
- **Formel-Felder**: Berechnete Werte basierend auf anderen Feldern
- **Referenz-Felder**: Verweise auf andere Nodes (z.B. "Heimatort" → Place-Node)
#### Anwendungsbeispiele
**Fantasy-RPG Charakter:**
- Stärke: Zahl (3-18)
- Klasse: Auswahl (Krieger, Magier, Schurke)
- Trefferpunkte: Formel (Konstitution × 10 + Level × 5)
- Heimat: Referenz auf Place-Node
**Sci-Fi Raumschiff:**
- Hüllenstärke: Bereich (0-100%)
- Antriebstyp: Auswahl (Warp, Hyperraum, Subraum)
- Crew-Kapazität: Zahl (1-10000)
- Energieverbrauch: Formel (Antrieb × 2 + Waffen + Schilde)
#### Kategorisierung und Organisation
Felder können in logische Gruppen organisiert werden:
- Kampfwerte (Angriff, Verteidigung, Initiative)
- Soziale Attribute (Charisma, Reputation, Einfluss)
- Ressourcen (Gold, Munition, Treibstoff)
Diese Kategorien helfen bei der Übersichtlichkeit, besonders wenn Dutzende Custom Fields existieren.
### 2. Rule Templates - Vorgefertigte Systeme
#### Konzept
Rule Templates sind kuratierte Sammlungen von Custom Fields, Berechnungen und Regeln, die als Paket importiert werden können. Sie lösen das "Kaltstartproblem" - neue Nutzer müssen nicht bei Null anfangen.
#### Template-Struktur
Ein Template enthält:
- **Metadaten**: Name, Beschreibung, Autor, Version
- **Feld-Definitionen**: Alle Custom Fields des Systems
- **Berechnungsregeln**: Formeln und Abhängigkeiten
- **Standardwerte**: Sinnvolle Ausgangswerte
- **Dokumentation**: Erklärungen zur Nutzung
- **Beispiel-Content**: Optional vorgefertigte Charaktere/Objekte
#### Community-Aspekt
Templates können in einer öffentlichen Bibliothek geteilt werden:
**Offizielle Templates:**
- "Worldream Starter" - Einfache Basis-Mechaniken
- "Narrative Focus" - Für story-zentrierte Welten
- "Tactical Combat" - Detaillierte Kampfmechaniken
**Community Templates:**
- "Lovecraftian Horror" - Wahnsinn, Okkultismus, Verbotenes Wissen
- "Political Intrigue" - Einfluss, Loyalität, Geheimnisse
- "Hard Sci-Fi" - Realistische Physik, Ressourcenmanagement
- "Superhelden" - Kräfte, Schwächen, Geheimidentitäten
#### Evolution und Anpassung
Templates sind nicht statisch:
- Nutzer können importierte Templates modifizieren
- Modifizierte Versionen können als neue Templates geteilt werden
- Versionierung ermöglicht Updates ohne Datenverlust
- "Forking" erlaubt Varianten (z.B. "D&D 5e - Grimdark Edition")
### 3. Dynamic Traits System
#### Grundkonzept
Traits sind mehr als simple Werte - sie sind lebendige Eigenschaften, die sich entwickeln, interagieren und die Story beeinflussen.
#### Trait-Typen
**Skills (Fähigkeiten):**
- Haben Erfahrungsstufen (Anfänger → Meister)
- Können durch Übung verbessert werden
- Verfallen möglicherweise ohne Nutzung
- Beispiel: "Bogenschießen", "Diplomatie", "Hacken"
**Attributes (Attribute):**
- Grundlegende Charaktereigenschaften
- Beeinflussen andere Werte
- Ändern sich selten
- Beispiel: "Intelligenz", "Ausdauer", "Willenskraft"
**Resources (Ressourcen):**
- Verbrauchbare oder regenerierende Werte
- Haben Maximum und aktuellen Wert
- Beispiel: "Mana", "Reputation", "Sanity"
**States (Zustände):**
- Temporäre Bedingungen
- Haben Auslöser und Dauer
- Beispiel: "Vergiftet", "Inspiriert", "Erschöpft"
**Relationships (Beziehungen):**
- Verbindungen zu anderen Entities
- Mehrdimensional und dynamisch
- Beispiel: "Mentor von X", "Rivale von Y"
#### Progression und Entwicklung
**Erfahrungsbasiert:**
- Traits verbessern sich durch Nutzung
- "Schwertkampf" steigt nach 10 erfolgreichen Kämpfen
- Realistische Lernkurven (schnell am Anfang, langsamer später)
**Meilenstein-basiert:**
- Große Sprünge bei bestimmten Ereignissen
- "Magieresistenz" nach Überleben eines Drachenangriffs
- Story-relevante Entwicklungen
**Verfall und Verlust:**
- Ungenutzte Skills können schwächer werden
- Traumatische Ereignisse können Traits reduzieren
- Alter oder Verletzungen beeinflussen physische Traits
#### Abhängigkeiten und Synergien
**Voraussetzungen:**
- "Fortgeschrittene Magie" benötigt "Grundlegende Magie" Level 5
- "Adelstitel" benötigt "Reputation" > 75
**Modifikatoren:**
- "Müdigkeit" reduziert alle physischen Skills um 20%
- "Gesegnete Waffe" erhöht "Schwertkampf" um +3
- "Mentor" verdoppelt Lerngeschwindigkeit
**Kombinationen:**
- "Akrobatik" + "Schwertkampf" = Spezialangriff verfügbar
- "Alchemie" + "Kochen" = Kann magische Speisen herstellen
### 4. State Machines - Zustandsautomaten
#### Konzept
State Machines modellieren komplexe Zustände und deren Übergänge. Sie sind ideal für alles, was klare Phasen oder Stadien durchläuft.
#### Anwendungsbereiche
**Charakter-Loyalität:**
```
Feindlich → Misstrauisch → Neutral → Freundlich → Loyal → Ergeben
```
Übergänge durch: Geschenke, gemeinsame Quests, Verrat, Zeit
**Quest-Fortschritt:**
```
Unbekannt → Gerücht → Entdeckt → Angenommen → In Arbeit → Fast fertig → Abgeschlossen
Gescheitert → Wiederholt
```
**Objekt-Zustände:**
```
Magisches Schwert:
Versiegelt → Erwachend → Aktiv → Überladen → Ausgebrannt
Korrumpiert → Gereinigt
```
**Beziehungs-Dynamik:**
```
Fremde → Bekannte → Freunde → Beste Freunde
↓ ↓
Liebende Zerstritten → Versöhnt
```
#### Zustandseigenschaften
Jeder Zustand kann eigene Eigenschaften haben:
**Loyalität "Ergeben":**
- Befolgt alle Befehle automatisch
- Teilt alle Geheimnisse
- Kämpft bis zum Tod
- Immun gegen Bestechung
**Quest "Fast fertig":**
- Finale Konfrontation verfügbar
- Keine neuen Nebenquests
- Zeitdruck erhöht
- Belohnung vorbereitet
#### Übergangsbedingungen
**Einfache Trigger:**
- Zeit vergangen (3 Tage im Zustand)
- Aktion ausgeführt (Geschenk gegeben)
- Schwellwert erreicht (Reputation > 50)
**Komplexe Bedingungen:**
- Mehrere Anforderungen (Gold > 100 UND Quest erledigt)
- Wahrscheinlichkeiten (30% Chance bei jedem Gespräch)
- Externe Events (Wenn Krieg ausbricht)
### 5. Goals & Achievements System
#### Zielsetzung
Goals geben Struktur und Richtung. Sie machen Fortschritt messbar und belohnen Spieler/Leser für Engagement.
#### Ziel-Typen
**Persönliche Ziele (Character Goals):**
- "Werde der reichste Händler der Stadt"
- "Räche den Tod meines Vaters"
- "Meistere alle Kampfkünste"
**Welt-Ziele (World Goals):**
- "Verhindere die Apokalypse"
- "Vereinige die zerstrittenen Königreiche"
- "Entdecke den verschollenen Kontinent"
**Meta-Ziele (Reader/Player Goals):**
- "Erkunde alle Locations"
- "Triff alle Charaktere"
- "Enthülle alle Geheimnisse"
#### Fortschrittsverfolgung
**Quantitative Ziele:**
- Sammle 1000 Goldstücke (aktuell: 450/1000)
- Besiege 10 Drachen (aktuell: 3/10)
- Bereise 5 Kontinente (aktuell: 2/5)
**Qualitative Ziele:**
- Meilensteine mit Ja/Nein
- "Finde den verlorenen Tempel" ✓
- "Überzeuge den König" ✗
- "Entschlüssele die Prophezeiung" ✓
**Bedingte Ziele:**
- Erscheinen nur unter bestimmten Umständen
- Versteckte Ziele, die sich erst enthüllen
- Branching paths mit unterschiedlichen Zielen
#### Belohnungssysteme
**Mechanische Belohnungen:**
- Neue Traits oder Skills freischalten
- Stat-Boosts (+5 auf alle Kampfwerte)
- Spezialfähigkeiten oder Items
**Narrative Belohnungen:**
- Neue Story-Pfade öffnen sich
- Charaktere reagieren anders
- Weltveränderungen (neuer König, Frieden)
**Meta-Belohnungen:**
- Achievements/Trophäen
- Freischaltbare Bonus-Inhalte
- Alternative Enden
### 6. Relationship Matrix
#### Mehrdimensionale Beziehungen
Beziehungen sind selten eindimensional. Die Relationship Matrix erlaubt nuancierte Darstellung.
#### Dimensionen
**Klassische Dimensionen:**
- Zuneigung (-100 bis +100)
- Respekt (0 bis 100)
- Vertrauen (0 bis 100)
- Furcht (0 bis 100)
**Situative Dimensionen:**
- Schuld (was schuldet A dem B?)
- Wissen (was weiß A über B?)
- Einfluss (wie sehr kann A B beeinflussen?)
**Kulturelle Dimensionen:**
- Ehre (in Samurai-Settings)
- Blutschuld (in Vampir-Settings)
- Karma (in spirituellen Settings)
#### Asymmetrie und Perspektive
Beziehungen sind oft nicht symmetrisch:
- A vertraut B: 90, B vertraut A: 20
- A fürchtet B: 80, B beachtet A kaum: 10
- A liebt B: 100, B sieht A als Freund: 60
#### Dynamische Entwicklung
**Event-basierte Änderungen:**
- Gemeinsame Schlacht: Vertrauen +20, Respekt +15
- Verrat: Vertrauen -100, Furcht +30, Zuneigung -50
- Geschenk: Zuneigung +10, Schuld +Geschenkwert
**Zeit-basierte Änderungen:**
- Ohne Kontakt sinkt Vertrauen langsam
- Alte Wunden heilen (Zorn -1 pro Monat)
- Gewohnheit steigert Zuneigung
**Schwellenwert-Effekte:**
- Vertrauen > 80: Teilt Geheimnisse
- Furcht > 90: Flieht bei Begegnung
- Respekt < 20 UND Furcht < 30: Wird aggressiv
- Zuneigung > 60 UND Vertrauen > 70: Romantik möglich
### 7. Inventory & Crafting System
#### Inventar-Management
**Slot-basierte Systeme:**
- Ausrüstungsplätze (Kopf, Brust, Hände, etc.)
- Kategorisierte Taschen (Waffen, Tränke, Materialien)
- Begrenzte Kapazität pro Kategorie
**Gewichts-basierte Systeme:**
- Realistisches Gewichtsmanagement
- Traglast basierend auf Stärke
- Überladung reduziert Beweglichkeit
**Abstraktes System:**
- "Bedeutende Gegenstände" ohne Details
- Narrative Freiheit bei Kleinzeug
- Fokus auf story-relevante Items
#### Crafting-Mechaniken
**Rezept-basiert:**
- Feste Kombinationen (Eisen + Kohle = Stahl)
- Qualitätsstufen basierend auf Skill
- Chance auf besondere Eigenschaften
**Experimentell:**
- Freie Kombinationen mit Überraschungen
- Entdeckung neuer Rezepte
- Risiko von Fehlschlägen oder Unfällen
**Narrativ:**
- Crafting als Story-Element
- Quests für seltene Materialien
- Legendäre Schmiede oder Werkstätten
### 8. Magic & Ability Systems
#### Magie-Paradigmen
**Mana-basiert:**
- Klassisches Ressourcen-System
- Regeneration über Zeit oder Ruhe
- Verschiedene Mana-Typen (Feuer, Wasser, etc.)
**Vorbereitung-basiert:**
- Zauber müssen vorbereitet werden
- Begrenzte Slots pro Tag
- Flexibilität vs. Macht
**Risiko-basiert:**
- Magie hat Konsequenzen
- Erschöpfung, Wahnsinn, Corruption
- Große Macht = Großes Risiko
**Komponenten-basiert:**
- Benötigt physische Komponenten
- Seltene Zutaten für mächtige Zauber
- Ökonomischer Aspekt
#### Fähigkeiten-Kombinationen
**Synergie-System:**
- Feuer + Wind = Feuersturm
- Illusion + Telepathie = Falsche Erinnerungen
- Heilung + Nekromantie = Untod verhindern
**Combo-System:**
- Reihenfolgen wichtig
- Schnelligkeit vs. Macht
- Unterbrechungsrisiko
### 9. Timeline & Event System
#### Kalender-Systeme
**Eigene Zeitrechnung:**
- Anpassbare Tage, Wochen, Monate
- Mehrere Monde oder Sonnen
- Kulturelle Unterschiede (Ork-Kalender vs. Elfen-Kalender)
**Event-Planung:**
- Wiederkehrende Ereignisse (Feste, Märkte)
- Einmalige Events (Sonnenfinsternis, Komet)
- Bedingte Events (Wenn X dann Y)
#### Trigger-Mechanismen
**Zeit-Trigger:**
- Nach X Tagen geschieht Y
- Bestimmtes Datum erreicht
- Periodische Events
**Bedingungs-Trigger:**
- Wenn Held Level 10 erreicht
- Wenn zwei Charaktere sich treffen
- Wenn Item gefunden wird
**Kaskaden-Events:**
- Event A löst Event B aus
- Kettenreaktionen möglich
- Butterfly-Effect-Simulation
### 10. Faction & Reputation System
#### Fraktions-Mechaniken
**Fraktions-Eigenschaften:**
- Werte und Ideologie
- Ressourcen und Macht
- Territorium und Einfluss
- Feinde und Verbündete
**Spieler-Interaktion:**
- Reputation pro Fraktion (-100 bis +100)
- Ränge und Titel
- Exklusive Quests und Belohnungen
- Konsequenzen der Zugehörigkeit
**Fraktions-Dynamik:**
- Kriege und Allianzen
- Machtkämpfe intern
- Wirtschaftliche Konkurrenz
- Ideologische Wandel
#### Reputation-Effekte
**Soziale Auswirkungen:**
- NPC-Reaktionen ändern sich
- Preise in Läden variieren
- Zugang zu exklusiven Orten
- Informationsfluss
**Mechanische Auswirkungen:**
- Rekrutierbare Verbündete
- Verfügbare Quests
- Handelsmöglichkeiten
- Sichere Häfen
## Implementierungsstrategie
### Phase 1: Foundation (Monate 1-2)
1. **Custom Fields System** implementieren
2. Basis-UI für Feld-Definition
3. Einfache Formeln und Berechnungen
4. Import/Export als JSON
### Phase 2: Templates (Monate 3-4)
1. Template-Struktur definieren
2. Template-Bibliothek aufbauen
3. Community-Sharing vorbereiten
4. Erste offizielle Templates
### Phase 3: Advanced Mechanics (Monate 5-8)
1. Dynamic Traits System
2. State Machines
3. Relationship Matrix
4. Goals & Achievements
### Phase 4: Specialized Systems (Monate 9-12)
1. Inventory & Crafting
2. Magic & Abilities
3. Timeline & Events
4. Factions & Reputation
### Phase 5: Polish & Integration (Monate 13-14)
1. Visuelle Editoren
2. Performance-Optimierung
3. Tutorial-System
4. Community-Features
## Technische Überlegungen
### Datenbankstruktur
**Erweiterung content_nodes:**
```
- custom_schema: JSONB (Feld-Definitionen)
- custom_data: JSONB (Aktuelle Werte)
- mechanics_template: UUID (Verweis auf Template)
- mechanics_version: INTEGER (Für Updates)
```
**Neue Tabellen:**
```
- mechanics_templates (Template-Bibliothek)
- mechanics_calculations (Formel-Cache)
- mechanics_events (Event-Queue)
- mechanics_history (Änderungsprotokoll)
```
### Performance-Überlegungen
**Caching:**
- Berechnete Werte cachen
- Abhängigkeits-Graph für Updates
- Lazy Loading für komplexe Mechaniken
**Skalierung:**
- Mechaniken optional aktivierbar
- Progressive Enhancement
- Modularer Aufbau
### User Experience
**Onboarding:**
- Wizard für erste Mechaniken
- Template-Empfehlungen basierend auf Genre
- Interaktive Tutorials
**Komplexitäts-Management:**
- Standard/Advanced Modi
- Verstecken ungenutzter Features
- Kontextuelle Hilfe
## Risiken und Herausforderungen
### Komplexitäts-Falle
**Risiko:** System wird zu komplex für Casual-Nutzer
**Mitigation:**
- Klare Trennung zwischen Basic und Advanced
- Templates als Einstiegshilfe
- Progressive Disclosure of Features
### Performance-Probleme
**Risiko:** Viele Berechnungen verlangsamen das System
**Mitigation:**
- Intelligentes Caching
- Background-Processing
- Optimierte Formeln
### Inkonsistenzen
**Risiko:** Nutzer erstellen widersprüchliche Regeln
**Mitigation:**
- Validierungs-System
- Warnings bei Konflikten
- Rollback-Möglichkeiten
### Community-Qualität
**Risiko:** Schlechte Templates überfluten Bibliothek
**Mitigation:**
- Kuratierung und Bewertungen
- Offizielle vs. Community-Trennung
- Qualitäts-Guidelines
## Erfolgskriterien
### Quantitative Metriken
- 50% der Nutzer verwenden mindestens ein Custom Field
- 20% der Nutzer erstellen eigene Templates
- 30% Steigerung der Session-Dauer
- 40% höhere Retention nach 30 Tagen
### Qualitative Ziele
- Nutzer berichten von mehr Kreativität
- Reduzierte Abhängigkeit von externen Tools
- Positive Community-Entwicklung
- Entstehung von Sub-Communities um Templates
## Fazit
Das Custom Mechanics System würde Worldream zu einer einzigartigen Plattform machen, die die Flexibilität eines Text-Editors mit der Struktur eines Regelsystems verbindet. Durch den modularen, community-getriebenen Ansatz kann jeder Nutzer genau die Komplexität wählen, die zu seinem Projekt passt.
Der Schlüssel zum Erfolg liegt in der schrittweisen Implementierung, beginnend mit dem Custom Fields System als solidem Fundament. Von dort aus können komplexere Systeme aufgebaut werden, immer mit dem Fokus auf Nutzerfreundlichkeit und Story-Förderung.
Diese Mechaniken sind nicht nur Features - sie sind Werkzeuge, die Geschichtenerzählern helfen, reichere, konsistentere und interaktivere Welten zu erschaffen. Sie transformieren Worldream von einem Dokumentations-Tool zu einer lebendigen Plattform für kreatives Weltenbau.
## Anhang: Use Cases
### Use Case 1: Fantasy-Autor
Maria schreibt eine Fantasy-Serie und nutzt Worldream für Worldbuilding. Sie importiert das "Classic Fantasy" Template, das grundlegende Stats wie Stärke und Magie enthält. Sie passt es an, fügt eigene Magieformen hinzu und teilt ihr "Elemental Harmony" System mit der Community. Ihre Charaktere entwickeln sich über die Bücher hinweg, und sie trackt deren Fortschritt in Worldream.
### Use Case 2: Pen&Paper Spielleiter
Tom leitet eine Cyberpunk-Kampagne. Er kombiniert das "Cyberpunk 2077" Template mit dem "Corporate Politics" Template. Seine Spieler können ihre Charaktere in Worldream verwalten, während er die Faction-Reputation trackt und Story-Events plant. Das System berechnet automatisch Kampfwerte basierend auf Cyberware-Implantaten.
### Use Case 3: Indie-Game Developer
Alex entwickelt ein narratives Indie-Game. Sie nutzt Worldream für das Narrative Design und exportiert die Mechaniken als JSON für ihre Game-Engine. Die State Machines definieren NPC-Verhalten, während das Goals System die Quest-Struktur vorgibt. Updates in Worldream können direkt ins Spiel importiert werden.
### Use Case 4: Bildungsbereich
Professor Kim nutzt Worldream für historische Simulationen. Studenten erstellen historische Charaktere mit period-appropriate Traits und simulieren politische Entscheidungen. Das Timeline-System hilft, Ursache und Wirkung zu verstehen, während das Faction-System Machtdynamiken visualisiert.
### Use Case 5: Collaborative Storytelling
Eine Online-Community erstellt gemeinsam eine Science-Fiction-Welt. Verschiedene Autoren fügen Charaktere und Orte hinzu, während das Regel-System Konsistenz sicherstellt. Das Reputation-System trackt, wie Charaktere verschiedener Autoren miteinander interagieren. Goals geben der Community gemeinsame Ziele.
---
*Dieser Bericht stellt eine Vision für die Zukunft von Worldream dar. Die Implementierung sollte iterativ erfolgen, mit kontinuierlichem Nutzer-Feedback und Anpassungen basierend auf tatsächlicher Verwendung.*

View file

@ -0,0 +1,513 @@
# Phase 1: Custom Fields System - Implementierungsplan
## Überblick
**Zeitrahmen:** 8 Wochen (2 Monate)
**Ziel:** Implementierung eines voll funktionsfähigen Custom Fields Systems als Fundament für alle weiteren Mechaniken
**Priorität:** Höchste Priorität - alle weiteren Phasen bauen hierauf auf
## Woche 1-2: Datenmodell & Backend-Grundlagen
### Datenbankschema erweitern
#### 1.1 Neue Spalten in `content_nodes`
```sql
ALTER TABLE content_nodes
ADD COLUMN custom_schema JSONB DEFAULT NULL,
ADD COLUMN custom_data JSONB DEFAULT NULL,
ADD COLUMN schema_version INTEGER DEFAULT 1;
```
#### 1.2 Schema-Struktur definieren
```typescript
interface CustomFieldSchema {
version: number;
fields: CustomFieldDefinition[];
categories?: FieldCategory[];
validation_rules?: ValidationRule[];
}
interface CustomFieldDefinition {
id: string;
key: string; // Eindeutiger Schlüssel z.B. "strength"
label: string; // Anzeigename z.B. "Stärke"
type: FieldType;
category?: string;
description?: string;
required?: boolean;
config: FieldConfig;
display?: DisplayConfig;
permissions?: FieldPermissions;
}
type FieldType =
| 'text' // Einfacher Text
| 'number' // Ganzzahl oder Dezimal
| 'range' // Slider zwischen Min/Max
| 'select' // Dropdown-Auswahl
| 'multiselect' // Mehrfachauswahl
| 'boolean' // Ja/Nein
| 'date' // Datum
| 'formula' // Berechnetes Feld
| 'reference' // Verweis auf anderen Node
| 'list' // Array von Werten
| 'json'; // Strukturierte Daten
```
#### 1.3 Migration erstellen
- Migration-Script für bestehende Daten
- Backup-Strategie vor Schema-Änderungen
- Rollback-Plan bei Problemen
### API-Endpoints
#### 1.4 Schema-Management
```
GET /api/nodes/:slug/schema - Schema abrufen
PUT /api/nodes/:slug/schema - Schema aktualisieren
POST /api/nodes/:slug/schema/fields - Feld hinzufügen
DELETE /api/nodes/:slug/schema/fields/:fieldId - Feld entfernen
```
#### 1.5 Daten-Management
```
GET /api/nodes/:slug/custom-data - Custom Data abrufen
PUT /api/nodes/:slug/custom-data - Custom Data aktualisieren
PATCH /api/nodes/:slug/custom-data - Teilupdate
POST /api/nodes/:slug/validate - Daten validieren
```
#### 1.6 Bulk-Operationen
```
POST /api/world/:worldSlug/apply-schema - Schema auf mehrere Nodes anwenden
GET /api/world/:worldSlug/schemas - Alle Schemas einer Welt
POST /api/schemas/import - Schema importieren
GET /api/schemas/export/:nodeSlug - Schema exportieren
```
### Validierung & Sicherheit
#### 1.7 Validierungsregeln
- Typ-Validierung (number, text, etc.)
- Range-Validierung (min/max für numbers)
- Pattern-Validierung (regex für text)
- Required-Field-Validierung
- Cross-Field-Validierung (Feld A > Feld B)
- Custom Validators (JavaScript-Funktionen)
#### 1.8 Sicherheitsmaßnahmen
- Input-Sanitization
- SQL-Injection-Schutz bei JSONB-Queries
- Schema-Size-Limits (max. Anzahl Felder)
- Rate-Limiting für Schema-Änderungen
- Permissions-Check (nur Owner kann Schema ändern)
## Woche 3-4: Frontend-Komponenten
### Field-Editor Komponenten
#### 2.1 FieldDefinitionEditor.svelte
Komponente zum Erstellen/Bearbeiten von Feld-Definitionen:
- Feld-Typ-Auswahl
- Konfiguration je nach Typ
- Validierungsregeln festlegen
- Anzeigeoptionen
- Drag&Drop für Reihenfolge
#### 2.2 SchemaManager.svelte
Hauptkomponente für Schema-Verwaltung:
- Liste aller Custom Fields
- Kategorien verwalten
- Import/Export-Funktionen
- Schema-Versionierung
- Batch-Operationen
#### 2.3 Field-Renderer Komponenten
Für jeden Feldtyp eine eigene Render-Komponente:
- `TextField.svelte` - Ein/mehrzeiliger Text
- `NumberField.svelte` - Zahlen mit Min/Max
- `RangeField.svelte` - Slider-Komponente
- `SelectField.svelte` - Dropdown/Radio
- `FormulaField.svelte` - Read-only berechnete Werte
- `ReferenceField.svelte` - Node-Auswahl
- `ListField.svelte` - Dynamische Listen
- `BooleanField.svelte` - Checkbox/Toggle
#### 2.4 CustomDataForm.svelte
Dynamisches Formular basierend auf Schema:
- Automatisches Rendering aller Fields
- Validierung in Echtzeit
- Kategorisierte Darstellung
- Responsive Layout
- Save/Cancel-Funktionen
- Dirty-State-Tracking
### UI/UX-Überlegungen
#### 2.5 Field-Builder Interface
- Intuitive Drag&Drop-Oberfläche
- Live-Vorschau der Felder
- Undo/Redo-Funktionalität
- Keyboard-Shortcuts
- Kontextuelle Hilfe
#### 2.6 Responsive Design
- Mobile-optimierte Field-Editoren
- Touch-freundliche Controls
- Collapsible Kategorien
- Progressive Disclosure
## Woche 5: Formeln & Berechnungen
### Formula Engine
#### 3.1 Parser implementieren
- Math.js oder eigene Implementierung
- Unterstützte Operationen: +, -, *, /, ^, sqrt, etc.
- Funktionen: min, max, round, floor, ceil
- Conditionals: if/then/else
- String-Operationen: concat, length
#### 3.2 Feld-Referenzen
- Syntax: `@fieldname` oder `{fieldname}`
- Nested References: `@character.strength`
- Array-Zugriffe: `@inventory[0].weight`
- Aggregationen: `sum(@inventory[].value)`
#### 3.3 Abhängigkeits-Graph
- Automatische Erkennung von Abhängigkeiten
- Zirkuläre Abhängigkeiten verhindern
- Optimale Berechnungsreihenfolge
- Cache-Invalidierung bei Änderungen
#### 3.4 Performance-Optimierung
- Lazy Evaluation
- Memoization von Ergebnissen
- Batch-Berechnungen
- Web Worker für komplexe Formeln
### Beispiel-Formeln
#### 3.5 Vordefinierte Formeln
```javascript
// Kampfkraft
"(@strength + @dexterity) / 2 + @weaponBonus"
// Tragkraft
"@strength * 10 + (@size == 'large' ? 50 : 0)"
// Bewegungsreichweite
"@baseSpeed * (1 - @encumbrance / 100)"
// Magieresistenz
"@willpower + @level * 2 + (@race == 'elf' ? 10 : 0)"
// Handelspreis
"@basePrice * (1 - @reputation / 1000) * @quantity"
```
## Woche 6: Integration & Testing
### Integration in bestehende Komponenten
#### 4.1 NodeForm.svelte erweitern
- Tab für "Custom Fields"
- Nahtlose Integration mit Standard-Feldern
- Gemeinsame Speicherung
- Konsistente Validierung
#### 4.2 NodeDetail.svelte erweitern
- Anzeige von Custom Fields
- Gruppiert nach Kategorien
- Inline-Editing wo sinnvoll
- Export-Optionen
#### 4.3 NodeList.svelte erweitern
- Custom Fields als Spalten wählbar
- Sortierung nach Custom Fields
- Filterung nach Custom Fields
- Bulk-Edit für Custom Fields
### Testing-Strategie
#### 4.4 Unit Tests
- Schema-Validierung
- Formel-Parser
- Field-Validatoren
- API-Endpoints
- Komponenten-Tests
#### 4.5 Integration Tests
- Schema-CRUD-Operationen
- Daten-Speicherung
- Formel-Berechnungen
- Import/Export
- Permissions
#### 4.6 E2E Tests
- Kompletter Field-Creation-Flow
- Schema-Anwendung auf Nodes
- Formel-Updates
- Bulk-Operationen
- Error-Handling
#### 4.7 Performance Tests
- Load-Tests mit vielen Fields
- Komplexe Formel-Berechnungen
- Große Schemas
- Concurrent Updates
## Woche 7: Templates & Presets
### Template-System
#### 5.1 Template-Struktur
```typescript
interface FieldTemplate {
id: string;
name: string;
description: string;
category: 'official' | 'community' | 'personal';
tags: string[];
applicable_to: NodeKind[];
fields: CustomFieldDefinition[];
example_data?: any;
author?: string;
version: string;
dependencies?: string[]; // Andere Templates
}
```
#### 5.2 Offizielle Templates erstellen
- **Basic Stats**: Stärke, Intelligenz, Geschick, etc.
- **Resource Tracking**: HP, MP, Stamina, etc.
- **Inventory Basic**: Gewicht, Wert, Menge
- **Social Stats**: Reputation, Einfluss, Beziehungen
- **Combat Ready**: Angriff, Verteidigung, Initiative
- **Magic Simple**: Magiepunkte, Zauberslots
- **Skill Trees**: Basis-Skill-System
#### 5.3 Template-Bibliothek
- Template-Browser mit Suche
- Preview-Funktion
- Ein-Klick-Installation
- Merge-Funktion für mehrere Templates
- Update-Mechanismus
### Import/Export
#### 5.4 Export-Formate
- JSON (vollständig)
- CSV (nur Daten)
- Markdown (human-readable)
- YAML (für Entwickler)
#### 5.5 Import-Features
- Validierung vor Import
- Konflikt-Resolution
- Mapping-Tool für unterschiedliche Schemas
- Batch-Import
## Woche 8: Polish & Dokumentation
### User Experience
#### 6.1 Onboarding
- Interaktives Tutorial
- Tooltips und Hints
- Beispiel-Workflows
- Video-Tutorials (optional)
#### 6.2 Fehlerbehandlung
- Klare Fehlermeldungen
- Recovery-Optionen
- Auto-Save
- Konflikt-Resolution UI
#### 6.3 Performance-Optimierung
- Code-Splitting
- Lazy Loading
- Virtual Scrolling für große Listen
- Debouncing für Formeln
### Dokumentation
#### 6.4 Technische Dokumentation
- API-Dokumentation
- Schema-Spezifikation
- Formel-Syntax-Guide
- Migration-Guide
#### 6.5 Nutzer-Dokumentation
- Getting Started Guide
- Field-Type-Übersicht
- Formel-Beispiele
- Best Practices
- FAQ
#### 6.6 Entwickler-Dokumentation
- Template-Entwicklung
- Custom Validators
- Plugin-System (Vorbereitung)
- Contribution Guidelines
### Launch-Vorbereitung
#### 6.7 Beta-Testing
- Ausgewählte Nutzer einladen
- Feedback-System einrichten
- Bug-Tracking
- Performance-Monitoring
#### 6.8 Marketing-Material
- Feature-Ankündigung
- Demo-Videos
- Blog-Post
- Newsletter
## Meilensteine & Erfolgskriterien
### Woche 2: Backend Complete
✓ Datenbank-Schema erweitert
✓ Alle API-Endpoints funktionsfähig
✓ Basis-Validierung implementiert
### Woche 4: Frontend Functional
✓ Schema-Editor funktioniert
✓ Custom Fields werden gerendert
✓ Daten können gespeichert werden
### Woche 5: Formulas Working
✓ Formel-Parser implementiert
✓ Abhängigkeiten werden aufgelöst
✓ Performance akzeptabel
### Woche 6: Fully Integrated
✓ Integration in alle relevanten Komponenten
✓ Tests grün
✓ Keine Regression bei bestehenden Features
### Woche 7: Templates Ready
✓ Mindestens 5 offizielle Templates
✓ Import/Export funktioniert
✓ Template-Browser implementiert
### Woche 8: Production Ready
✓ Dokumentation vollständig
✓ Performance-Ziele erreicht
✓ Beta-Feedback eingearbeitet
✓ Launch-bereit
## Risiken & Mitigation
### Technische Risiken
**Komplexität unterschätzt**
- Mitigation: Iterative Entwicklung, MVP first
- Fallback: Features in Phase 2 verschieben
**Performance-Probleme**
- Mitigation: Frühe Performance-Tests
- Fallback: Limits für Anzahl Fields
**Formel-Engine zu komplex**
- Mitigation: Externe Library nutzen
- Fallback: Einfache Formeln only
### Organisatorische Risiken
**Zeitplan zu ambitioniert**
- Mitigation: Wöchentliche Reviews
- Fallback: Scope reduzieren
**User-Akzeptanz unklar**
- Mitigation: Frühe User-Tests
- Fallback: A/B Testing
## Ressourcen & Team
### Benötigte Skills
- **Backend**: Node.js, PostgreSQL, JSONB
- **Frontend**: Svelte 5, TypeScript
- **UX/UI**: Field-Editor Design
- **Testing**: Jest, Playwright
### Geschätzter Aufwand
- **Entwicklung**: 1-2 Vollzeit-Entwickler
- **Design**: 0.5 Designer
- **Testing**: 0.5 QA
- **Gesamt**: ~240-320 Personenstunden
## Next Steps nach Phase 1
### Sofort möglich
- Rule Templates (Phase 2)
- Erweiterte Formeln
- Visual Field Builder
### Vorbereitet für
- Dynamic Traits (Phase 3)
- State Machines (Phase 3)
- Relationship Matrix (Phase 3)
### Feedback-Loop
- User-Feedback sammeln
- Analytics auswerten
- Prioritäten für Phase 2 anpassen
## Appendix: Technische Details
### A. JSONB Query-Beispiele
```sql
-- Alle Nodes mit Custom Field "strength" > 15
SELECT * FROM content_nodes
WHERE (custom_data->>'strength')::int > 15;
-- Nodes mit bestimmtem Schema-Version
SELECT * FROM content_nodes
WHERE custom_schema->>'version' = '2';
-- Aggregate über Custom Fields
SELECT AVG((custom_data->>'level')::int) as avg_level
FROM content_nodes
WHERE kind = 'character';
```
### B. Security Considerations
```typescript
// Sanitization Beispiel
function sanitizeCustomData(data: any): any {
// Remove any executable code
// Validate data types
// Check size limits
// Escape special characters
return sanitizedData;
}
// Permission Check
function canModifySchema(userId: string, nodeId: string): boolean {
// Check ownership
// Check world permissions
// Check collaboration rights
return hasPermission;
}
```
### C. Migration Strategy
```sql
-- Backup vor Migration
CREATE TABLE content_nodes_backup AS
SELECT * FROM content_nodes;
-- Rollback wenn nötig
ALTER TABLE content_nodes
DROP COLUMN IF EXISTS custom_schema,
DROP COLUMN IF EXISTS custom_data;
```
---
*Dieser Plan ist als lebendiges Dokument zu verstehen und sollte basierend auf Fortschritt und Feedback angepasst werden. Wöchentliche Reviews sind essentiell für den Erfolg.*

View file

@ -0,0 +1,134 @@
# Flux Bildgenerierung über Replicate
## Überblick
Worldream nutzt **Flux Schnell** von Black Forest Labs über die Replicate API für schnelle, hochwertige Bildgenerierung. Flux ist eines der modernsten Open-Source Bildgenerierungsmodelle und bietet exzellente Qualität bei sehr schnellen Generierungszeiten.
## Modelle
### Flux Schnell (Primär)
- **Geschwindigkeit**: 1-2 Sekunden
- **Qualität**: Sehr gut für schnelle Iterationen
- **Model ID**: `black-forest-labs/flux-schnell`
- **Optimal für**: Schnelle Prototypen und Ideenfindung
### Flux Dev (Fallback)
- **Geschwindigkeit**: 5-10 Sekunden
- **Qualität**: Höher als Schnell
- **Model ID**: `black-forest-labs/flux-dev`
- **Optimal für**: Finale Bilder mit mehr Details
### Flux Pro (Optional)
- **Geschwindigkeit**: 10-15 Sekunden
- **Qualität**: Höchste Qualität
- **Model ID**: `black-forest-labs/flux-pro`
- **Optimal für**: Produktionsreife Bilder
## Features
### Unterstützte Stile
- **Realistic**: Fotorealistische Darstellung
- **Fantasy**: Magische und fantastische Kunstwerke
- **Anime**: Anime/Manga-Stil
- **Concept Art**: Professionelle Konzeptkunst
- **Illustration**: Künstlerische Illustrationen
### Bildformate
- **1:1** - Quadratisch (Standard)
- **16:9** - Breitbild
- **9:16** - Hochformat
- **21:9** - Ultrawide
- **3:2, 2:3** - Fotografie-Formate
- **4:5, 5:4** - Social Media
- **3:4, 4:3** - Klassische Formate
## Technische Details
### API-Integration
```javascript
const output = await replicate.run('black-forest-labs/flux-schnell', {
input: {
prompt: 'Your detailed prompt here',
num_outputs: 1,
aspect_ratio: '1:1',
output_format: 'webp',
output_quality: 80
}
});
```
### Kosten
- **Flux Schnell**: ~$0.003 pro Bild
- **Flux Dev**: ~$0.01 pro Bild
- **Flux Pro**: ~$0.05 pro Bild
## Prompt-Optimierungen
Flux reagiert besonders gut auf:
- Detaillierte Beschreibungen
- Stilangaben (z.B. "artstation quality", "8k resolution")
- Kompositionshinweise (z.B. "centered", "rule of thirds")
- Beleuchtungsangaben (z.B. "golden hour", "studio lighting")
- Qualitätsmarker (z.B. "masterpiece", "best quality")
## Workflow in Worldream
1. User erstellt Content (Character, Place, etc.)
2. Wählt Stil aus (Fantasy, Realistic, etc.)
3. Flux Schnell generiert Bild in 1-2 Sekunden
4. Bild wird in Supabase Storage gespeichert
5. Bei Fehler: Automatischer Fallback auf Flux Dev
## Umgebungsvariablen
```env
# Replicate API Token
REPLICATE_API_TOKEN=r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Den API Token erhältst du unter: https://replicate.com/account/api-tokens
## Vorteile gegenüber anderen Modellen
### Vorteile von Flux Schnell
- ✅ Sehr günstig (~$0.003 pro Bild)
- ✅ Extrem schnell (1-2s)
- ✅ Open Source
- ✅ Exzellente Prompt-Befolgung
- ✅ Flexible Seitenverhältnisse
### vs. Midjourney
- ✅ API-Zugriff
- ✅ Schneller
- ✅ Günstiger
- ✅ Keine Discord-Abhängigkeit
- ❌ Weniger künstlerisch
### vs. Stable Diffusion
- ✅ Bessere Qualität out-of-the-box
- ✅ Einfachere Prompts
- ✅ Schneller
- ✅ Kein eigener Server nötig
## Dateien
- `/src/lib/ai/replicate-flux.ts` - Hauptmodul für Flux-Integration
- `/src/routes/api/ai/generate-image/+server.ts` - API-Endpunkt
- `/src/lib/components/AiImageGenerator.svelte` - Frontend-Komponente
## Weitere Ressourcen
- [Replicate Flux Dokumentation](https://replicate.com/black-forest-labs/flux-schnell)
- [Black Forest Labs](https://blackforestlabs.ai/)
- [Flux auf GitHub](https://github.com/black-forest-labs/flux)

View file

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node },
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig,
},
},
}
);

View file

@ -0,0 +1,47 @@
{
"name": "@worldream/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.56.1",
"marked": "^16.2.1",
"openai": "^5.16.0",
"replicate": "^1.1.0"
}
}

View file

@ -0,0 +1,13 @@
#!/bin/bash
echo "Führe Datenbank-Migrationen aus..."
# Migration 004: Prompt System
echo "Migration 004: Prompt System..."
npx supabase db push --db-url "$DATABASE_URL" < supabase/migrations/004_prompt_system.sql
# Migration 005: Add image_url
echo "Migration 005: Add image_url column..."
npx supabase db push --db-url "$DATABASE_URL" < supabase/migrations/005_add_image_url.sql
echo "Migrationen abgeschlossen!"

View file

@ -0,0 +1,36 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
@config '../tailwind.config.js';
/* Import theme CSS variables */
@import '$lib/themes/themes.css';
/* Define custom utilities for theme colors */
@layer utilities {
.bg-theme-base {
background-color: var(--theme-background-base);
}
.bg-theme-surface {
background-color: var(--theme-background-surface);
}
.bg-theme-elevated {
background-color: var(--theme-background-elevated);
}
.bg-theme-overlay {
background-color: var(--theme-background-overlay);
}
.bg-theme-subtle {
background-color: var(--theme-background-subtle, var(--theme-background-elevated));
}
}
/* Apply theme background colors using CSS variables */
html,
body {
background-color: var(--theme-background-base);
color: var(--theme-text-primary);
transition:
background-color 0.3s ease,
color 0.3s ease;
}

21
games/worldream/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient;
safeGetSession: () => Promise<{ session: Session | null; user: User | null }>;
}
interface PageData {
session: Session | null;
user: User | null;
}
// interface Error {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-gray-50 dark:bg-gray-900">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,31 @@
import { createClient } from '$lib/supabase/server';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createClient(event);
event.locals.safeGetSession = async () => {
const {
data: { session },
} = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const {
data: { user },
error,
} = await event.locals.supabase.auth.getUser();
if (error) {
return { session: null, user: null };
}
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range' || name === 'x-supabase-api-version';
},
});
};

View file

@ -0,0 +1,264 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentNode, NodeKind } from '$lib/types/content';
import { aiLogger } from '$lib/utils/logger';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface EditContentOptions {
node: ContentNode;
command: string;
}
function getEditSystemPrompt(kind: NodeKind): string {
const basePrompt = `Du bist ein AI-Editor für Content Nodes in einem Worldbuilding-System.
Du erhältst die aktuellen Daten einer ${kind} Entity und einen Bearbeitungsbefehl.
DEINE AUFGABE:
- Interpretiere den Befehl und identifiziere welche Felder geändert werden sollen
- Gib NUR die geänderten Felder als JSON zurück
- Behalte den bestehenden Stil und Ton bei
- Bei slug-Änderungen: automatisch URL-safe formatieren (lowercase, hyphens)
- WICHTIG: Bei Umbenennungen durchsuche ALLE Felder nach dem alten Namen und aktualisiere sie
BEFEHLSTYPEN:
- "Benenne um zu X" title und slug ändern + ALLE anderen Felder nach altem Namen durchsuchen und ersetzen
- "Ändere [Feld] zu/auf X" spezifisches Feld updaten
- "Füge zu [Feld] hinzu: X" bestehenden Inhalt erweitern
- "Entferne aus [Feld]: X" spezifischen Inhalt löschen
- "Aktualisiere [Feld]: X" Feld komplett ersetzen
FELDER nach NodeKind:`;
const fieldMappings = {
character: `
- title: Name des Charakters
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Aussehen/Beschreibung
- content.lore: Hintergrundgeschichte
- content.voice_style: Sprechweise
- content.capabilities: Fähigkeiten
- content.constraints: Einschränkungen
- content.motivations: Ziele/Motivationen
- content.secrets: Geheimnisse
- content.relationships_text: Beziehungen
- content.inventory_text: Inventar/Besitz
- content.timeline_text: Wichtige Ereignisse
- content.state_text: Aktueller Zustand`,
place: `
- title: Name des Orts
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Erscheinungsbild
- content.lore: Geschichte/Bedeutung
- content.capabilities: Was ist möglich
- content.constraints: Gefahren/Einschränkungen
- content.state_text: Aktueller Zustand
- content.secrets: Verborgene Aspekte`,
object: `
- title: Name des Objekts
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Aussehen/Material
- content.lore: Herkunft/Geschichte
- content.capabilities: Eigenschaften/Fähigkeiten
- content.constraints: Einschränkungen/Nachteile
- content.state_text: Zustand/Besitzer`,
world: `
- title: Name der Welt
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.appearance: Beschreibung
- content.lore: Geschichte/Lore
- content.canon_facts_text: Kanon-Fakten
- content.glossary_text: Glossar
- content.constraints: Regeln/Einschränkungen
- content.timeline_text: Zeitlinie
- content.prompt_guidelines: KI-Richtlinien`,
story: `
- title: Titel der Geschichte
- slug: URL-freundlicher Identifier
- summary: Kurze Zusammenfassung
- tags: Array von Tags
- content.lore: Story-Verlauf/Plot
- content.references: Referenzen/Verweise
- content.prompt_guidelines: LLM-Richtlinien`,
};
return (
basePrompt +
fieldMappings[kind] +
`
BEISPIELE:
User: "Benenne um zu Gandalf der Graue"
{"title": "Gandalf der Graue", "slug": "gandalf-der-graue", "content": {"appearance": "Gandalf der Graue trägt...", "lore": "Gandalf der Graue wurde..."}}
(Alle Felder durchsuchen wo "Gandalf" erwähnt wird und zu "Gandalf der Graue" ändern)
User: "Füge zur Erscheinung hinzu: trägt einen blauen Mantel"
{"content": {"appearance": "[BESTEHENDER TEXT] trägt einen blauen Mantel"}}
User: "Ändere die Fähigkeiten zu: Meister der Feuermagie"
{"content": {"capabilities": "Meister der Feuermagie"}}
WICHTIG:
- Gib NUR ein gültiges JSON-Objekt zurück
- Keine Erklärungen oder zusätzlicher Text
- Bei content-Feldern: Nur die geänderten Unterfelder einschließen
- Bestehende @mentions und Formatierung beibehalten`
);
}
export async function editContentWithAI(
options: EditContentOptions
): Promise<Partial<ContentNode>> {
const { node, command } = options;
aiLogger.info(`Starting AI content editing for ${node.kind}`, {
nodeId: node.id,
nodeSlug: node.slug,
commandLength: command.length,
});
const systemPrompt = getEditSystemPrompt(node.kind);
const endTimer = aiLogger.startTimer(`editContent-${node.kind}`);
try {
const userPrompt = `AKTUELLE DATEN:
${JSON.stringify(
{
title: node.title,
slug: node.slug,
summary: node.summary,
tags: node.tags,
content: node.content,
},
null,
2
)}
BEFEHL: ${command}`;
const requestParams = {
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
response_format: { type: 'json_object' },
max_completion_tokens: 5000,
// Keine temperature - GPT-4o-mini unterstützt nur default (1.0)
};
aiLogger.apiRequest('OpenAI', 'chat.completions.create', requestParams);
const completion = await openai.chat.completions.create(requestParams as any);
const duration = endTimer();
if (!completion.choices[0]?.message?.content) {
throw new Error('No content received from AI');
}
const rawResponse = completion.choices[0].message.content;
aiLogger.debug('Raw AI editing response', {
contentLength: rawResponse.length,
first500Chars: rawResponse.substring(0, 500),
tokensUsed: completion.usage?.completion_tokens || 0,
finishReason: completion.choices[0].finish_reason,
});
// Parse AI response
let updates: Partial<ContentNode>;
try {
updates = JSON.parse(rawResponse);
} catch (parseError) {
aiLogger.error('Failed to parse AI response as JSON', { rawResponse, parseError });
throw new Error('AI returned invalid JSON format');
}
// Validate and clean updates
const cleanedUpdates = validateAndCleanUpdates(updates, node);
aiLogger.apiResponse('OpenAI', 'chat.completions.create', completion, duration);
aiLogger.info('Content edited successfully', {
nodeSlug: node.slug,
fieldsChanged: Object.keys(cleanedUpdates),
duration,
});
return cleanedUpdates;
} catch (error) {
const duration = endTimer();
aiLogger.error('AI content editing failed', {
nodeSlug: node.slug,
command,
duration,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
function validateAndCleanUpdates(updates: any, originalNode: ContentNode): Partial<ContentNode> {
const cleaned: Partial<ContentNode> = {};
// Validate basic fields
if (updates.title && typeof updates.title === 'string') {
cleaned.title = updates.title.trim();
}
if (updates.slug && typeof updates.slug === 'string') {
// Ensure slug is URL-safe
cleaned.slug = updates.slug
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (updates.summary && typeof updates.summary === 'string') {
cleaned.summary = updates.summary.trim();
}
if (updates.tags && Array.isArray(updates.tags)) {
cleaned.tags = updates.tags.filter((tag) => typeof tag === 'string').map((tag) => tag.trim());
}
// Validate content updates
if (updates.content && typeof updates.content === 'object') {
// WICHTIG: Starte mit dem originalen Content, nicht mit einem leeren Objekt!
// So bleiben alle nicht-geänderten Felder erhalten
cleaned.content = { ...(originalNode.content || {}) };
// Merge content fields, handling append operations
for (const [key, value] of Object.entries(updates.content)) {
if (typeof value === 'string') {
const trimmedValue = value.trim();
// Update or add the field
cleaned.content[key] = trimmedValue;
} else if (value === null || value === undefined) {
// Allow deletion of fields if explicitly set to null
delete cleaned.content[key];
}
}
}
// Always update timestamp when making changes
if (Object.keys(cleaned).length > 0) {
cleaned.updated_at = new Date().toISOString();
}
return cleaned;
}

View file

@ -0,0 +1,162 @@
import { GoogleGenerativeAI } from '@google/generative-ai';
import { GEMINI_API_KEY } from '$env/static/private';
import type { NodeKind } from '$lib/types/content';
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
interface ImageGenerationOptions {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: {
world?: string;
appearance?: string;
atmosphere?: string;
};
}
export async function generateImage(options: ImageGenerationOptions): Promise<{
imageUrl: string;
prompt: string;
}> {
const { kind, title, description, style = 'fantasy', context } = options;
const prompt = buildImagePrompt(kind, title, description, style, context);
// WICHTIG: Gemini API unterstützt derzeit keine direkte Bildgenerierung
// Die "Nano Banana" Bildgenerierung ist nur über die Gemini Web-App verfügbar
// Wir generieren stattdessen einen optimierten Prompt für externe Dienste
const model = genAI.getGenerativeModel({
model: 'gemini-1.5-flash', // Verwende das Standard-Modell für Prompt-Optimierung
});
try {
// Generiere einen optimierten Bildprompt mit Gemini
const result = await model.generateContent({
contents: [
{
role: 'user',
parts: [
{
text: `Create an optimized image generation prompt for: ${prompt}.
Make it detailed, descriptive, and suitable for image generation AI.
Keep it under 500 characters. Return only the prompt, no explanation.`,
},
],
},
],
generationConfig: {
temperature: 0.8,
maxOutputTokens: 200,
},
});
const response = await result.response;
const optimizedPrompt = response.text() || prompt;
// Für Demo-Zwecke: Generiere eine Placeholder-URL mit dem Prompt
// In Produktion: Hier würde man einen echten Bildgenerierungsdienst aufrufen
const placeholderUrl = `https://via.placeholder.com/1024x1024/4F46E5/ffffff?text=${encodeURIComponent(title.substring(0, 20))}`;
console.log('Optimized prompt for external image generation:', optimizedPrompt);
return {
imageUrl: placeholderUrl, // Placeholder - ersetze mit echtem Bildgenerierungsdienst
prompt: optimizedPrompt,
};
} catch (error) {
console.error('Fehler bei Prompt-Generierung:', error);
throw new Error(
`Prompt-Generierung fehlgeschlagen: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`
);
}
}
function buildImagePrompt(
kind: NodeKind,
title: string,
description?: string,
style: string = 'fantasy',
context?: any
): string {
const styleDescriptions = {
realistic: 'photorealistic, highly detailed, professional photography',
fantasy: 'fantasy art style, magical atmosphere, detailed illustration',
anime: 'anime art style, vibrant colors, expressive',
'concept-art': 'concept art, professional digital painting, atmospheric',
illustration: 'detailed illustration, artistic, hand-drawn quality',
};
const kindPrompts: Record<NodeKind, string> = {
character: `Character portrait of ${title}. ${description || ''} ${context?.appearance || ''}. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
place: `Environment concept art of ${title}. ${description || ''} ${context?.atmosphere || ''}. Wide shot, establishing view. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
object: `Item design of ${title}. ${description || ''} Centered composition, clear details. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
world: `World map or panoramic view of ${title}. ${description || ''} Epic scale, diverse landscapes. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
story: `Key scene illustration from "${title}". ${description || ''} Dramatic composition, narrative moment. ${styleDescriptions[style as keyof typeof styleDescriptions]}`,
};
let fullPrompt = kindPrompts[kind];
if (context?.world) {
fullPrompt += ` Set in the world of ${context.world}.`;
}
// Zusätzliche Qualitätshinweise
fullPrompt += ' High quality, detailed, professional artwork. No text, no watermarks.';
return fullPrompt;
}
export async function analyzeImage(imageUrl: string): Promise<{
description: string;
tags: string[];
colors: string[];
}> {
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
});
try {
const result = await model.generateContent({
contents: [
{
role: 'user',
parts: [
{
text: 'Analyze this image and provide a description, relevant tags, and dominant colors in JSON format.',
},
{
inlineData: {
mimeType: 'image/jpeg',
data: imageUrl, // Base64 oder URL
},
},
],
},
],
generationConfig: {
temperature: 0.3,
maxOutputTokens: 1024,
responseMimeType: 'application/json',
},
});
const response = await result.response;
const analysis = JSON.parse(response.text());
return {
description: analysis.description || '',
tags: analysis.tags || [],
colors: analysis.colors || [],
};
} catch (error) {
console.error('Fehler bei Bildanalyse:', error);
throw new Error('Bildanalyse fehlgeschlagen');
}
}

View file

@ -0,0 +1,441 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentData, NodeKind } from '$lib/types/content';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface StreamOptions {
kind: NodeKind;
prompt: string;
context?: any;
onChunk?: (chunk: string) => void;
onComplete?: (result: any) => void;
}
// Streaming-Version für bessere UX (zeigt Fortschritt)
export async function generateContentStream(options: StreamOptions): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}> {
const { kind, prompt, context, onChunk, onComplete } = options;
if (kind === 'world') {
return generateWorldContentStream(prompt, context, onChunk, onComplete);
}
// Für andere Content-Typen
const systemPrompt = getStreamingPrompt(kind, context);
const stream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 2000,
});
let fullContent = '';
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
fullContent += content;
onChunk?.(content);
}
// Parse das Ergebnis
try {
const result = parseGeneratedContent(fullContent, kind);
onComplete?.(result);
return result;
} catch (error) {
console.error('Failed to parse generated content:', error);
// Fallback: Versuche trotzdem etwas zu extrahieren
return extractFallbackContent(fullContent, kind);
}
}
// Optimierte zweistufige Welt-Generierung mit Streaming
async function generateWorldContentStream(
prompt: string,
context: any,
onChunk?: (chunk: string) => void,
onComplete?: (result: any) => void
): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}> {
// Stufe 1: Basis-Info mit strukturiertem Output
onChunk?.('🌍 Erstelle Grundlagen der Welt...\n\n');
const basePrompt = `Erstelle eine neue Welt. Antworte in folgendem Format:
TITEL: [Name der Welt]
ZUSAMMENFASSUNG: [1-2 Sätze Beschreibung]
TAGS: [tag1, tag2, tag3]
ERSCHEINUNG:
[2-3 Absätze über Landschaften und Atmosphäre]
GESCHICHTE:
[2-3 Absätze über Entstehung und Historie]
REGELN:
[Wichtigste Naturgesetze und Einschränkungen als Stichpunkte]`;
const baseStream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: 'Du bist ein kreativer Weltenbauer.' },
{ role: 'user', content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 1000,
});
let baseContent = '';
for await (const chunk of baseStream) {
const content = chunk.choices[0]?.delta?.content || '';
baseContent += content;
onChunk?.(content);
}
// Parse Basis-Ergebnis
const baseResult = parseWorldBase(baseContent);
// Stufe 2: Details
onChunk?.('\n\n📚 Erweitere Details...\n\n');
const detailPrompt = `Für die Welt "${baseResult.title}", erstelle:
CANON-FAKTEN:
[3-5 unveränderliche Wahrheiten]
GLOSSAR:
[5-7 wichtige Begriffe mit Erklärungen]
TIMELINE:
[3-5 historische Ereignisse]
RICHTLINIEN:
[Stil-Richtlinien für weitere Inhalte]`;
const detailStream = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{ role: 'system', content: 'Erweitere die Welt-Details.' },
{ role: 'user', content: detailPrompt },
],
// temperature: 1 ist default für GPT-4o-mini
stream: true,
max_completion_tokens: 800,
});
let detailContent = '';
for await (const chunk of detailStream) {
const content = chunk.choices[0]?.delta?.content || '';
detailContent += content;
onChunk?.(content);
}
const detailResult = parseWorldDetails(detailContent);
const finalResult = {
title: baseResult.title,
summary: baseResult.summary,
tags: baseResult.tags,
content: {
appearance: baseResult.appearance,
lore: baseResult.lore,
constraints: baseResult.constraints,
canon_facts_text: detailResult.canon_facts_text,
glossary_text: detailResult.glossary_text,
timeline_text: detailResult.timeline_text,
prompt_guidelines: detailResult.prompt_guidelines,
},
};
onComplete?.(finalResult);
return finalResult;
}
// Helper: Parse strukturierten Text für Welt-Basis
function parseWorldBase(text: string): any {
const lines = text.split('\n');
const result: any = {
title: '',
summary: '',
tags: [],
appearance: '',
lore: '',
constraints: '',
};
let currentSection = '';
let sectionContent: string[] = [];
for (const line of lines) {
if (line.startsWith('TITEL:')) {
result.title = line.replace('TITEL:', '').trim();
} else if (line.startsWith('ZUSAMMENFASSUNG:')) {
result.summary = line.replace('ZUSAMMENFASSUNG:', '').trim();
} else if (line.startsWith('TAGS:')) {
result.tags = line
.replace('TAGS:', '')
.trim()
.split(',')
.map((t) => t.trim());
} else if (line.startsWith('ERSCHEINUNG:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'appearance';
sectionContent = [];
} else if (line.startsWith('GESCHICHTE:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'lore';
sectionContent = [];
} else if (line.startsWith('REGELN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'constraints';
sectionContent = [];
} else if (currentSection) {
sectionContent.push(line);
}
}
// Letzten Abschnitt speichern
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
return result;
}
// Helper: Parse Details
function parseWorldDetails(text: string): any {
const lines = text.split('\n');
const result: any = {};
let currentSection = '';
let sectionContent: string[] = [];
for (const line of lines) {
if (line.startsWith('CANON-FAKTEN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'canon_facts_text';
sectionContent = [];
} else if (line.startsWith('GLOSSAR:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'glossary_text';
sectionContent = [];
} else if (line.startsWith('TIMELINE:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'timeline_text';
sectionContent = [];
} else if (line.startsWith('RICHTLINIEN:')) {
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
currentSection = 'prompt_guidelines';
sectionContent = [];
} else if (currentSection) {
sectionContent.push(line);
}
}
// Letzten Abschnitt speichern
if (currentSection && sectionContent.length) {
result[currentSection] = sectionContent.join('\n').trim();
}
return result;
}
// Helper für andere Content-Typen
function getStreamingPrompt(kind: NodeKind, context?: any): string {
const prompts: Record<NodeKind, string> = {
character: `Erstelle einen Charakter. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
GESCHICHTE:
[Hintergrund]
FÄHIGKEITEN:
[Liste]
MOTIVATION:
[Ziele und Antriebe]`,
place: `Erstelle einen Ort. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
GESCHICHTE:
[Hintergrund]
BESONDERHEITEN:
[Was macht diesen Ort einzigartig]`,
object: `Erstelle ein Objekt. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Kurzbeschreibung]
TAGS: [tag1, tag2]
AUSSEHEN:
[Beschreibung]
FUNKTION:
[Zweck und Fähigkeiten]
GESCHICHTE:
[Herkunft]`,
story: `Erstelle eine Story. Format:
TITEL: [Name]
ZUSAMMENFASSUNG: [Plot-Zusammenfassung]
TAGS: [genre1, genre2]
HANDLUNG:
[Story-Verlauf]
CHARAKTERE:
[Wichtige Personen]
WENDEPUNKTE:
[Schlüsselmomente]`,
world: '', // Wird oben speziell behandelt
};
return prompts[kind] || prompts.character;
}
// Parse generierte Inhalte aus strukturiertem Text
function parseGeneratedContent(text: string, kind: NodeKind): any {
// Ähnlich wie parseWorldBase, aber für alle Content-Typen
const lines = text.split('\n');
const result: any = {
title: '',
summary: '',
tags: [],
content: {},
};
// Extrahiere Basis-Info
for (const line of lines) {
if (line.startsWith('TITEL:')) {
result.title = line.replace('TITEL:', '').trim();
} else if (line.startsWith('ZUSAMMENFASSUNG:')) {
result.summary = line.replace('ZUSAMMENFASSUNG:', '').trim();
} else if (line.startsWith('TAGS:')) {
result.tags = line
.replace('TAGS:', '')
.trim()
.split(',')
.map((t) => t.trim());
}
}
// Content-spezifische Felder
if (kind === 'character') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
result.content.capabilities = extractSection(text, 'FÄHIGKEITEN:');
result.content.motivations = extractSection(text, 'MOTIVATION:');
} else if (kind === 'place') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
result.content.capabilities = extractSection(text, 'BESONDERHEITEN:');
} else if (kind === 'object') {
result.content.appearance = extractSection(text, 'AUSSEHEN:');
result.content.capabilities = extractSection(text, 'FUNKTION:');
result.content.lore = extractSection(text, 'GESCHICHTE:');
} else if (kind === 'story') {
result.content.lore = extractSection(text, 'HANDLUNG:');
result.content.references = extractSection(text, 'CHARAKTERE:');
result.content.timeline_text = extractSection(text, 'WENDEPUNKTE:');
}
return result;
}
// Helper: Extrahiere Sektion aus Text
function extractSection(text: string, marker: string): string {
const startIndex = text.indexOf(marker);
if (startIndex === -1) return '';
const nextMarkers = [
'TITEL:',
'ZUSAMMENFASSUNG:',
'TAGS:',
'AUSSEHEN:',
'GESCHICHTE:',
'FÄHIGKEITEN:',
'MOTIVATION:',
'BESONDERHEITEN:',
'FUNKTION:',
'HANDLUNG:',
'CHARAKTERE:',
'WENDEPUNKTE:',
'CANON-FAKTEN:',
'GLOSSAR:',
'TIMELINE:',
'RICHTLINIEN:',
'REGELN:',
];
let endIndex = text.length;
for (const nextMarker of nextMarkers) {
const idx = text.indexOf(nextMarker, startIndex + marker.length);
if (idx > -1 && idx < endIndex) {
endIndex = idx;
}
}
return text.substring(startIndex + marker.length, endIndex).trim();
}
// Fallback wenn Parsing fehlschlägt
function extractFallbackContent(text: string, kind: NodeKind): any {
// Versuche zumindest Titel zu extrahieren
const titleMatch = text.match(/TITEL:\s*(.+)/i);
const summaryMatch = text.match(/ZUSAMMENFASSUNG:\s*(.+)/i);
return {
title: titleMatch?.[1] || 'Unbenannt',
summary: summaryMatch?.[1] || text.substring(0, 100),
tags: [],
content: {
lore: text, // Speichere alles als lore
},
};
}

View file

@ -0,0 +1,564 @@
import OpenAI from 'openai';
import { OPENAI_API_KEY } from '$env/static/private';
import type { ContentData, NodeKind } from '$lib/types/content';
import { aiLogger } from '$lib/utils/logger';
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
interface GenerateContentOptions {
kind: NodeKind;
prompt: string;
context?: {
world?: string;
worldData?: any;
existingCharacters?: string[];
existingPlaces?: string[];
existingObjects?: string[];
selectedCharacters?: any[];
selectedPlace?: any;
};
}
export async function generateContent(options: GenerateContentOptions): Promise<{
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
generationContext: any;
}> {
const { kind, prompt, context } = options;
aiLogger.info(`Starting content generation for ${kind}`, {
kind,
promptLength: prompt.length,
hasContext: !!context,
});
const systemPrompt = getSystemPrompt(kind, context);
const timer = aiLogger.startTimer(`generateContent-${kind}`);
// Build complete generation context for storage
const generationContext = {
userPrompt: prompt,
systemPrompt: systemPrompt,
worldContext: context?.world,
worldDetails: context?.worldData
? {
title: context.worldData.title,
summary: context.worldData.summary,
appearance: context.worldData.content?.appearance,
}
: undefined,
selectedCharacters: context?.selectedCharacters || undefined,
selectedPlace: context?.selectedPlace || undefined,
model: 'gpt-5-mini',
timestamp: new Date().toISOString(),
};
try {
const requestParams = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{ role: 'user' as const, content: prompt },
],
// temperature: 1 ist default für GPT-4o-mini (andere Werte nicht unterstützt)
response_format: { type: 'json_object' as const },
max_completion_tokens: 10000, // Einheitliches Token-Limit für alle
};
aiLogger.apiRequest('OpenAI', 'chat.completions.create', requestParams);
const completion = await openai.chat.completions.create(requestParams);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'chat.completions.create', completion, duration);
const rawContent = completion.choices[0].message.content || '{}';
// Enhanced logging for story generation debugging
if (kind === 'story') {
console.log('🎬 Story Generation Debug:', {
hasSelectedCharacters: !!context?.selectedCharacters?.length,
selectedCharacters: context?.selectedCharacters?.map((c: any) => ({
name: c.name,
slug: c.slug,
})),
rawResponsePreview: rawContent.substring(0, 1000),
});
// Log the actual parsed result
try {
const parsedForDebug = JSON.parse(rawContent);
if (parsedForDebug.content?.lore) {
console.log('📝 Generated story lore:', parsedForDebug.content.lore.substring(0, 500));
}
} catch (e) {
console.log('Could not parse for debug');
}
}
aiLogger.debug('Raw AI response', {
contentLength: rawContent.length,
first500Chars: rawContent.substring(0, 500),
tokensUsed: completion.usage?.completion_tokens,
finishReason: completion.choices[0].finish_reason,
});
let result: any;
// Check if response was cut off
if (completion.choices[0].finish_reason === 'length') {
aiLogger.warn('Response was truncated due to token limit', {
tokensUsed: completion.usage?.completion_tokens,
contentLength: rawContent.length,
});
}
try {
result = JSON.parse(rawContent);
} catch (parseError) {
aiLogger.error('Failed to parse AI response', {
error: parseError,
rawContent: rawContent.substring(0, 1000),
finishReason: completion.choices[0].finish_reason,
});
// If content is just "{}" and we hit token limit, throw error
if (rawContent.trim() === '{}' && completion.choices[0].finish_reason === 'length') {
throw new Error(
'AI-Generierung fehlgeschlagen: Token-Limit erreicht. Bitte versuchen Sie einen kürzeren Prompt.'
);
}
// Fallback: Try to extract JSON
const jsonMatch = rawContent.match(/{[\s\S]*}/);
if (jsonMatch) {
try {
result = JSON.parse(jsonMatch[0]);
} catch (e) {
result = {};
}
} else {
result = {};
}
}
// Post-process for story content: Replace REF_X with actual @slugs if AI generated them incorrectly
if (kind === 'story' && result.content?.lore) {
let processedLore = result.content.lore;
let replacementsMade = false;
// Check if there are REF_X placeholders
if (/REF_\d+/.test(processedLore)) {
console.warn('⚠️ Found REF_X placeholders in generated story, attempting to fix...');
// Build a mapping of all possible references
const refMapping: Record<number, string> = {};
let refIndex = 0;
// Add characters first
if (context?.selectedCharacters?.length) {
context.selectedCharacters.forEach((char: any) => {
refMapping[refIndex] = `@${char.slug}`;
console.log(`Mapping REF_${refIndex} → @${char.slug} (${char.name})`);
refIndex++;
});
}
// Add place if selected
if (context?.selectedPlace) {
refMapping[refIndex] = `@${context.selectedPlace.slug}`;
console.log(
`Mapping REF_${refIndex} → @${context.selectedPlace.slug} (${context.selectedPlace.name})`
);
refIndex++;
}
// Replace all REF_X with mapped values
for (const [index, replacement] of Object.entries(refMapping)) {
const refPattern = new RegExp(`REF_${index}(?!\\d)`, 'g');
const before = processedLore;
processedLore = processedLore.replace(refPattern, replacement);
if (before !== processedLore) {
replacementsMade = true;
console.log(`✅ Replaced REF_${index} with ${replacement}`);
}
}
// Check for any remaining REF_X patterns
const remainingRefs = processedLore.match(/REF_\d+/g);
if (remainingRefs) {
console.error('❌ Still found unmatched REF patterns:', remainingRefs);
console.log('Available mappings were:', refMapping);
}
if (replacementsMade) {
result.content.lore = processedLore;
console.log('✨ Fixed story content with proper @references');
}
}
}
aiLogger.info(`Content generated successfully for ${kind}`, {
title: result.title,
tagsCount: result.tags?.length || 0,
duration,
});
return {
title: result.title || 'Unbenannt',
summary: result.summary || '',
content: result.content || {},
tags: result.tags || [],
generationContext,
};
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'chat.completions.create', error, duration);
throw error;
}
}
function getSystemPrompt(kind: NodeKind, context?: any): string {
const basePrompt = `Du bist ein kreativer Weltenbauer und Geschichtenerzähler.
Erstelle detaillierte, konsistente und fesselnde Inhalte für eine text-first Worldbuilding-Plattform.
Antworte IMMER im JSON-Format.`;
const kindPrompts: Record<NodeKind, string> = {
character: `
${basePrompt}
WICHTIG: Antworte NUR mit validem JSON!
Erstelle einen Charakter:
{
"title": "Name",
"summary": "Beschreibung in 1-2 Sätzen",
"tags": ["tag1", "tag2"],
"content": {
"appearance": "Aussehen (50-100 Wörter)",
"lore": "Hintergrund (50-100 Wörter)",
"voice_style": "Sprechstil",
"capabilities": "Fähigkeiten (Stichpunkte)",
"constraints": "Schwächen (Stichpunkte)",
"motivations": "Ziele (Stichpunkte)",
"secrets": "1-2 Geheimnisse",
"relationships_text": "Beziehungen",
"inventory_text": "Wichtige Gegenstände",
"state_text": "Aktueller Status"
}
}`,
world: `
${basePrompt}
WICHTIG: Antworte NUR mit validem JSON ohne zusätzlichen Text!
Erstelle eine Welt mit folgender JSON-Struktur:
{
"title": "Name der Welt",
"summary": "Kurze Beschreibung der Welt in 1-2 Sätzen",
"tags": ["genre1", "genre2", "setting"],
"content": {
"appearance": "Beschreibung der Welt, Landschaften, Atmosphäre (100-200 Wörter)",
"lore": "Geschichte und Entstehung der Welt (100-200 Wörter)",
"canon_facts_text": "3-5 unveränderliche Wahrheiten als kurze Liste",
"glossary_text": "5-7 wichtige Begriffe mit kurzen Erklärungen",
"constraints": "Naturgesetze und Einschränkungen als Stichpunkte",
"timeline_text": "3-5 wichtige historische Ereignisse",
"prompt_guidelines": "Stil-Richtlinien für weitere Generierungen (1-2 Sätze)"
}
}`,
place: `
${basePrompt}
Erstelle einen Ort mit folgender JSON-Struktur:
{
"title": "Name des Ortes",
"summary": "Kurze Beschreibung",
"tags": ["typ", "stimmung"],
"content": {
"appearance": "Detaillierte Beschreibung des Ortes",
"lore": "Geschichte und Bedeutung",
"capabilities": "Was ist hier möglich?",
"constraints": "Gefahren und Einschränkungen",
"state_text": "Aktueller Zustand",
"secrets": "Verborgene Aspekte"
}
}`,
object: `
${basePrompt}
Erstelle ein Objekt/Gegenstand mit folgender JSON-Struktur:
{
"title": "Name des Objekts",
"summary": "Kurze Beschreibung",
"tags": ["typ", "seltenheit"],
"content": {
"appearance": "Aussehen und Material",
"lore": "Herkunft und Geschichte",
"capabilities": "Eigenschaften und Fähigkeiten",
"constraints": "Einschränkungen und Nachteile",
"state_text": "Aktueller Zustand und Aufbewahrungsort"
}
}`,
story: `
${basePrompt}
Erstelle eine Story mit folgender JSON-Struktur:
{
"title": "Kurzer, packender Titel",
"summary": "Zusammenfassung in 1-2 Sätzen",
"tags": ["genre", "stimmung", "max 3 tags"],
"content": {
"lore": "## Szenen-Titel\\n\\nStory-Text mit @charaktername direkt im Text...",
"references": "cast: @charaktere\\nplaces: @orte\\nobjects: @gegenstände",
"prompt_guidelines": "Erzählstil für spätere Generierungen"
}
}
STORY-REGELN:
1. Verwende Markdown: ## für Überschriften, **fett**, *kursiv*
2. Schreibe mindestens 30% Dialoge: "Text", sagte @charaktername.
3. Maximal 500 Wörter, fokussiere auf EINE Szene
4. Schreibe IMMER @slug-name DIREKT im Text, niemals Platzhalter!`,
};
let fullPrompt = kindPrompts[kind];
if (context) {
// World context with details - aber NICHT für neue Welten!
// Neue Welten sollen unabhängig von bestehenden Welten sein
if (kind !== 'world') {
if (context.worldData) {
fullPrompt += `\n\n🌍 WELT-KONTEXT: "${context.worldData.title}"`;
if (context.worldData.summary) {
fullPrompt += `\nZusammenfassung: ${context.worldData.summary}`;
}
if (context.worldData.content?.appearance) {
fullPrompt += `\nErscheinung: ${context.worldData.content.appearance}`;
}
fullPrompt += `\n\nWICHTIG: Alle generierten Inhalte MÜSSEN konsistent mit dieser Welt-Beschreibung sein!`;
} else if (context.world) {
fullPrompt += `\n\nDie Inhalte sollen zur Welt "${context.world}" passen.`;
}
}
if (context.selectedCharacters?.length) {
fullPrompt += `\n\n👥 CHARAKTERE IN DIESER STORY:`;
context.selectedCharacters.forEach((char: any) => {
fullPrompt += `\n\n${char.name} (@${char.slug})`;
if (char.summary) fullPrompt += `\n• ${char.summary}`;
if (char.voice_style) fullPrompt += `\n• Sprechstil: ${char.voice_style}`;
if (char.motivations) fullPrompt += `\n• Motivation: ${char.motivations}`;
});
fullPrompt += `\n\n⚠ KRITISCH: Verwende EXAKT diese @-Slugs im Text:`;
context.selectedCharacters.forEach((c: any) => {
fullPrompt += `\n• @${c.slug} für ${c.name}`;
});
fullPrompt += `\n\nSchreibe @${context.selectedCharacters[0].slug} statt "${context.selectedCharacters[0].name}"`;
fullPrompt += `\nNiemals Platzhalter, immer @slug-name direkt!`;
}
if (context.selectedPlace) {
const place = context.selectedPlace;
fullPrompt += `\n\n📍 AUSGEWÄHLTER ORT für diese Story:`;
fullPrompt += `\n━━━ ${place.name} (@${place.slug}) ━━━`;
if (place.summary) fullPrompt += `\n📝 Zusammenfassung: ${place.summary}`;
if (place.appearance) fullPrompt += `\n🎨 Erscheinung: ${place.appearance}`;
if (place.capabilities) fullPrompt += `\n✨ Besonderheiten: ${place.capabilities}`;
if (place.constraints) fullPrompt += `\n⚠ Gefahren/Einschränkungen: ${place.constraints}`;
if (place.secrets) fullPrompt += `\n🔒 Geheimnisse: ${place.secrets}`;
fullPrompt += `\n\n⚠ PFLICHT: Die Story MUSS an diesem Ort spielen! Nutze die Ortsbeschreibung für Atmosphäre und Setting.`;
}
if (context.existingCharacters?.length) {
fullPrompt += `\n\nExistierende Charaktere: ${context.existingCharacters.join(', ')}`;
}
if (context.existingPlaces?.length) {
fullPrompt += `\n\nExistierende Orte: ${context.existingPlaces.join(', ')}`;
}
if (context.existingObjects?.length) {
fullPrompt += `\n\nExistierende Objekte: ${context.existingObjects.join(', ')}`;
}
}
return fullPrompt;
}
export async function enhanceContent(
existingContent: Partial<ContentData>,
kind: NodeKind,
instruction: string
): Promise<Partial<ContentData>> {
aiLogger.info('Enhancing content', {
kind,
instructionLength: instruction.length,
});
const timer = aiLogger.startTimer('enhanceContent');
const systemPrompt = `Du bist ein kreativer Assistent für Worldbuilding.
Verbessere oder erweitere den gegebenen Content basierend auf den Anweisungen.
Behalte den existierenden Stil und Ton bei.
Antworte NUR mit dem verbesserten Content-Objekt im JSON-Format.`;
try {
const params = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{
role: 'user' as const,
content: `Existierender Content:\n${JSON.stringify(existingContent, null, 2)}\n\nAnweisung: ${instruction}`,
},
],
// temperature: 1 ist default für GPT-4o-mini
response_format: { type: 'json_object' as const },
};
aiLogger.apiRequest('OpenAI', 'enhanceContent', params);
const completion = await openai.chat.completions.create(params);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'enhanceContent', completion, duration);
const result = JSON.parse(completion.choices[0].message.content || '{}');
aiLogger.info('Content enhanced successfully', { duration });
return result;
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'enhanceContent', error, duration);
throw error;
}
}
export async function generateSuggestions(
field: keyof ContentData,
context: {
kind: NodeKind;
title?: string;
existingContent?: Partial<ContentData>;
}
): Promise<string[]> {
const prompts: Record<string, string> = {
appearance: 'Generiere 3 kurze Vorschläge für das Aussehen',
lore: 'Generiere 3 Ideen für die Hintergrundgeschichte',
capabilities: 'Generiere 3 Vorschläge für Fähigkeiten',
motivations: 'Generiere 3 mögliche Motivationen',
secrets: 'Generiere 3 interessante Geheimnisse',
};
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
messages: [
{
role: 'system' as const,
content:
'Generiere kreative Vorschläge. Antworte mit einem JSON-Array von 3 kurzen Strings.',
},
{
role: 'user' as const,
content: `${prompts[field] || 'Generiere 3 Vorschläge'} für ${context.title || 'dieses Element'}`,
},
],
// temperature: 1 ist default für GPT-4o-mini
response_format: { type: 'json_object' as const },
max_completion_tokens: 200,
});
const result = JSON.parse(completion.choices[0].message.content || '{"suggestions":[]}');
return result.suggestions || [];
}
export async function translateToImagePrompt(
germanDescription: string,
kind: NodeKind,
title: string,
style: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration' = 'fantasy'
): Promise<string> {
const timer = aiLogger.startTimer('translateToImagePrompt');
const systemPrompt = `Du bist ein Experte für KI-Bildgenerierung. Übersetze deutsche Beschreibungen in optimierte englische Prompts für Bildgenerierungs-KIs wie Flux.
Regeln:
- Übersetze präzise ins Englische
- Optimiere für Bildgenerierung (visuelle Details, Komposition, Beleuchtung)
- Keine deutschen Wörter im Ergebnis
- Fokus auf visuell beschreibbare Elemente
- Nutze Fachbegriffe für Bildqualität (sharp focus, detailed, professional, etc.)
- Antworte nur mit dem englischen Prompt, kein JSON oder zusätzlicher Text`;
const kindContext = {
character: 'Focus on character portrait, facial features, clothing, pose, expression',
place: 'Focus on environment, landscape, architecture, atmosphere, lighting',
object: 'Focus on item details, materials, textures, product shot composition',
world: 'Focus on epic scale, panoramic view, diverse landscapes, world building',
story: 'Focus on dramatic scene, narrative moment, cinematic composition',
};
const styleContext = {
realistic: 'photorealistic style',
fantasy: 'fantasy art style with magical elements',
anime: 'anime art style with vibrant colors',
'concept-art': 'professional concept art style',
illustration: 'detailed illustration style',
};
try {
const params = {
model: 'gpt-5-mini',
messages: [
{ role: 'system' as const, content: systemPrompt },
{
role: 'user' as const,
content: `Title: ${title}\nKind: ${kind} (${kindContext[kind]})\nStyle: ${style} (${styleContext[style]})\n\nGerman description to translate:\n${germanDescription}`,
},
],
max_completion_tokens: 300,
};
aiLogger.apiRequest('OpenAI', 'translateToImagePrompt', params);
const completion = await openai.chat.completions.create(params);
const duration = timer();
aiLogger.apiResponse('OpenAI', 'translateToImagePrompt', completion, duration);
const englishPrompt = completion.choices[0].message.content?.trim();
if (!englishPrompt) {
throw new Error('No translation received from API');
}
aiLogger.info('German description translated to English image prompt', {
originalLength: germanDescription.length,
translatedLength: englishPrompt.length,
duration,
});
console.log('✅ Translation successful:', {
original: germanDescription.substring(0, 50) + '...',
translated: englishPrompt.substring(0, 50) + '...',
});
return englishPrompt;
} catch (error) {
const duration = timer();
aiLogger.apiError('OpenAI', 'translateToImagePrompt', error, duration);
console.error('❌ Translation error details:', {
error: error instanceof Error ? error.message : error,
model: 'gpt-5-mini',
germanText: germanDescription.substring(0, 100) + '...',
});
// Fallback: return original text if translation fails
aiLogger.warn('Translation failed, using original text', { error });
return germanDescription;
}
}

View file

@ -0,0 +1,194 @@
import Replicate from 'replicate';
import { REPLICATE_API_TOKEN } from '$env/static/private';
import type { NodeKind } from '$lib/types/content';
// Prüfe ob Token vorhanden
if (!REPLICATE_API_TOKEN) {
console.error('REPLICATE_API_TOKEN ist nicht definiert. Bitte in .env eintragen.');
}
const replicate = new Replicate({
auth: REPLICATE_API_TOKEN || '',
});
interface ImageGenerationOptions {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: {
world?: string;
appearance?: string;
atmosphere?: string;
};
aspectRatio?: string;
}
export async function generateImageWithFlux(options: ImageGenerationOptions): Promise<{
imageUrl: string;
prompt: string;
}> {
const { kind, title, description, style = 'fantasy', context, aspectRatio = '1:1' } = options;
// Prüfe Token nochmals
if (!REPLICATE_API_TOKEN) {
throw new Error('REPLICATE_API_TOKEN nicht konfiguriert. Bitte Token in .env Datei eintragen.');
}
const prompt = buildImagePrompt(kind, title, description, style, context);
try {
console.log('Generating image with Flux Schnell, prompt:', prompt);
console.log('Using aspect ratio:', aspectRatio, 'for kind:', kind);
// Verwende die Standard run() API ohne Stream
const output = await replicate.run('black-forest-labs/flux-schnell', {
input: {
prompt: prompt,
num_outputs: 1,
aspect_ratio: aspectRatio,
output_format: 'webp',
output_quality: 80,
},
});
console.log('Flux Raw Output Type:', typeof output);
console.log('Flux Raw Output:', output);
// Verarbeite das Output
let imageUrl: string = '';
// Wenn es ein Array mit ReadableStreams ist
if (Array.isArray(output) && output.length > 0) {
const firstItem = output[0];
// Wenn es ein ReadableStream ist, konvertiere zu Base64
if (
firstItem instanceof ReadableStream ||
(firstItem && typeof firstItem === 'object' && 'locked' in firstItem)
) {
console.log('Verarbeite ReadableStream mit Binärdaten...');
// Prüfe ob Stream bereits gelesen wurde
if (firstItem.locked || (firstItem as any).state === 'closed') {
console.error('Stream ist bereits geschlossen oder gesperrt');
throw new Error('Stream konnte nicht gelesen werden');
}
const reader = firstItem.getReader();
const chunks = [];
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
// Kombiniere alle Chunks zu einem Uint8Array
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const combinedArray = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
combinedArray.set(chunk, offset);
offset += chunk.length;
}
// Konvertiere zu Base64
// Verwende Buffer wenn verfügbar (Node.js), sonst btoa (Browser)
let base64String = '';
if (typeof Buffer !== 'undefined') {
// Node.js Umgebung
base64String = Buffer.from(combinedArray).toString('base64');
} else {
// Browser Umgebung (falls jemals direkt verwendet)
const binaryString = Array.from(combinedArray)
.map((byte) => String.fromCharCode(byte))
.join('');
base64String = btoa(binaryString);
}
// Erstelle Data URL (WebP Format basierend auf den Einstellungen)
imageUrl = `data:image/webp;base64,${base64String}`;
console.log('Bild als Base64 Data URL konvertiert');
} catch (streamError) {
console.error('Fehler beim Lesen des Streams:', streamError);
throw new Error('Stream konnte nicht verarbeitet werden');
} finally {
reader.releaseLock();
}
}
// Wenn es bereits eine URL ist
else if (typeof firstItem === 'string' && firstItem.startsWith('http')) {
imageUrl = firstItem;
}
}
// Wenn es direkt ein String ist
else if (typeof output === 'string' && output.startsWith('http')) {
imageUrl = output;
}
if (!imageUrl) {
console.error('Konnte keine URL extrahieren aus:', output);
throw new Error('Keine gültige Bild-URL von Flux erhalten');
}
console.log('Flux finale Bild-URL:', imageUrl);
return {
imageUrl,
prompt,
};
} catch (error: any) {
console.error('Flux Schnell Fehler:', error);
// Gebe detaillierten Fehler zurück
throw new Error(`Bildgenerierung fehlgeschlagen: ${error.message || 'Unbekannter Fehler'}`);
}
}
function buildImagePrompt(
kind: NodeKind,
title: string,
description?: string,
style: string = 'fantasy',
context?: any
): string {
const styleDescriptions = {
realistic: 'photorealistic, highly detailed, professional photography, 8k resolution',
fantasy:
'fantasy art style, magical atmosphere, detailed digital illustration, artstation quality',
anime: 'anime art style, vibrant colors, expressive, studio ghibli inspired',
'concept-art': 'concept art, professional digital painting, atmospheric, cinematic lighting',
illustration: 'detailed illustration, artistic, hand-drawn quality, storybook style',
};
const kindPrompts: Record<NodeKind, string> = {
character: `Character portrait of ${title}. ${description || ''} ${context?.appearance || ''}. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Detailed face, expressive eyes, professional character design`,
place: `Environment concept art of ${title}. ${description || ''} ${context?.atmosphere || ''}. Wide shot, establishing view. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Epic landscape, atmospheric perspective`,
object: `Item design of ${title}. ${description || ''} Centered composition, clear details. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Product shot, clean background, professional presentation`,
world: `World map or panoramic view of ${title}. ${description || ''} Epic scale, diverse landscapes. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Bird's eye view, detailed geography`,
story: `Key scene illustration from "${title}". ${description || ''} Dramatic composition, narrative moment. ${styleDescriptions[style as keyof typeof styleDescriptions]}. Dynamic action, emotional impact`,
};
let fullPrompt = kindPrompts[kind];
if (context?.world) {
fullPrompt += ` Set in the world of ${context.world}.`;
}
// Flux-spezifische Optimierungen
fullPrompt +=
' Masterpiece, best quality, ultra-detailed, sharp focus. No watermarks, no text, no logos.';
// Flux Prompt-Limit
if (fullPrompt.length > 1000) {
fullPrompt = fullPrompt.substring(0, 1000) + '...';
}
return fullPrompt;
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,166 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface Props {
field: string;
kind: NodeKind;
value: string;
onUpdate: (value: string) => void;
placeholder?: string;
rows?: number;
label: string;
}
let {
field,
kind,
value = $bindable(),
onUpdate,
placeholder,
rows = 3,
label,
}: Props = $props();
let generating = $state(false);
let showSuggestions = $state(false);
let suggestions = $state<string[]>([]);
async function generateSuggestions() {
if (generating) return;
generating = true;
showSuggestions = false;
try {
const response = await fetch('/api/ai/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
field,
context: { kind, existingContent: { [field]: value } },
}),
});
if (response.ok) {
const data = await response.json();
suggestions = data.suggestions || [];
showSuggestions = suggestions.length > 0;
}
} catch (err) {
console.error('Failed to generate suggestions:', err);
} finally {
generating = false;
}
}
async function enhanceContent() {
if (generating || !value.trim()) return;
generating = true;
try {
const response = await fetch('/api/ai/enhance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: { [field]: value },
kind,
instruction: `Verbessere und erweitere dieses ${label} Feld. Behalte den Kern bei, aber füge Details und Tiefe hinzu.`,
}),
});
if (response.ok) {
const data = await response.json();
if (data.content?.[field]) {
value = data.content[field];
onUpdate(value);
}
}
} catch (err) {
console.error('Failed to enhance content:', err);
} finally {
generating = false;
}
}
function applySuggestion(suggestion: string) {
value = value ? `${value}\n\n${suggestion}` : suggestion;
onUpdate(value);
showSuggestions = false;
}
</script>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label for={field} class="block text-sm font-medium text-slate-700">
{label}
</label>
<div class="flex space-x-1">
<button
type="button"
onclick={generateSuggestions}
disabled={generating}
title="Vorschläge generieren"
class="p-1 text-violet-600 hover:text-violet-500 disabled:opacity-50"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</button>
{#if value}
<button
type="button"
onclick={enhanceContent}
disabled={generating}
title="Mit KI verbessern"
class="p-1 text-violet-600 hover:text-violet-500 disabled:opacity-50"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</button>
{/if}
</div>
</div>
<textarea
id={field}
bind:value
oninput={() => onUpdate(value)}
{rows}
{placeholder}
disabled={generating}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50 sm:text-sm"
></textarea>
{#if showSuggestions && suggestions.length > 0}
<div class="mt-2 rounded-md bg-violet-50 p-3">
<p class="mb-2 text-xs font-medium text-violet-900">KI-Vorschläge:</p>
<div class="space-y-2">
{#each suggestions as suggestion}
<button
type="button"
onclick={() => applySuggestion(suggestion)}
class="block w-full rounded border border-violet-200 bg-white p-2 text-left text-sm hover:bg-violet-100"
>
{suggestion}
</button>
{/each}
</div>
</div>
{/if}
{#if generating}
<p class="text-xs text-slate-500">KI arbeitet...</p>
{/if}
</div>

View file

@ -0,0 +1,200 @@
<script lang="ts">
import type { NodeKind, ContentData } from '$lib/types/content';
interface Props {
kind: NodeKind;
onGenerated: (data: {
title: string;
summary: string;
content: Partial<ContentData>;
tags: string[];
}) => void;
context?: {
world?: string;
existingCharacters?: string[];
existingPlaces?: string[];
existingObjects?: string[];
};
}
let { kind, onGenerated, context }: Props = $props();
let isOpen = $state(false);
let prompt = $state('');
let generating = $state(false);
let error = $state<string | null>(null);
const kindLabels: Record<NodeKind, string> = {
character: 'Charakter',
world: 'Welt',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
const placeholders: Record<NodeKind, string> = {
character: 'Ein weiser alter Magier mit einem Geheimnis...',
world: 'Eine düstere Cyberpunk-Welt mit magischen Elementen...',
place: 'Ein mysteriöser Wald, in dem die Zeit anders verläuft...',
object: 'Ein Amulett, das seinem Träger besondere Kräfte verleiht...',
story: 'Eine Heldenreise, bei der ungleiche Gefährten zusammenfinden...',
};
async function generate() {
if (!prompt.trim()) return;
generating = true;
error = null;
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
prompt,
context,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Generierung fehlgeschlagen');
}
const result = await response.json();
onGenerated(result);
isOpen = false;
prompt = '';
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
generating = false;
}
}
function toggleDialog() {
isOpen = !isOpen;
if (!isOpen) {
prompt = '';
error = null;
}
}
</script>
<div class="relative">
<button
type="button"
onclick={toggleDialog}
class="inline-flex items-center rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-medium leading-4 text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
KI-Generierung
</button>
{#if isOpen}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div
class="flex min-h-screen items-center justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0"
>
<!-- Background overlay -->
<div
class="fixed inset-0 bg-slate-500 bg-opacity-75 transition-opacity"
onclick={toggleDialog}
></div>
<!-- Modal panel -->
<div
class="inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle"
>
<div>
<div
class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-violet-100"
>
<svg
class="h-6 w-6 text-violet-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-lg font-medium leading-6 text-slate-900">
{kindLabels[kind]} mit KI generieren
</h3>
<div class="mt-2">
<p class="text-sm text-slate-500">
Beschreibe, was du erstellen möchtest. Die KI generiert dann alle Details für
dich.
</p>
</div>
</div>
</div>
<div class="mt-5">
{#if error}
<div class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<textarea
bind:value={prompt}
disabled={generating}
rows="4"
placeholder={placeholders[kind]}
class="w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50 sm:text-sm"
></textarea>
{#if context}
<div class="mt-2 text-xs text-slate-500">
{#if context.world}
<p>Welt: {context.world}</p>
{/if}
{#if context.existingCharacters?.length}
<p>Verfügbare Charaktere: {context.existingCharacters.slice(0, 3).join(', ')}</p>
{/if}
{#if context.existingPlaces?.length}
<p>Verfügbare Orte: {context.existingPlaces.slice(0, 3).join(', ')}</p>
{/if}
</div>
{/if}
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
onclick={generate}
disabled={generating || !prompt.trim()}
class="inline-flex w-full justify-center rounded-md border border-transparent bg-violet-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-violet-700 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-2 sm:text-sm"
>
{generating ? 'Generiere...' : 'Generieren'}
</button>
<button
type="button"
onclick={toggleDialog}
disabled={generating}
class="mt-3 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:opacity-50 sm:col-start-1 sm:mt-0 sm:text-sm"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface Props {
kind?: NodeKind;
title?: string;
description?: string;
appearance?: string;
prompt?: string;
imagePrompt?: string;
imageUrl?: string | null;
onImageGenerated?: (imageUrl: string) => void;
}
let {
kind = 'character',
title = '',
description = '',
appearance = '',
prompt = $bindable(''),
imagePrompt = $bindable(''),
imageUrl = $bindable(null),
onImageGenerated,
}: Props = $props();
let loading = $state(false);
let translating = $state(false);
let error = $state<string | null>(null);
let generatedImageUrl = $state<string | null>(null);
let selectedStyle = $state<'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration'>(
'fantasy'
);
let showOptions = $state(false);
// Extract title and description from prompt if provided
$effect(() => {
if (prompt) {
const parts = prompt.split(':');
if (parts.length > 0 && !title) {
title = parts[0].trim();
}
if (parts.length > 1 && !description && !appearance) {
description = parts.slice(1).join(':').trim();
}
}
});
// Determine aspect ratio based on kind
function getAspectRatio() {
switch (kind) {
case 'world':
case 'place':
return '16:9'; // Widescreen for worlds and places
case 'object':
return '1:1'; // Square for objects
case 'character':
return '9:16'; // Portrait for characters
default:
return '1:1'; // Default to square
}
}
// Get CSS class for image display based on aspect ratio
function getImageClass() {
const aspectRatio = getAspectRatio();
switch (aspectRatio) {
case '21:9':
return 'w-full aspect-[21/9]'; // 21:9 ultrawide aspect ratio
case '16:9':
return 'w-full aspect-video'; // 16:9 aspect ratio
case '9:16':
return 'w-64 mx-auto aspect-[9/16]'; // 9:16 aspect ratio, centered
case '1:1':
default:
return 'w-full max-w-md mx-auto aspect-square'; // 1:1 aspect ratio
}
}
async function translateToEnglish() {
const germanText = appearance || description;
if (!germanText || germanText.length < 10) {
error = 'Keine deutsche Beschreibung zum Übersetzen vorhanden';
return;
}
translating = true;
error = null;
try {
const response = await fetch('/api/ai/translate-image-prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
germanDescription: germanText,
kind,
title: title || 'Unbenannt',
style: selectedStyle,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Übersetzung fehlgeschlagen');
}
const data = await response.json();
if (data.englishPrompt) {
imagePrompt = data.englishPrompt;
}
} catch (err) {
console.error('Translation error:', err);
error = err instanceof Error ? err.message : 'Übersetzung fehlgeschlagen';
} finally {
translating = false;
}
}
async function generateImage() {
const effectiveTitle = title || prompt?.split(':')[0]?.trim();
const effectiveDescription =
description || appearance || prompt?.split(':').slice(1).join(':')?.trim();
if (!effectiveTitle) {
error = 'Titel ist erforderlich für die Bildgenerierung';
return;
}
loading = true;
error = null;
try {
const response = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
kind,
title: effectiveTitle,
description: imagePrompt || effectiveDescription,
style: selectedStyle,
aspectRatio: getAspectRatio(),
context: {
appearance: imagePrompt || appearance || effectiveDescription,
},
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Bildgenerierung fehlgeschlagen');
}
const data = await response.json();
console.log('Response von API:', data); // Debug-Log
if (data.imageUrl) {
generatedImageUrl = data.imageUrl;
imageUrl = data.imageUrl; // Update the bound prop
onImageGenerated?.(data.imageUrl);
console.log('Bild-URL gesetzt:', generatedImageUrl); // Debug-Log
}
imagePrompt = data.prompt;
prompt = data.prompt; // Update the bound prompt prop
// Zeige Info-Message wenn Bild noch nicht verfügbar
if (!data.imageUrl && data.message) {
error = data.message;
console.log('Kein Bild, Nachricht:', data.message); // Debug-Log
}
} catch (err) {
console.error('Fehler:', err);
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function resetImage() {
generatedImageUrl = null;
imagePrompt = null;
error = null;
}
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-slate-900">Bild generieren</h3>
{#if !generatedImageUrl}
<button
type="button"
onclick={() => (showOptions = !showOptions)}
class="text-sm text-violet-600 hover:text-violet-500"
>
{showOptions ? 'Optionen ausblenden' : 'Optionen anzeigen'}
</button>
{/if}
</div>
{#if showOptions && !generatedImageUrl}
<div class="space-y-3 rounded-md bg-slate-50 p-3">
<div>
<label for="style" class="block text-sm font-medium text-slate-700"> Bildstil </label>
<select
id="style"
bind:value={selectedStyle}
class="mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
>
<option value="fantasy">Fantasy</option>
<option value="realistic">Realistisch</option>
<option value="anime">Anime</option>
<option value="concept-art">Concept Art</option>
<option value="illustration">Illustration</option>
</select>
</div>
<p class="text-xs text-slate-500">
Das Bild wird basierend auf dem Titel und der Beschreibung generiert.
</p>
</div>
{/if}
<!-- Deutsche Beschreibung und Übersetzung -->
{#if appearance && !generatedImageUrl}
<div class="space-y-3 rounded-md border border-blue-200 bg-blue-50/50 p-3">
<div>
<h4 class="mb-2 text-sm font-medium text-slate-700">Deutsche Beschreibung:</h4>
<p class="rounded border bg-white p-2 text-sm text-slate-600">{appearance}</p>
</div>
{#if !imagePrompt}
<button
type="button"
onclick={translateToEnglish}
disabled={translating}
class="flex w-full items-center justify-center rounded-md border border-blue-300 bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 shadow-sm hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if translating}
<svg
class="-ml-1 mr-2 h-4 w-4 animate-spin text-blue-600"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Übersetze...
{:else}
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
/>
</svg>
Ins Englische übersetzen
{/if}
</button>
{:else}
<div>
<h4 class="mb-2 flex items-center text-sm font-medium text-green-700">
<svg
class="mr-2 h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Englischer Bild-Prompt:
</h4>
<p class="rounded border border-green-200 bg-green-50 p-2 text-sm text-slate-600">
{imagePrompt}
</p>
</div>
{/if}
</div>
{/if}
{#if generatedImageUrl}
<div class="relative">
<img
src={generatedImageUrl}
alt={`Generiertes Bild für ${title}`}
class="{getImageClass()} rounded-lg object-cover shadow-md"
/>
<button
type="button"
onclick={resetImage}
class="bg-theme-surface/90 absolute right-2 top-2 rounded-full p-2 shadow-lg backdrop-blur-sm transition-all hover:bg-theme-surface"
title="Neues Bild generieren"
>
<svg
class="h-5 w-5 text-theme-text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
{:else}
<button
type="button"
onclick={generateImage}
disabled={loading || (!title && !prompt) || (appearance && !imagePrompt)}
class="border-theme-border-default flex w-full items-center justify-center rounded-md border bg-theme-surface px-4 py-3 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if loading}
<svg
class="-ml-1 mr-3 h-5 w-5 animate-spin text-theme-text-primary"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Generiere Bild...
{:else if appearance && !imagePrompt}
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 15.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
Bitte zuerst deutsche Beschreibung übersetzen
{:else}
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Bild mit KI generieren
{/if}
</button>
{/if}
{#if error}
<div class="rounded-md bg-yellow-50/50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-theme-warning">{error}</p>
</div>
</div>
</div>
{/if}
{#if imagePrompt}
<details class="text-xs text-theme-text-secondary">
<summary class="cursor-pointer hover:text-theme-text-primary">Verwendeter Prompt</summary>
<p class="mt-2 rounded bg-theme-elevated p-2 font-mono text-xs text-theme-text-secondary">
{imagePrompt}
</p>
</details>
{/if}
</div>

View file

@ -0,0 +1,343 @@
<script lang="ts">
import type { NodeKind, PromptTemplate } from '$lib/types/content';
import PromptTemplateSelector from './PromptTemplateSelector.svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
kind: NodeKind;
onGenerated: (data: any, prompt: string) => void;
context?: any;
selectedCharacters?: string[];
selectedPlace?: string | null;
placeholder?: string;
}
let { kind, onGenerated, context, selectedCharacters, selectedPlace, placeholder }: Props =
$props();
let prompt = $state('');
let generating = $state(false);
let error = $state<string | null>(null);
let showSaveTemplateDialog = $state(false);
let templateTitle = $state('');
let templateDescription = $state('');
const defaultPlaceholders: Record<NodeKind, string> = {
character:
'Z.B. "Ein weiser alter Magier mit einem dunklen Geheimnis" oder "Eine mutige Kriegerin aus dem Norden"',
world:
'Z.B. "Eine düstere Cyberpunk-Welt mit magischen Elementen" oder "Ein friedliches Königreich am Meer"',
place:
'Z.B. "Ein mysteriöser Wald, in dem die Zeit anders verläuft" oder "Eine schwimmende Stadt in den Wolken"',
object:
'Z.B. "Ein Amulett, das seinem Träger besondere Kräfte verleiht" oder "Ein verfluchtes Schwert"',
story:
'Z.B. "Eine Heldenreise, bei der ungleiche Gefährten zusammenfinden" oder "Ein Krimi in einer magischen Stadt"',
};
function handleTemplateSelect(template: PromptTemplate | null) {
if (template) {
let appliedPrompt = template.prompt_template;
// Variablen ersetzen
if ($currentWorld) {
appliedPrompt = appliedPrompt.replace(/{world_name}/g, $currentWorld.title);
}
prompt = appliedPrompt;
}
}
// Load character details for AI context
let characterDetails = $state<any[]>([]);
let placeDetails = $state<any | null>(null);
async function loadCharacterDetails() {
if (!selectedCharacters || selectedCharacters.length === 0) {
characterDetails = [];
return;
}
try {
const details = await Promise.all(
selectedCharacters.map(async (slug) => {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
return await response.json();
}
return null;
})
);
characterDetails = details.filter(Boolean);
} catch (err) {
console.error('Failed to load character details:', err);
}
}
async function loadPlaceDetails() {
if (!selectedPlace) {
placeDetails = null;
return;
}
try {
const response = await fetch(`/api/nodes/${selectedPlace}`);
if (response.ok) {
placeDetails = await response.json();
}
} catch (err) {
console.error('Failed to load place details:', err);
}
}
$effect(() => {
loadCharacterDetails();
loadPlaceDetails();
});
async function handleGenerate() {
if (!prompt.trim() || generating) return;
generating = true;
error = null;
// Start loading indicator für kompletten Prozess
loadingStore.startCompleteCreation(kind);
// Build enhanced context with character and place details
// Bei Welt-Erstellung: Keinen worldData Context mitschicken!
let enhancedContext =
kind === 'world' ? { ...context, worldData: undefined, world: undefined } : { ...context };
if (characterDetails.length > 0) {
enhancedContext.selectedCharacters = characterDetails.map((char) => ({
name: char.title,
slug: char.slug,
summary: char.summary,
appearance: char.content?.appearance,
voice_style: char.content?.voice_style,
motivations: char.content?.motivations,
capabilities: char.content?.capabilities,
}));
}
if (placeDetails) {
enhancedContext.selectedPlace = {
name: placeDetails.title,
slug: placeDetails.slug,
summary: placeDetails.summary,
appearance: placeDetails.content?.appearance,
capabilities: placeDetails.content?.capabilities,
constraints: placeDetails.content?.constraints,
secrets: placeDetails.content?.secrets,
};
}
try {
const response = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
prompt,
context: enhancedContext,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Generierung fehlgeschlagen');
}
const result = await response.json();
// Wechsel zum nächsten Schritt (Erstellen)
loadingStore.nextStep('KI-Generierung abgeschlossen');
onGenerated(result, prompt); // Prompt mitgeben für Speicherung
prompt = '';
// Loading wird in NodeForm fortgesetzt
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
loadingStore.setError(error || 'Generierung fehlgeschlagen');
} finally {
generating = false;
}
}
async function saveAsTemplate() {
if (!templateTitle.trim() || !prompt.trim()) return;
try {
const response = await fetch('/api/prompt-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
title: templateTitle,
prompt_template: prompt,
description: templateDescription,
world_slug: $currentWorld?.slug,
is_public: false,
}),
});
if (response.ok) {
showSaveTemplateDialog = false;
templateTitle = '';
templateDescription = '';
}
} catch (err) {
console.error('Failed to save template:', err);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
}
</script>
<div class="space-y-4">
<!-- Template Selector with Save Button -->
<div class="flex items-end gap-3">
<div class="flex-1">
<PromptTemplateSelector {kind} onSelect={handleTemplateSelect} />
</div>
<button
type="button"
onclick={() => (showSaveTemplateDialog = true)}
disabled={!prompt.trim()}
class="border-theme-border-default rounded border bg-theme-surface px-3 py-1.5 text-sm font-medium text-theme-text-primary transition-colors hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
title="Aktuellen Prompt als Vorlage speichern"
>
Als Vorlage speichern
</button>
</div>
<!-- Prompt Input -->
<div class="relative">
<label for="ai-prompt" class="mb-2 block text-sm font-medium text-theme-text-primary">
<span class="inline-flex items-center">
<svg
class="mr-1.5 h-4 w-4 text-theme-primary-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
KI-Prompt
</span>
</label>
<div class="relative">
<textarea
id="ai-prompt"
bind:value={prompt}
onkeydown={handleKeydown}
disabled={generating}
rows="3"
placeholder={placeholder || defaultPlaceholders[kind]}
class="block w-full resize-none rounded border border-theme-border-default bg-theme-surface pr-20 text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 disabled:opacity-50"
></textarea>
<button
type="button"
onclick={handleGenerate}
disabled={generating || !prompt.trim()}
class="absolute bottom-1.5 right-1.5 inline-flex items-center rounded px-3 py-1.5 text-sm font-medium text-white {generating
? 'bg-orange-600'
: 'bg-theme-primary-600 hover:bg-theme-primary-700'} transition-colors focus:outline-none focus:ring-2 focus:ring-theme-primary-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? 'Generiert...' : 'Generieren'}
</button>
</div>
<p class="mt-2 text-xs text-theme-text-secondary">
Beschreibe was du erstellen möchtest und drücke Enter oder klicke auf Generieren.
</p>
</div>
{#if error}
<div class="flex items-center rounded border border-theme-error bg-theme-error/10 p-2">
<svg class="mr-1 h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<p class="text-sm text-red-800 dark:text-red-400">{error}</p>
</div>
{/if}
{#if showSaveTemplateDialog}
<div class="border-theme-border-default rounded border bg-theme-surface p-4 shadow-sm">
<h4 class="mb-3 text-sm font-medium text-theme-text-primary">Prompt als Vorlage speichern</h4>
<div class="space-y-3">
<div>
<label
for="template-title"
class="mb-1 block text-xs font-medium text-theme-text-primary"
>
Name der Vorlage *
</label>
<input
id="template-title"
type="text"
bind:value={templateTitle}
placeholder="z.B. Cyberpunk-Welt mit Magie"
class="block w-full rounded border border-theme-border-default bg-theme-surface text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500"
/>
</div>
<div>
<label for="template-desc" class="mb-1 block text-xs font-medium text-theme-text-primary">
Beschreibung (optional)
</label>
<textarea
id="template-desc"
bind:value={templateDescription}
placeholder="Wofür ist diese Vorlage gedacht?"
rows="2"
class="block w-full resize-none rounded border border-theme-border-default bg-theme-surface text-sm text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500"
></textarea>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-theme-text-primary">
Zu speichernder Prompt:
</label>
<div
class="rounded border border-theme-border-subtle bg-theme-surface p-2 text-sm text-theme-text-secondary"
>
{prompt}
</div>
</div>
<div class="flex justify-end space-x-2 pt-2">
<button
type="button"
onclick={() => {
showSaveTemplateDialog = false;
templateTitle = '';
templateDescription = '';
}}
class="border-theme-border-default rounded border bg-theme-surface px-3 py-1.5 text-sm font-medium text-theme-text-primary transition-colors hover:bg-theme-interactive-hover"
>
Abbrechen
</button>
<button
type="button"
onclick={saveAsTemplate}
disabled={!templateTitle.trim()}
class="rounded bg-theme-primary-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-theme-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,112 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
worldSlug: string;
selectedCharacters: string[];
onSelectionChange: (selected: string[]) => void;
}
let { worldSlug, selectedCharacters, onSelectionChange }: Props = $props();
let characters = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadCharacters() {
if (!worldSlug) return;
try {
const response = await fetch(`/api/nodes?kind=character&world_slug=${worldSlug}`);
if (!response.ok) throw new Error('Failed to load characters');
characters = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Laden der Charaktere';
} finally {
loading = false;
}
}
function toggleCharacter(characterSlug: string) {
const newSelection = selectedCharacters.includes(characterSlug)
? selectedCharacters.filter((slug) => slug !== characterSlug)
: [...selectedCharacters, characterSlug];
onSelectionChange(newSelection);
}
$effect(() => {
loadCharacters();
});
</script>
<div>
<label class="block text-sm font-medium text-theme-text-primary mb-3">
Charaktere auswählen
</label>
{#if loading}
<div class="text-sm text-theme-text-secondary">Lade Charaktere...</div>
{:else if error}
<div class="text-sm text-theme-error">
{error}
</div>
{:else if characters.length === 0}
<div class="text-sm text-theme-text-secondary">
Keine Charaktere in dieser Welt gefunden.
<a
href="/worlds/{worldSlug}/characters/new"
class="text-theme-primary-600 hover:text-theme-primary-500"
>
Ersten Charakter erstellen
</a>
</div>
{:else}
<div
class="space-y-2 max-h-60 overflow-y-auto border border-theme-border-default rounded-md p-3"
>
{#each characters as character}
<label
class="flex items-center space-x-3 cursor-pointer hover:bg-theme-elevated p-2 rounded"
>
<input
type="checkbox"
checked={selectedCharacters.includes(character.slug)}
onchange={() => toggleCharacter(character.slug)}
class="rounded border-theme-border-default text-theme-primary-600 focus:ring-theme-primary-500"
/>
<div class="flex-1">
<div class="flex items-center space-x-2">
{#if character.image_url}
<img
src={character.image_url}
alt={character.title}
class="w-8 h-8 rounded-full object-cover"
/>
{/if}
<div>
<div class="text-sm font-medium text-theme-text-primary">
{character.title}
</div>
<div class="text-xs text-theme-text-secondary">
@{character.slug}
</div>
</div>
</div>
{#if character.summary}
<div class="text-xs text-theme-text-secondary mt-1 line-clamp-1">
{character.summary}
</div>
{/if}
</div>
</label>
{/each}
</div>
{#if selectedCharacters.length > 0}
<div class="mt-2 text-xs text-theme-text-secondary">
Ausgewählt: {selectedCharacters.map((slug) => `@${slug}`).join(', ')}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { slide } from 'svelte/transition';
interface Props {
title?: string;
initiallyOpen?: boolean;
hasContent?: boolean;
children?: any;
}
let {
title = 'Weitere Optionen',
initiallyOpen = false,
hasContent = false,
children,
}: Props = $props();
// Automatically open if there's content or manually requested
let isOpen = $state(initiallyOpen || hasContent);
// Update isOpen when hasContent changes
$effect(() => {
if (hasContent && !isOpen) {
isOpen = true;
}
});
function toggle() {
isOpen = !isOpen;
}
</script>
<div class="border-t pt-6">
<button
type="button"
onclick={toggle}
class="-m-2 flex w-full items-center justify-between rounded-md p-2 text-left transition-colors hover:bg-theme-interactive-hover focus:outline-none focus:ring-2 focus:ring-theme-primary-500 focus:ring-offset-2"
>
<h2 class="text-lg font-medium text-theme-text-primary">{title}</h2>
<svg
class="h-5 w-5 text-theme-text-secondary transition-transform {isOpen ? 'rotate-180' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div transition:slide={{ duration: 200 }} class="mt-4 space-y-4">
{@render children?.()}
</div>
{/if}
</div>

View file

@ -0,0 +1,607 @@
<script lang="ts">
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
import AiImageGenerator from './AiImageGenerator.svelte';
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import type { NodeKind } from '$lib/types/content';
let command = $state('');
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state<string | null>(null);
let processingCommands = $state<Set<string>>(new Set());
// Image generation state
let imagePrompt = $state('');
let imageUrl = $state<string | null>(null);
let generatedPrompt = $state<string | null>(null);
// Subscribe to store
let aiState = $state({
isVisible: false,
currentNode: null as any,
isOwner: false,
mode: 'text' as 'text' | 'image',
imageGenerationState: {
loading: false,
generatedUrl: null as string | null,
prompt: '',
style: 'fantasy' as any,
error: null as string | null,
},
});
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = aiAuthorStore.subscribe((state) => {
console.log('🌟 GlobalAiAuthorBar: Store update', state);
aiState = state;
// Auto-populate image prompt from node appearance
if (state.mode === 'image' && state.currentNode && !imagePrompt) {
const node = state.currentNode;
imagePrompt = node.content?.appearance || node.summary || '';
}
});
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
});
// Auto-hide success/error messages
let successTimeout: ReturnType<typeof setTimeout>;
let errorTimeout: ReturnType<typeof setTimeout>;
function showSuccess(message: string) {
success = message;
clearTimeout(successTimeout);
successTimeout = setTimeout(() => {
success = null;
}, 4000);
}
function showError(message: string) {
error = message;
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
error = null;
}, 6000);
}
async function executeCommand() {
const currentCommand = command.trim();
if (!currentCommand || processingCommands.has(currentCommand) || !aiState.currentNode) return;
// Add to processing queue
processingCommands.add(currentCommand);
processingCommands = new Set(processingCommands);
// Clear input immediately for better UX
command = '';
loading = true;
error = null;
// Show processing feedback
showSuccess(
`🔄 Bearbeite: "${currentCommand.substring(0, 50)}${currentCommand.length > 50 ? '...' : ''}"`
);
try {
const response = await fetch('/api/ai/edit-node', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nodeSlug: aiState.currentNode.slug,
command: currentCommand,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Bearbeiten');
}
if (data.success && data.updatedNode) {
aiAuthorStore.updateNode(data.updatedNode);
showSuccess(`✅ Erfolgreich bearbeitet: "${currentCommand.substring(0, 30)}..."`);
// Dispatch custom event to notify components
window.dispatchEvent(
new CustomEvent('node-updated', {
detail: { updatedNode: data.updatedNode },
})
);
} else {
throw new Error('Unexpected response format');
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Ein unerwarteter Fehler ist aufgetreten';
showError(`❌ Fehler: ${errorMessage}`);
} finally {
// Remove from processing queue
processingCommands.delete(currentCommand);
processingCommands = new Set(processingCommands);
loading = processingCommands.size > 0;
}
}
async function handleImageGenerated(url: string) {
imageUrl = url;
aiAuthorStore.setImageState({ generatedUrl: url });
await saveGeneratedImage();
}
async function saveGeneratedImage() {
if (!imageUrl || !aiState.currentNode) return;
loading = true;
error = null;
try {
// Use the proper attachments-based endpoint to save image
const response = await fetch(`/api/nodes/${aiState.currentNode.slug}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
prompt: generatedPrompt || imagePrompt,
is_primary: false,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
}
showSuccess('🖼️ Bild erfolgreich gespeichert!');
// Reset image state
imageUrl = null;
generatedPrompt = null;
aiAuthorStore.resetImageState();
// Notify components to reload images
window.dispatchEvent(
new CustomEvent('images-updated', {
detail: { nodeSlug: aiState.currentNode.slug },
})
);
} catch (err) {
showError(err instanceof Error ? err.message : 'Fehler beim Speichern');
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
// Only handle global shortcuts when author bar is focused
if (e.target && (e.target as HTMLElement).closest('#global-ai-author-bar')) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (aiState.mode === 'text') {
executeCommand();
}
}
}
// Global escape to close
if (e.key === 'Escape' && aiState.isVisible) {
aiAuthorStore.hide();
}
}
function toggleVisibility() {
aiAuthorStore.toggle();
if (aiState.isVisible) {
// Focus the textarea when shown
setTimeout(() => {
if (aiState.mode === 'text') {
const textarea = document.querySelector(
'#global-ai-command-input'
) as HTMLTextAreaElement;
textarea?.focus();
}
}, 100);
}
}
function switchMode(mode: 'text' | 'image') {
aiAuthorStore.setMode(mode);
error = null;
success = null;
}
// Command suggestions based on node type
function getSuggestions() {
if (!aiState.currentNode) return [];
const suggestions = {
character: [
'Benenne um zu Maximilian der Große',
'Füge zur Erscheinung hinzu: trägt einen roten Mantel',
'Ändere die Fähigkeiten zu: Meister der Feuermagie',
'Aktualisiere das Inventar: trägt @magisches-schwert',
],
place: [
'Benenne um zu Die goldene Stadt',
'Füge zur Geschichte hinzu: wurde vor 100 Jahren erbaut',
'Ändere die Gefahren zu: wilde Kreaturen in der Nacht',
'Aktualisiere den Zustand: jetzt in Ruinen',
],
object: [
'Benenne um zu Schwert der Macht',
'Füge zu den Fähigkeiten hinzu: kann Feinde blenden',
'Ändere den Besitzer zu: gehört jetzt @aragorn',
'Aktualisiere die Erscheinung: glänzt in blauem Licht',
],
world: [
'Benenne um zu Reich der tausend Sonnen',
'Füge zur Geschichte hinzu: geprägt von magischen Kriegen',
'Aktualisiere die Regeln: Magie ist verboten',
'Ändere die Zeitlinie: Das große Erwachen im Jahr 2157',
],
story: [
'Benenne um zu Das letzte Abenteuer',
'Füge zum Plot hinzu: die Helden treffen auf einen Drachen',
'Ändere die Referenzen zu: @mira, @dunkler-turm, @zauberring',
'Aktualisiere den Verlauf: endet mit einem Cliffhanger',
],
};
return suggestions[aiState.currentNode.kind as NodeKind] || [];
}
let suggestions = $derived(getSuggestions());
</script>
<!-- Floating Toast Notifications -->
<div class="fixed right-4 top-20 z-50 space-y-2">
{#if success}
<div
transition:fly={{ x: 100, duration: 300 }}
class="max-w-sm rounded-lg border border-theme-border-subtle bg-theme-surface shadow-lg"
>
<div class="flex items-start p-4">
<div class="flex-shrink-0">
{#if success.includes('🔄')}
<svg
class="h-5 w-5 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<svg class="h-5 w-5 text-theme-success" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
<p class="ml-3 text-sm text-theme-text-primary">{success}</p>
</div>
</div>
{/if}
{#if error}
<div
transition:fly={{ x: 100, duration: 300 }}
class="max-w-sm rounded-lg border border-theme-error/20 bg-theme-error/10 shadow-lg"
>
<div class="flex items-start p-4">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-theme-error" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<p class="ml-3 text-sm text-theme-error">{error}</p>
</div>
</div>
{/if}
</div>
<!-- Global Floating Toggle Button -->
{#if aiState.currentNode && aiState.isOwner}
<button
onclick={toggleVisibility}
class="fixed bottom-4 right-4 z-40 rounded-full bg-gradient-to-br from-theme-primary-500 to-theme-primary-600 p-3 text-white shadow-lg transition-all duration-200 hover:shadow-xl hover:scale-110 {aiState.isVisible
? 'rotate-45'
: ''} {loading ? 'animate-pulse' : ''}"
title="AI Author Bar {aiState.isVisible ? 'schließen' : 'öffnen'}"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{#if loading}
<div class="absolute -right-1 -top-1 h-3 w-3">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-theme-primary-400 opacity-75"
></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-theme-primary-500"></span>
</div>
{/if}
</button>
{/if}
<!-- Global Author Bar -->
{#if aiState.currentNode && aiState.isOwner}
<div
id="global-ai-author-bar"
class="fixed inset-x-0 bottom-0 z-50 border-t border-theme-border-default bg-theme-surface/95 backdrop-blur-md shadow-2xl transition-transform duration-300 {aiState.isVisible
? 'translate-y-0'
: 'translate-y-full'}"
>
<div class="mx-auto max-w-4xl p-4">
<!-- Header with Tabs -->
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<div class="relative">
<div
class="h-3 w-3 rounded-full {loading
? 'bg-theme-primary-500 animate-pulse'
: 'bg-theme-success'}"
></div>
{#if processingCommands.size > 0}
<div
class="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-theme-primary-600 text-[10px] text-white"
>
{processingCommands.size}
</div>
{/if}
</div>
<h3 class="text-base font-medium text-theme-text-primary">✨ AI Author</h3>
</div>
<!-- Tab Navigation -->
<div class="flex rounded-lg bg-theme-elevated p-0.5">
<button
onclick={() => switchMode('text')}
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
'text'
? 'bg-theme-surface text-theme-text-primary shadow-sm'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>Text</span>
</button>
<button
onclick={() => switchMode('image')}
class="flex items-center space-x-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {aiState.mode ===
'image'
? 'bg-theme-surface text-theme-text-primary shadow-sm'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Bild</span>
</button>
</div>
</div>
<button
onclick={() => aiAuthorStore.hide()}
class="p-1 text-theme-text-secondary transition-colors hover:text-theme-text-primary"
title="Schließen (Esc)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content Area -->
{#if aiState.mode === 'text'}
<!-- Text Edit Mode -->
<div class="space-y-3">
<!-- Command Input -->
<div class="relative">
<textarea
id="global-ai-command-input"
bind:value={command}
onkeydown={handleKeydown}
placeholder="z.B. 'Benenne um zu Maximilian der Große' oder 'Füge zur Erscheinung hinzu: trägt eine goldene Krone'"
rows="2"
class="w-full resize-none rounded-md border border-theme-border-default bg-theme-background pr-20 text-sm shadow-sm transition-all focus:border-theme-primary-500 focus:ring-2 focus:ring-theme-primary-500/20 {loading
? 'pl-10'
: ''}"
></textarea>
{#if loading}
<div class="absolute left-3 top-3">
<svg
class="h-4 w-4 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
{/if}
<div class="absolute bottom-1 right-2 text-xs text-theme-text-secondary">⌘+Enter</div>
</div>
<!-- Quick Suggestions -->
{#if suggestions.length > 0 && !command.trim()}
<div class="scrollbar-thin flex gap-2 overflow-x-auto pb-1">
{#each suggestions as suggestion}
<button
onclick={() => (command = suggestion)}
class="flex-shrink-0 whitespace-nowrap rounded-full border border-theme-border-default bg-theme-elevated px-3 py-1 text-xs transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
{suggestion}
</button>
{/each}
</div>
{/if}
<!-- Processing Queue Display -->
{#if processingCommands.size > 0}
<div class="rounded-lg bg-theme-primary-500/10 p-2">
<p class="mb-1 text-xs font-medium text-theme-text-secondary">
Verarbeite {processingCommands.size} Befehl{processingCommands.size !== 1
? 'e'
: ''}:
</p>
<div class="space-y-1">
{#each Array.from(processingCommands) as cmd}
<div class="flex items-center space-x-2 text-xs text-theme-text-secondary">
<svg
class="h-3 w-3 animate-spin text-theme-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="truncate"
>{cmd.substring(0, 50)}{cmd.length > 50 ? '...' : ''}</span
>
</div>
{/each}
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex items-center justify-between">
<div class="text-xs text-theme-text-secondary">
<span class="inline-flex items-center">
<span
class="mr-1 h-2 w-2 rounded-full {loading
? 'animate-pulse bg-theme-primary-500'
: 'bg-theme-success'}"
></span>
{loading
? `Verarbeite ${processingCommands.size} Befehl${processingCommands.size !== 1 ? 'e' : ''}...`
: 'AI bereit'}
</span>
</div>
<div class="flex space-x-2">
<button
onclick={() => aiAuthorStore.hide()}
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
Schließen
</button>
<button
onclick={executeCommand}
disabled={!command.trim()}
class="flex items-center space-x-2 rounded bg-gradient-to-r from-theme-primary-500 to-theme-primary-600 px-4 py-1.5 text-sm text-white transition-all hover:from-theme-primary-600 hover:to-theme-primary-700 hover:shadow-lg disabled:opacity-50"
>
<span>✨ Mit AI bearbeiten</span>
{#if loading}
<span class="text-xs opacity-75">({processingCommands.size})</span>
{/if}
</button>
</div>
</div>
</div>
{:else}
<!-- Image Generation Mode -->
<div class="space-y-4">
{#if aiState.currentNode}
<AiImageGenerator
kind={aiState.currentNode.kind}
title={aiState.currentNode.title}
description={aiState.currentNode.summary}
appearance={aiState.currentNode.content?.appearance}
bind:imageUrl
bind:prompt={generatedPrompt}
onImageGenerated={handleImageGenerated}
/>
{/if}
{#if imageUrl}
<div class="flex justify-end space-x-2 border-t border-theme-border-subtle pt-3">
<button
onclick={() => {
imageUrl = null;
generatedPrompt = null;
aiAuthorStore.resetImageState();
}}
class="rounded border border-theme-border-default px-3 py-1.5 text-sm text-theme-text-primary transition-colors hover:bg-theme-interactive-hover"
>
Verwerfen
</button>
<button
onclick={saveGeneratedImage}
disabled={loading}
class="rounded bg-theme-primary-600 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Zur Galerie hinzufügen'}
</button>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<svelte:window onkeydown={handleKeydown} />

View file

@ -0,0 +1,222 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
interface ImageItem {
id: string;
image_url: string;
prompt?: string;
is_primary: boolean;
sort_order: number;
created_at: string;
}
interface Props {
images: ImageItem[];
nodeSlug: string;
nodeKind: NodeKind;
editable?: boolean;
onImageUpdate?: () => void;
}
let { images = [], nodeSlug, nodeKind, editable = false, onImageUpdate }: Props = $props();
let selectedImage = $state<ImageItem | null>(null);
let showLightbox = $state(false);
let loading = $state(false);
// Sort images: primary first, then by sort_order
let sortedImages = $derived(
[...images].sort((a, b) => {
if (a.is_primary && !b.is_primary) return -1;
if (!a.is_primary && b.is_primary) return 1;
return a.sort_order - b.sort_order;
})
);
let primaryImage = $derived(sortedImages.find((img) => img.is_primary) || sortedImages[0]);
let galleryImages = $derived(sortedImages.filter((img) => !img.is_primary));
function openLightbox(image: ImageItem) {
selectedImage = image;
showLightbox = true;
}
function closeLightbox() {
showLightbox = false;
selectedImage = null;
}
async function setPrimaryImage(imageId: string) {
if (!editable || loading) return;
loading = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_primary: true }),
});
if (response.ok) {
onImageUpdate?.();
} else {
console.error('Failed to set primary image');
}
} catch (error) {
console.error('Error setting primary image:', error);
} finally {
loading = false;
}
}
async function deleteImage(imageId: string) {
if (!editable || loading) return;
loading = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/images/${imageId}`, {
method: 'DELETE',
});
if (response.ok) {
onImageUpdate?.();
} else {
console.error('Failed to delete image');
}
} catch (error) {
console.error('Error deleting image:', error);
} finally {
loading = false;
}
}
// Get aspect ratio class based on node kind for primary image display
function getAspectClass() {
switch (nodeKind) {
case 'world':
case 'place':
return 'w-full aspect-[21/9]'; // 21:9 ultrawide
case 'character':
return 'w-full aspect-[9/16]'; // Portrait 9:16 format
case 'object':
default:
return 'w-full aspect-square'; // 1:1
}
}
</script>
{#if images.length > 0}
<!-- Primary Image Display -->
{#if primaryImage}
<div class="mb-6">
<div class="group relative">
<button onclick={() => openLightbox(primaryImage)} class="block w-full">
<img
src={primaryImage.image_url}
alt="Hauptbild"
class={`${getAspectClass()} rounded-lg object-cover shadow-lg transition-shadow hover:shadow-xl`}
onload={() => console.log('🖼️ Primary image loaded:', primaryImage.image_url)}
onerror={(e) =>
console.error('🚨 Primary image failed to load:', primaryImage.image_url, e)}
/>
</button>
</div>
</div>
{/if}
<!-- Gallery Grid -->
{#if galleryImages.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{#each galleryImages as image}
<div class="group relative">
<button onclick={() => openLightbox(image)} class="block w-full">
<img
src={image.image_url}
alt="Galeriebild"
class="aspect-square w-full rounded-lg object-cover shadow transition-shadow hover:shadow-lg"
onload={() => console.log('🖼️ Gallery image loaded:', image.image_url)}
onerror={(e) => console.error('🚨 Gallery image failed to load:', image.image_url, e)}
/>
</button>
{#if editable}
<div
class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => setPrimaryImage(image.id)}
disabled={loading}
class="rounded-full bg-white p-1 shadow-md hover:bg-yellow-50 disabled:opacity-50"
title="Als Hauptbild setzen"
>
<svg class="h-4 w-4 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
/>
</svg>
</button>
<button
onclick={() => deleteImage(image.id)}
disabled={loading}
class="hover:bg-theme-error/10 rounded-full bg-theme-surface p-1 shadow-md disabled:opacity-50"
title="Löschen"
>
<svg
class="h-4 w-4 text-theme-error"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{:else}
<div class="py-8 text-center text-gray-500">Noch keine Bilder vorhanden</div>
{/if}
<!-- Lightbox -->
{#if showLightbox && selectedImage}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90 p-4"
onclick={closeLightbox}
>
<div class="relative max-h-full max-w-6xl">
<img
src={selectedImage.image_url}
alt="Vollbild"
class="max-h-[90vh] max-w-full object-contain"
onclick={(e) => e.stopPropagation()}
/>
<button onclick={closeLightbox} class="absolute right-4 top-4 text-white hover:text-gray-300">
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if selectedImage.prompt}
<div
class="absolute bottom-4 left-4 right-4 mx-auto max-w-2xl rounded-lg bg-black bg-opacity-75 p-4 text-white"
>
<p class="text-sm">{selectedImage.prompt}</p>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,319 @@
<script lang="ts">
import { fade, scale } from 'svelte/transition';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
show: boolean;
nodeSlug: string;
onClose: () => void;
onUploadComplete: () => void;
}
let { show, nodeSlug, onClose, onUploadComplete }: Props = $props();
let dragActive = $state(false);
let selectedFiles = $state<File[]>([]);
let uploadProgress = $state<number>(0);
let uploading = $state(false);
let fileInput: HTMLInputElement;
let previews = $state<{ file: File; url: string }[]>([]);
// Max file size: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
function handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragActive = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
// Only set dragActive to false if we're leaving the drop zone entirely
const target = e.target as HTMLElement;
const relatedTarget = e.relatedTarget as HTMLElement;
if (!target.closest('.drop-zone') || !relatedTarget?.closest('.drop-zone')) {
dragActive = false;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragActive = false;
const files = Array.from(e.dataTransfer?.files || []);
processFiles(files);
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const files = Array.from(target.files || []);
processFiles(files);
}
function processFiles(files: File[]) {
const validFiles = files.filter((file) => {
if (!ALLOWED_TYPES.includes(file.type)) {
alert(`${file.name} ist kein unterstütztes Bildformat`);
return false;
}
if (file.size > MAX_FILE_SIZE) {
alert(`${file.name} ist zu groß (max. 10MB)`);
return false;
}
return true;
});
selectedFiles = [...selectedFiles, ...validFiles];
// Create preview URLs
validFiles.forEach((file) => {
const url = URL.createObjectURL(file);
previews = [...previews, { file, url }];
});
}
function removeFile(index: number) {
// Revoke the URL to free memory
URL.revokeObjectURL(previews[index].url);
selectedFiles = selectedFiles.filter((_, i) => i !== index);
previews = previews.filter((_, i) => i !== index);
}
async function uploadFiles() {
if (selectedFiles.length === 0) return;
uploading = true;
// Create upload steps based on number of files
const steps = selectedFiles.map(
(file, i) => `Lade Bild ${i + 1}/${selectedFiles.length}: ${file.name}`
);
loadingStore.start('Bilder werden hochgeladen', steps);
uploadProgress = 0;
try {
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
const formData = new FormData();
formData.append('image', file);
// Set first image as primary if no images exist yet
formData.append('is_primary', i === 0 ? 'true' : 'false');
const response = await fetch(`/api/nodes/${nodeSlug}/images/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Upload fehlgeschlagen: ${error}`);
}
uploadProgress = ((i + 1) / selectedFiles.length) * 100;
loadingStore.nextStep(`Bild ${i + 1} erfolgreich hochgeladen`);
}
// Clean up preview URLs
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
// Reset state
selectedFiles = [];
previews = [];
uploadProgress = 0;
// Mark loading as complete
loadingStore.complete('Alle Bilder erfolgreich hochgeladen');
// Notify parent
onUploadComplete();
onClose();
} catch (error) {
console.error('Upload error:', error);
loadingStore.setError(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
alert(error instanceof Error ? error.message : 'Upload fehlgeschlagen');
// Reset loading after error
setTimeout(() => loadingStore.reset(), 2000);
} finally {
uploading = false;
}
}
function openFileDialog() {
fileInput?.click();
}
// Clean up URLs when component is destroyed
$effect(() => {
return () => {
previews.forEach((preview) => URL.revokeObjectURL(preview.url));
};
});
</script>
{#if show}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
transition:fade={{ duration: 200 }}
onclick={onClose}
>
<div
class="relative w-full max-w-3xl rounded-lg bg-theme-surface p-6 shadow-xl"
transition:scale={{ duration: 200, start: 0.95 }}
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<h2 class="text-2xl font-bold text-theme-text-primary">Bilder hochladen</h2>
<button
onclick={onClose}
class="rounded-lg p-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Drop Zone -->
<div
class="drop-zone mb-6 rounded-lg border-2 border-dashed p-8 text-center transition-colors
{dragActive
? 'border-theme-primary-600 bg-theme-primary-100/10'
: 'border-theme-border-subtle hover:border-theme-border-default'}"
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}
>
<svg
class="mx-auto mb-4 h-12 w-12 text-theme-text-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="mb-2 text-lg text-theme-text-primary">
Bilder hier ablegen oder
<button onclick={openFileDialog} class="text-theme-primary-600 hover:underline">
durchsuchen
</button>
</p>
<p class="text-sm text-theme-text-secondary">
JPG, PNG, WebP oder GIF • Max. 10MB pro Bild
</p>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
onchange={handleFileSelect}
class="hidden"
/>
</div>
<!-- Preview Grid -->
{#if previews.length > 0}
<div class="mb-6">
<h3 class="mb-3 text-sm font-medium text-theme-text-primary">
Ausgewählte Bilder ({previews.length})
</h3>
<div class="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
{#each previews as preview, index}
<div class="group relative">
<img
src={preview.url}
alt="Vorschau"
class="aspect-square w-full rounded-lg object-cover"
/>
<button
onclick={() => removeFile(index)}
class="absolute right-1 top-1 rounded-full bg-red-600 p-1 opacity-0 transition-opacity group-hover:opacity-100"
title="Entfernen"
>
<svg
class="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{#if index === 0}
<span
class="absolute bottom-1 left-1 rounded bg-yellow-500 px-1.5 py-0.5 text-xs font-semibold text-white"
>
Hauptbild
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Upload Progress -->
{#if uploading}
<div class="mb-6">
<div class="mb-1 flex justify-between text-sm">
<span class="text-theme-text-secondary">Hochladen...</span>
<span class="text-theme-text-primary">{Math.round(uploadProgress)}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-theme-elevated">
<div
class="h-full bg-theme-primary-600 transition-all duration-300"
style="width: {uploadProgress}%"
/>
</div>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-end gap-3">
<button
onclick={onClose}
disabled={uploading}
class="rounded-lg px-4 py-2 text-theme-text-secondary hover:bg-theme-interactive-hover hover:text-theme-text-primary disabled:opacity-50"
>
Abbrechen
</button>
<button
onclick={uploadFiles}
disabled={selectedFiles.length === 0 || uploading}
class="rounded-lg bg-theme-primary-600 px-4 py-2 text-white hover:bg-theme-primary-700 disabled:opacity-50"
>
{uploading
? 'Wird hochgeladen...'
: `${selectedFiles.length} Bild${selectedFiles.length !== 1 ? 'er' : ''} hochladen`}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,141 @@
<script lang="ts">
import type { NodeKind } from '$lib/types/content';
import AiImageGenerator from './AiImageGenerator.svelte';
interface Props {
nodeSlug: string;
nodeKind: NodeKind;
nodeTitle: string;
nodeDescription?: string;
onImageAdded?: () => void;
}
let { nodeSlug, nodeKind, nodeTitle, nodeDescription, onImageAdded }: Props = $props();
let showGenerator = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let imageUrl = $state<string | null>(null);
let generationPrompt = $state<string | null>(null);
async function handleImageGenerated(url: string) {
imageUrl = url;
await saveImage();
}
async function saveImage() {
if (!imageUrl) return;
loading = true;
error = null;
try {
// Use the proper attachments-based endpoint
const response = await fetch(`/api/nodes/${nodeSlug}/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_url: imageUrl,
prompt: generationPrompt || `${nodeTitle}: ${nodeDescription || ''}`,
is_primary: false, // New images are not primary by default
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Fehler beim Speichern des Bildes');
}
// Reset and close
imageUrl = null;
generationPrompt = null;
showGenerator = false;
onImageAdded?.();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function toggleGenerator() {
showGenerator = !showGenerator;
if (!showGenerator) {
// Reset state when closing
imageUrl = null;
generationPrompt = null;
error = null;
}
}
</script>
<div class="space-y-4">
{#if !showGenerator}
<button
onclick={toggleGenerator}
class="border-theme-border-default flex w-full items-center justify-center rounded-lg border-2 border-dashed px-4 py-3 transition-colors hover:border-theme-primary-400 hover:bg-theme-primary-50"
>
<svg
class="mr-2 h-6 w-6 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="font-medium text-theme-text-secondary">Neues Bild generieren</span>
</button>
{:else}
<div class="rounded-lg border border-theme-border-subtle bg-theme-surface p-6 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-medium text-theme-text-primary">Neues Bild generieren</h3>
<button
onclick={toggleGenerator}
class="text-theme-text-tertiary hover:text-theme-text-primary"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50/50 p-3">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<AiImageGenerator
kind={nodeKind}
title={nodeTitle}
description={nodeDescription}
bind:imageUrl
bind:prompt={generationPrompt}
onImageGenerated={handleImageGenerated}
/>
{#if imageUrl}
<div class="mt-4 flex justify-end space-x-3">
<button
onclick={toggleGenerator}
class="border-theme-border-default rounded-md border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
Abbrechen
</button>
<button
onclick={saveImage}
disabled={loading}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-theme-inverse hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Bild zur Galerie hinzufügen'}
</button>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,289 @@
<script lang="ts">
import { loadingStore } from '$lib/stores/loadingStore';
import { fade, fly, scale } from 'svelte/transition';
import { cubicOut, elasticOut } from 'svelte/easing';
// Reactive state from store
let loading = $derived($loadingStore);
let minimized = $state(false);
let toastMode = $state(false);
// Calculate overall progress
let progress = $derived(() => {
if (!loading.steps.length) return 0;
const completed = loading.steps.filter((s) => s.status === 'completed').length;
return (completed / loading.steps.length) * 100;
});
</script>
{#if loading.isLoading}
<!-- Overlay -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-theme-overlay backdrop-blur-sm"
transition:fade={{ duration: 200 }}
>
<!-- Main Container with Glassmorphism (responsive) -->
<div
class="mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-theme-border-subtle bg-theme-surface/95 shadow-2xl backdrop-blur-md sm:mx-auto {minimized
? 'max-w-xs'
: ''}"
transition:fly={{ y: 50, duration: 300, easing: cubicOut }}
>
<!-- Header with gradient -->
<div
class="relative overflow-hidden border-b border-theme-border-subtle bg-gradient-to-r from-theme-primary-500/10 to-theme-primary-600/10 px-6 py-4"
>
<!-- Animated background pattern -->
<div class="absolute inset-0 opacity-30">
<div
class="absolute -left-4 -top-4 h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-400 to-transparent blur-2xl"
></div>
<div
class="absolute -right-4 -bottom-4 h-24 w-24 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-600 to-transparent blur-2xl animation-delay-1000"
></div>
</div>
<div class="relative flex items-center justify-between">
<h2 class="text-xl font-semibold text-theme-text-primary">
{loading.title}
</h2>
<!-- Minimize button -->
<button
onclick={() => (minimized = !minimized)}
class="rounded-lg p-1 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
title={minimized ? 'Maximieren' : 'Minimieren'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if minimized}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
{/if}
</svg>
</button>
</div>
<!-- Progress bar -->
<div class="relative mt-3 h-1.5 overflow-hidden rounded-full bg-theme-border-default">
<div
class="absolute left-0 top-0 h-full bg-gradient-to-r from-theme-primary-500 to-theme-primary-600 transition-all duration-500 ease-out"
style="width: {progress()}%"
>
<!-- Shimmer effect -->
<div
class="absolute inset-0 animate-shimmer bg-gradient-to-r from-transparent via-white/30 to-transparent"
></div>
</div>
</div>
<!-- Progress percentage -->
{#if progress() > 0}
<div class="mt-2 flex items-center justify-between text-xs">
<span class="text-theme-text-secondary">{Math.round(progress())}% abgeschlossen</span>
{#if loading.estimatedTime && loading.estimatedTime > Date.now()}
<span class="text-theme-text-secondary">
~{Math.max(1, Math.ceil((loading.estimatedTime - Date.now()) / 1000))}s verbleibend
</span>
{/if}
</div>
{/if}
</div>
{#if !minimized}
<!-- Steps Container -->
<div class="px-6 py-4" transition:scale={{ duration: 200, easing: cubicOut }}>
<div class="space-y-3">
{#each loading.steps as step, index}
<div class="flex items-start space-x-3" style="animation-delay: {index * 50}ms">
<!-- Step indicator with animations -->
<div class="relative mt-0.5 flex-shrink-0">
{#if step.status === 'completed'}
<div
class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-theme-success to-theme-success-dark shadow-lg shadow-theme-success/30"
transition:scale={{ duration: 400, easing: elasticOut }}
>
<svg
class="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
transition:scale={{ duration: 600, delay: 100, easing: elasticOut }}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
{:else if step.status === 'active'}
<div class="relative flex h-7 w-7 items-center justify-center">
<!-- Outer ring animation -->
<div
class="absolute inset-0 animate-ping rounded-full bg-theme-primary-400 opacity-30"
></div>
<!-- Inner spinning ring -->
<div
class="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-theme-primary-500 border-r-theme-primary-600"
></div>
<!-- Center dot -->
<div
class="relative h-3 w-3 animate-pulse rounded-full bg-gradient-to-br from-theme-primary-500 to-theme-primary-600"
></div>
</div>
{:else if step.status === 'error'}
<div
class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-theme-error to-theme-error-dark shadow-lg shadow-theme-error/30"
transition:scale={{ duration: 400, easing: elasticOut }}
>
<svg
class="h-4 w-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{:else}
<div
class="h-7 w-7 rounded-full border-2 border-theme-border-default bg-theme-elevated"
></div>
{/if}
</div>
<!-- Step content -->
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium transition-colors duration-200 {step.status ===
'active'
? 'text-theme-text-primary'
: step.status === 'completed'
? 'text-theme-text-secondary'
: 'text-theme-text-tertiary'}"
>
{step.label}
</p>
{#if step.message}
<p
class="mt-1 text-xs text-theme-text-tertiary"
transition:fade={{ duration: 200 }}
>
{step.message}
</p>
{/if}
</div>
</div>
<!-- Animated connection line -->
{#if index < loading.steps.length - 1}
<div class="relative ml-3.5 h-4">
<div
class="absolute left-0 w-px bg-theme-border-default {step.status === 'completed'
? 'bg-gradient-to-b from-theme-success to-theme-success/50'
: ''}"
style="height: 100%; transform-origin: top; transition: all 0.3s ease-out;"
></div>
{#if step.status === 'active'}
<div
class="absolute left-0 w-px animate-flow bg-gradient-to-b from-theme-primary-500 to-transparent"
style="height: 100%;"
></div>
{/if}
</div>
{/if}
{/each}
</div>
<!-- Error message -->
{#if loading.error}
<div
class="mt-4 rounded-lg bg-theme-error/10 border border-theme-error/20 p-3"
transition:scale={{ duration: 200 }}
>
<p class="text-sm text-theme-error">{loading.error}</p>
</div>
{/if}
<!-- Fun Fact -->
{#if loading.funFact && !loading.error}
<div
class="mt-4 rounded-lg bg-gradient-to-r from-theme-primary-500/5 to-theme-primary-600/5 border border-theme-border-subtle p-3"
transition:fade={{ duration: 300 }}
>
<p class="text-xs italic text-theme-text-secondary">
{loading.funFact}
</p>
</div>
{/if}
</div>
<!-- Footer actions -->
{#if loading.error}
<div class="rounded-b-2xl bg-theme-elevated px-6 py-3">
<button
onclick={() => loadingStore.reset()}
class="w-full rounded-lg border border-theme-border-default bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary transition-all hover:bg-theme-interactive-hover hover:shadow-md"
>
Schließen
</button>
</div>
{/if}
{/if}
</div>
</div>
{/if}
<style>
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}
@keyframes flow {
0% {
opacity: 0;
transform: translateY(-100%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
.animate-flow {
animation: flow 1.5s ease-in-out infinite;
}
.animation-delay-1000 {
animation-delay: 1s;
}
</style>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
node: ContentNode;
href: string;
}
let { node, href }: Props = $props();
// Get the primary image from attachments or fallback to image_url
let primaryImage = $derived(node.image_url);
// For character portraits, use object-top to show faces
let imageObjectPosition = $derived(node.kind === 'character' ? 'object-top' : 'object-center');
// Get aspect ratio class based on node kind
let aspectClass = $derived(() => {
switch (node.kind) {
case 'world':
case 'place':
return 'aspect-[21/9]'; // Ultrawide for worlds and places
case 'character':
return 'aspect-[9/16]'; // Portrait for characters
case 'object':
case 'story':
default:
return 'aspect-square'; // Square for objects and stories
}
});
</script>
<a
{href}
class="overflow-hidden rounded-lg bg-theme-surface shadow transition-all hover:shadow-md hover:-translate-y-0.5"
>
{#if primaryImage}
<div class="{aspectClass} w-full bg-theme-elevated">
<img
src={primaryImage}
alt={node.title}
class="h-full w-full object-cover {imageObjectPosition}"
loading="lazy"
/>
</div>
{/if}
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium text-theme-text-primary">{node.title}</h3>
{#if node.summary}
<p class="mt-1 line-clamp-2 text-sm text-theme-text-secondary">{node.summary}</p>
{/if}
<div class="mt-3 flex items-center justify-between">
<span
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
>
{node.visibility}
</span>
{#if node.tags && node.tags.length > 0}
<div class="flex space-x-1">
{#each node.tags.slice(0, 2) as tag}
<span
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
</div>
{/if}
</div>
</div>
</a>

View file

@ -0,0 +1,852 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
import { goto } from '$app/navigation';
import PromptInfo from './PromptInfo.svelte';
import ImageGallery from './ImageGallery.svelte';
import { extractMentions } from '$lib/utils/mentions';
import { aiAuthorStore } from '$lib/stores/aiAuthorStore';
import { onMount, onDestroy } from 'svelte';
import { renderMarkdown, parseReferences as parseRefs } from '$lib/utils/markdown';
import NodeMemory from './NodeMemory.svelte';
import SmartMarkdown from './SmartMarkdown.svelte';
import CustomFieldsDisplay from './customFields/CustomFieldsDisplay.svelte';
import ImageUploadModal from './ImageUploadModal.svelte';
interface Props {
node: ContentNode;
isOwner: boolean;
onDelete: () => void;
editPath?: string;
backPath?: string;
}
let { node: initialNode, isOwner, onDelete, editPath, backPath }: Props = $props();
// Make node reactive for AI updates
let node = $state(initialNode);
// Update node when initialNode changes (e.g., navigation)
$effect(() => {
node = initialNode;
// Update global AI Author Bar context when node changes
if (isOwner) {
aiAuthorStore.setContext(node, isOwner);
}
});
// Set AI Author Bar context on mount
onMount(() => {
console.log('🎯 NodeDetail: Setting AI context', { node: node.slug, isOwner });
if (isOwner) {
aiAuthorStore.setContext(node, isOwner);
}
});
// Listen for node updates from AI Author Bar
let handleNodeUpdate: (event: CustomEvent) => void;
let handleImagesUpdate: (event: CustomEvent) => void;
onMount(() => {
handleNodeUpdate = (event: CustomEvent) => {
if (event.detail.updatedNode.slug === node.slug) {
node = event.detail.updatedNode;
// Re-load linked objects if this is a character
if (node.kind === 'character') {
loadLinkedObjects();
}
}
};
handleImagesUpdate = (event: CustomEvent) => {
if (event.detail.nodeSlug === node.slug) {
console.log('📸 Images updated event received, reloading...');
loadImages();
}
};
window.addEventListener('node-updated', handleNodeUpdate as EventListener);
window.addEventListener('images-updated', handleImagesUpdate as EventListener);
});
onDestroy(() => {
if (handleNodeUpdate) {
window.removeEventListener('node-updated', handleNodeUpdate as EventListener);
}
if (handleImagesUpdate) {
window.removeEventListener('images-updated', handleImagesUpdate as EventListener);
}
});
// State for linked objects
let linkedObjects = $state<ContentNode[]>([]);
let loadingObjects = $state(false);
// State for image gallery
let images = $state<any[]>([]);
let loadingImages = $state(false);
// State for tabs
let activeTab = $state<'info' | 'memory' | 'prompt' | 'custom'>('info');
// State for dropdown menu
let showDropdown = $state(false);
// State for left column metadata
let showLeftMetadata = $state(false);
// State for image upload modal
let showUploadModal = $state(false);
// Close dropdown when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
showDropdown = false;
}
}
$effect(() => {
if (showDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
function parseReferences(text: string | undefined): string {
if (!text) return '';
// Use the new markdown-aware parser
return parseRefs(text);
}
function renderContent(text: string | undefined, isStoryLore: boolean = false): string {
if (!text) return '';
// For story lore, always use full markdown rendering
if (isStoryLore) {
return renderMarkdown(text);
}
// For other content, use reference parser (which auto-detects markdown)
return parseRefs(text);
}
// Load objects that are in this character's inventory
async function loadLinkedObjects() {
if (node.kind !== 'character' || !node.content.inventory_text) return;
loadingObjects = true;
try {
const mentions = extractMentions(node.content.inventory_text);
if (mentions.length === 0) return;
// Load all mentioned objects
const objects = await Promise.all(
mentions.map(async (slug) => {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
const obj = await response.json();
if (obj.kind === 'object') return obj;
}
return null;
})
);
linkedObjects = objects.filter((obj) => obj !== null) as ContentNode[];
} catch (err) {
console.error('Failed to load linked objects:', err);
} finally {
loadingObjects = false;
}
}
// Load images for the gallery
async function loadImages() {
console.log('📸 NodeDetail: Loading images for node:', node.slug);
loadingImages = true;
try {
// Use the proper attachments-based endpoint
const response = await fetch(`/api/nodes/${node.slug}/images`);
console.log('📸 NodeDetail: API response status:', response.status);
if (response.ok) {
images = await response.json();
console.log('📸 NodeDetail: Loaded images:', images);
console.log('📸 NodeDetail: images.length:', images.length);
} else {
console.error('📸 NodeDetail: API error:', response.status, response.statusText);
}
} catch (err) {
console.error('📸 NodeDetail: Failed to load images:', err);
} finally {
loadingImages = false;
console.log('📸 NodeDetail: loadingImages set to false');
}
}
$effect(() => {
loadLinkedObjects();
loadImages();
});
function formatFieldName(key: string): string {
return key
.replace(/_/g, ' ')
.replace(/text$/, '')
.replace(/\b\w/g, (l) => l.toUpperCase());
}
// Get the appropriate content fields based on node kind
function getContentFields(): Array<{ key: string; label: string }> {
const commonFields = [
{ key: 'appearance', label: 'Aussehen' },
{ key: 'lore', label: 'Geschichte' },
];
switch (node.kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten' },
{ key: 'glossary_text', label: 'Glossar' },
{ key: 'constraints', label: 'Einschränkungen' },
{ key: 'timeline_text', label: 'Zeitleiste' },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
];
case 'character':
return [
{ key: 'state_text', label: 'Aktuelle Situation' },
{ key: 'motivations', label: 'Motivationen' },
...commonFields,
{ key: 'voice_style', label: 'Sprechstil' },
{ key: 'capabilities', label: 'Fähigkeiten' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'relationships_text', label: 'Beziehungen' },
{ key: 'inventory_text', label: 'Inventar' },
{ key: 'timeline_text', label: 'Zeitleiste' },
{ key: 'constraints', label: 'Einschränkungen' },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Besonderheiten' },
{ key: 'constraints', label: 'Gefahren' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'state_text', label: 'Aktueller Zustand' },
{ key: 'timeline_text', label: 'Wichtige Ereignisse' },
];
case 'object':
return [
...commonFields,
{ key: 'capabilities', label: 'Eigenschaften' },
{ key: 'constraints', label: 'Einschränkungen' },
{ key: 'secrets', label: 'Geheimnisse' },
{ key: 'state_text', label: 'Zustand / Aufbewahrungsort' },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf' },
{ key: 'references', label: 'Referenzen' },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien' },
];
default:
return commonFields;
}
}
const contentFields = getContentFields();
// Check if layout should be side-by-side
const isSideBySide = node.kind === 'character' || node.kind === 'object';
</script>
{#if !isSideBySide && (node.kind === 'world' || node.kind === 'place') && !loadingImages && (images.length > 0 || node.image_url)}
<!-- Fixed Full-Width Background Image for worlds and places -->
<div class="fixed inset-0 w-full h-full" style="z-index: -1;">
{#if images.length > 0 && images[0]?.image_url}
<!-- Use first image from gallery as background -->
<div class="relative w-full h-full">
<img
src={images[0].image_url}
alt={`Bild für ${node.title}`}
class="w-full h-full object-cover"
/>
</div>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<div class="relative w-full h-full">
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="w-full h-full object-cover"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
</div>
{/if}
</div>
{/if}
<div class="mx-auto max-w-6xl relative">
{#if isSideBySide}
<!-- Side-by-side layout for characters and objects -->
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Left column: Image, Title and metadata -->
<div class="flex-shrink-0 lg:w-1/3">
<div class="sticky top-8">
<!-- Image -->
{#if !loadingImages && (images.length > 0 || node.image_url)}
{#if images.length > 0}
<ImageGallery
{images}
nodeSlug={node.slug}
nodeKind={node.kind}
editable={isOwner}
onImageUpdate={loadImages}
/>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="{node.kind === 'character'
? 'aspect-[9/16] w-full'
: 'aspect-square w-full'} rounded-lg object-cover shadow-lg"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
{/if}
{/if}
<!-- Title and metadata -->
<div class="mt-6">
<div class="flex items-start justify-between gap-2">
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
<div class="flex gap-1 flex-shrink-0">
<!-- Collapsible metadata button -->
<button
onclick={() => (showLeftMetadata = !showLeftMetadata)}
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Metadaten anzeigen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if isOwner}
<!-- Upload button -->
<button
onclick={() => (showUploadModal = true)}
class="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Bilder hochladen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</button>
{/if}
</div>
</div>
{#if node.summary}
<p class="mt-2 text-base text-theme-text-secondary">{node.summary}</p>
{/if}
{#if showLeftMetadata}
<div class="mt-2 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
>
{node.visibility}
</span>
{#if node.world_slug}
<a
href="/worlds/{node.world_slug}"
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
>
🌍 {node.world_slug}
</a>
{/if}
{#if node.tags && node.tags.length > 0}
{#each node.tags as tag}
<span
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</div>
<!-- Right column: Content -->
<div class="flex-1">
<!-- Tab Navigation for all node types except stories -->
{#if node.kind !== 'story'}
<div
class="sticky top-0 z-10 mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1"
>
<div class="flex space-x-1">
<button
onclick={() => (activeTab = 'info')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Informationen
</button>
<button
onclick={() => (activeTab = 'memory')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'memory'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{node.kind === 'world'
? 'Historie'
: node.kind === 'place'
? 'Ereignisse'
: node.kind === 'object'
? 'Geschichte'
: 'Erinnerungen'}
</button>
{#if node.generation_prompt}
<button
onclick={() => (activeTab = 'prompt')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'prompt'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
KI-Generierung
</button>
{/if}
{#if node.custom_schema && node.custom_schema.fields.length > 0}
<button
onclick={() => (activeTab = 'custom')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'custom'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Zusatzfelder
</button>
{/if}
</div>
{#if isOwner}
<div class="relative dropdown-container mr-1">
<button
onclick={() => (showDropdown = !showDropdown)}
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Mehr Optionen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
>
<div class="py-1">
{#if editPath}
<a
href={editPath}
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Bearbeiten
</a>
{/if}
<button
onclick={onDelete}
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Content fields or Memory Tab or Prompt Tab -->
<div class="rounded-lg bg-theme-surface p-6 shadow">
{#if node.kind !== 'story' && activeTab === 'memory'}
<!-- Memory Tab Content -->
<NodeMemory
nodeSlug={node.slug}
nodeKind={node.kind}
memory={node.memory || null}
editable={isOwner}
onMemoryUpdate={(updatedMemory) => {
node.memory = updatedMemory;
}}
/>
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
<!-- Prompt Tab Content -->
<PromptInfo {node} />
{:else}
<!-- Regular Content Fields -->
<div class="space-y-6">
{#each contentFields as field}
{#if node.content?.[field.key]}
<div>
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
{field.label}
</h3>
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
{#if field.key === 'lore' && node.kind === 'story'}
<SmartMarkdown
text={node.content[field.key] || ''}
references={node.content.references}
/>
{:else if field.key.includes('text') || field.key === 'references'}
{@html parseReferences(node.content[field.key])}
{:else}
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
{/if}
</div>
</div>
{/if}
{/each}
</div>
<!-- Show linked objects for characters -->
{#if node.kind === 'character' && linkedObjects.length > 0}
<div class="border-t border-theme-border-subtle pt-6">
<h3 class="mb-4 text-lg font-medium text-theme-text-primary">
📒 Inventar-Objekte
</h3>
<div class="grid grid-cols-1 gap-4">
{#each linkedObjects as obj}
<a
href="/worlds/{node.world_slug}/objects/{obj.slug}"
class="block rounded-lg bg-theme-elevated p-4 transition-colors hover:bg-theme-interactive-hover"
>
<div class="flex items-start space-x-3">
{#if obj.image_url}
<img
src={obj.image_url}
alt={obj.title}
class="h-12 w-12 rounded object-cover"
/>
{/if}
<div class="flex-1">
<h4 class="font-medium text-theme-text-primary">{obj.title}</h4>
{#if obj.summary}
<p class="mt-1 text-sm text-theme-text-secondary">{obj.summary}</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
</div>
{/if}
{/if}
</div>
</div>
</div>
{:else}
<!-- Traditional top-down layout for stories and worlds/places -->
<div
class="mx-auto max-w-4xl {node.kind === 'world' || node.kind === 'place'
? 'relative z-20'
: ''}"
style={node.kind === 'world' || node.kind === 'place'
? 'padding-top: 100vh; margin-top: -25vh;'
: ''}
>
<!-- Regular Image for stories and other content without sticky -->
{#if node.kind === 'story' && !loadingImages && (images.length > 0 || node.image_url)}
<div class="mb-6">
{#if images.length > 0}
<ImageGallery
{images}
nodeSlug={node.slug}
nodeKind={node.kind}
editable={isOwner}
onImageUpdate={loadImages}
/>
{:else if node.image_url}
<!-- Fallback: Direct image display when no images loaded via API -->
<div class="mb-6">
<img
src={node.image_url}
alt={`Bild für ${node.title}`}
class="aspect-square w-full rounded-lg object-cover shadow-lg"
onload={() => console.log('🖼️ Fallback image loaded:', node.image_url)}
onerror={() => console.error('🚨 Fallback image failed:', node.image_url)}
/>
</div>
{/if}
</div>
{/if}
<!-- Title and metadata -->
<div
class="mb-6 {node.kind === 'world' || node.kind === 'place'
? 'bg-theme-base/90 backdrop-blur-md rounded-lg p-6 shadow-lg'
: ''}"
>
<h1 class="text-3xl font-bold text-theme-text-primary">{node.title}</h1>
{#if node.summary}
<p class="mt-2 text-lg text-theme-text-secondary">{node.summary}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs font-medium text-theme-text-primary"
>
{node.visibility}
</span>
{#if node.world_slug}
<a
href="/worlds/{node.world_slug}"
class="bg-theme-primary-100/50 dark:hover:bg-theme-primary-900/70 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium text-theme-primary-800 hover:bg-theme-primary-200"
>
🌍 {node.world_slug}
</a>
{/if}
{#if node.tags && node.tags.length > 0}
{#each node.tags as tag}
<span
class="bg-theme-primary-100/50 inline-flex items-center rounded px-2 py-0.5 text-xs font-medium text-theme-primary-800"
>
{tag}
</span>
{/each}
{/if}
</div>
</div>
<!-- Tab Navigation for all node types except stories -->
{#if node.kind !== 'story'}
<div class="mb-4 flex items-center justify-between bg-theme-elevated rounded-lg p-1">
<div class="flex space-x-1">
<button
onclick={() => (activeTab = 'info')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'info'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Informationen
</button>
<button
onclick={() => (activeTab = 'memory')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab === 'memory'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{node.kind === 'world'
? 'Historie'
: node.kind === 'place'
? 'Ereignisse'
: node.kind === 'object'
? 'Geschichte'
: 'Erinnerungen'}
</button>
{#if node.generation_prompt}
<button
onclick={() => (activeTab = 'prompt')}
class="px-4 py-2 rounded text-sm font-medium transition-colors {activeTab ===
'prompt'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
KI-Generierung
</button>
{/if}
</div>
{#if isOwner}
<div class="relative dropdown-container mr-1">
<button
onclick={() => (showDropdown = !showDropdown)}
class="p-2 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-surface transition-colors"
title="Mehr Optionen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
{#if showDropdown}
<div
class="absolute right-0 mt-1 w-48 rounded-md bg-theme-surface shadow-lg border border-theme-border-default z-50"
>
<div class="py-1">
{#if editPath}
<a
href={editPath}
class="flex items-center px-4 py-2 text-sm text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Bearbeiten
</a>
{/if}
<button
onclick={onDelete}
class="flex items-center w-full px-4 py-2 text-sm text-theme-error hover:bg-theme-error/10"
>
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Löschen
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Content fields or Memory Tab or Prompt Tab -->
<div class="rounded-lg bg-theme-surface p-6 shadow">
{#if node.kind !== 'story' && activeTab === 'memory'}
<!-- Memory Tab Content -->
<NodeMemory
nodeSlug={node.slug}
nodeKind={node.kind}
memory={node.memory || null}
editable={isOwner}
onMemoryUpdate={(updatedMemory) => {
node.memory = updatedMemory;
}}
/>
{:else if node.kind !== 'story' && activeTab === 'prompt' && node.generation_prompt}
<!-- Prompt Tab Content -->
<PromptInfo {node} />
{:else if node.kind !== 'story' && activeTab === 'custom'}
<!-- Custom Fields Tab Content -->
<CustomFieldsDisplay schema={node.custom_schema} data={node.custom_data} />
{:else}
<!-- Regular Content Fields -->
<div class="space-y-6">
{#each contentFields as field}
{#if node.content?.[field.key]}
<div>
<h3 class="mb-2 text-lg font-medium text-theme-text-primary">
{field.label}
</h3>
<div class="prose dark:prose-invert max-w-none text-theme-text-secondary">
{#if field.key === 'lore' && node.kind === 'story'}
<SmartMarkdown
text={node.content[field.key] || ''}
references={node.content.references}
/>
{:else if field.key.includes('text') || field.key === 'references'}
{@html parseReferences(node.content[field.key])}
{:else}
<p class="whitespace-pre-wrap">{node.content[field.key]}</p>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Back link and bottom padding -->
{#if backPath}
<div
class="mt-6 {!isSideBySide && (node.kind === 'world' || node.kind === 'place')
? 'pb-[100vh]'
: 'pb-20'}"
>
<!-- Add massive bottom padding for world/place to show full background image -->
<a href={backPath} class="text-theme-primary-600 hover:text-theme-primary-500">
← Zurück zur Übersicht
</a>
</div>
{:else}
<!-- Add bottom padding even without back link -->
<div
class={!isSideBySide && (node.kind === 'world' || node.kind === 'place')
? 'pb-[100vh]'
: 'pb-20'}
></div>
{/if}
</div>
<!-- Image Upload Modal -->
{#if showUploadModal}
<ImageUploadModal
show={showUploadModal}
nodeSlug={node.slug}
onClose={() => (showUploadModal = false)}
onUploadComplete={loadImages}
/>
{/if}

View file

@ -0,0 +1,389 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import { goto } from '$app/navigation';
import AiImageGenerator from './AiImageGenerator.svelte';
import CollapsibleOptions from './CollapsibleOptions.svelte';
interface Props {
node: ContentNode;
onSave: (updatedNode: Partial<ContentNode>) => Promise<void>;
onCancel: () => void;
worldSlug?: string;
}
let { node, onSave, onCancel, worldSlug }: Props = $props();
// Basic fields
let title = $state(node.title);
let slug = $state(node.slug);
let summary = $state(node.summary || '');
let visibility = $state(node.visibility);
let tags = $state(node.tags.join(', '));
let imageUrl = $state(node.image_url);
// Content fields based on node type
let contentFields = $state<Record<string, any>>({});
let loading = $state(false);
let error = $state<string | null>(null);
// Initialize content fields based on node kind
$effect(() => {
const content = node.content || {};
switch (node.kind) {
case 'world':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
canon_facts_text: content.canon_facts_text || '',
glossary_text: content.glossary_text || '',
constraints: content.constraints || '',
timeline_text: content.timeline_text || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
case 'character':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
voice_style: content.voice_style || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
motivations: content.motivations || '',
secrets: content.secrets || '',
relationships_text: content.relationships_text || '',
inventory_text: content.inventory_text || '',
timeline_text: content.timeline_text || '',
state_text: content.state_text || '',
};
break;
case 'place':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
secrets: content.secrets || '',
};
break;
case 'object':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
};
break;
case 'story':
contentFields = {
lore: content.lore || '',
references: content.references || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
}
});
function generateSlug() {
if (title && slug === node.slug) {
slug = title
.toLowerCase()
.replace(/[äöü]/g, (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[char] || char)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
const updatedNode: Partial<ContentNode> = {
title,
slug,
summary,
visibility,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: contentFields,
image_url: imageUrl,
};
await onSave(updatedNode);
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
// Get field configuration based on node kind
function getFieldConfig() {
const kindNames = {
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
return {
title: kindNames[node.kind] || 'Node',
fields: getFieldsForKind(node.kind),
};
}
function getFieldsForKind(kind: NodeKind) {
const commonFields = [
{ key: 'appearance', label: 'Erscheinungsbild', rows: 3 },
{ key: 'lore', label: 'Geschichte & Bedeutung', rows: 4 },
];
switch (kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten', rows: 3 },
{ key: 'glossary_text', label: 'Glossar', rows: 3 },
{ key: 'constraints', label: 'Regeln & Einschränkungen', rows: 3 },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3 },
{ key: 'prompt_guidelines', label: 'KI-Richtlinien', rows: 3, optional: true },
];
case 'character':
return [
...commonFields,
{ key: 'voice_style', label: 'Stimme & Sprache', rows: 2 },
{ key: 'capabilities', label: 'Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen', rows: 3 },
{ key: 'motivations', label: 'Motivationen', rows: 3 },
{ key: 'relationships_text', label: 'Beziehungen', rows: 3, optional: true },
{ key: 'inventory_text', label: 'Inventar', rows: 3, optional: true },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3, optional: true },
{ key: 'secrets', label: 'Geheimnisse', rows: 2, optional: true },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Was ist hier möglich?', rows: 3 },
{ key: 'constraints', label: 'Gefahren & Einschränkungen', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
{ key: 'secrets', label: 'Verborgene Aspekte', rows: 2, optional: true },
];
case 'object':
return [
{ key: 'appearance', label: 'Aussehen & Material', rows: 3 },
{ key: 'lore', label: 'Herkunft & Geschichte', rows: 4 },
{ key: 'capabilities', label: 'Eigenschaften & Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen & Nachteile', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand & Besitzer', rows: 2, optional: true },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf / Plot', rows: 6 },
{ key: 'references', label: 'Referenzen', rows: 3, optional: true },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien', rows: 3, optional: true },
];
default:
return commonFields;
}
}
const config = getFieldConfig();
const fields = config.fields;
const optionalFields = fields.filter((f) => f.optional);
const requiredFields = fields.filter((f) => !f.optional);
let hasOptionalContent = $derived(
optionalFields.some((field) => contentFields[field.key]?.trim())
);
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-theme-text-primary">{config.title} bearbeiten</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
Bearbeite die Details für "{node.title}"
</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
<!-- Basic Information -->
<div>
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Grundinformationen</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="title" class="block text-sm font-medium text-theme-text-primary">Name *</label
>
<input
type="text"
id="title"
bind:value={title}
onblur={generateSlug}
required
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-theme-text-primary">Slug *</label>
<input
type="text"
id="slug"
bind:value={slug}
required
pattern="[a-z0-9\-]+"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
</div>
<div class="mt-4">
<label for="summary" class="block text-sm font-medium text-theme-text-primary"
>Zusammenfassung</label
>
<textarea
id="summary"
bind:value={summary}
rows="2"
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="visibility" class="block text-sm font-medium text-theme-text-primary"
>Sichtbarkeit</label
>
<select
id="visibility"
bind:value={visibility}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
>
<option value="private">Privat</option>
<option value="shared">Geteilt</option>
<option value="public">Öffentlich</option>
</select>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-theme-text-primary"
>Tags (kommagetrennt)</label
>
<input
type="text"
id="tags"
bind:value={tags}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Image Generation -->
{#if node.kind !== 'story'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Bild</h2>
<AiImageGenerator bind:imageUrl prompt={`${title}: ${contentFields.appearance}`} />
</div>
{/if}
<!-- Main Content Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Details</h2>
<div class="space-y-4">
{#each requiredFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
</div>
{/each}
</div>
</div>
<!-- Optional Fields -->
{#if optionalFields.length > 0}
<CollapsibleOptions title="Erweiterte Optionen" hasContent={hasOptionalContent}>
{#snippet children()}
{#each optionalFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="border-theme-border-default mt-1 block w-full rounded-md shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 dark:bg-slate-700 dark:text-zinc-100 sm:text-sm"
></textarea>
{#if field.key === 'inventory_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @objekt-slug um Objekte zu verlinken
</p>
{:else if field.key === 'state_text' && node.kind === 'object'}
<p class="mt-1 text-xs text-theme-text-secondary">
z.B. 'Im Besitz von @charakter-slug'
</p>
{/if}
</div>
{/each}
{/snippet}
</CollapsibleOptions>
{/if}
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button
type="button"
onclick={onCancel}
class="border-theme-border-default rounded-md border bg-white px-4 py-2 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-slate-50 dark:bg-slate-700 dark:hover:bg-slate-600"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{loading ? 'Speichere...' : 'Änderungen speichern'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import NodeCard from './NodeCard.svelte';
interface Props {
kind: NodeKind;
kindLabel: string;
kindLabelPlural: string;
description: string;
user: any;
}
let { kind, kindLabel, kindLabelPlural, description, user }: Props = $props();
let nodes = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadNodes() {
try {
const response = await fetch(`/api/nodes?kind=${kind}`);
if (!response.ok) throw new Error(`Failed to load ${kindLabelPlural}`);
nodes = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
$effect(() => {
loadNodes();
});
function getNodeUrl(node: ContentNode): string {
const baseUrls: Record<NodeKind, string> = {
world: '/worlds',
character: '/characters',
object: '/objects',
place: '/places',
story: '/stories',
};
return `${baseUrls[node.kind]}/${node.slug}`;
}
</script>
<div class="space-y-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-theme-text-primary">{kindLabelPlural}</h1>
<p class="mt-1 text-sm text-theme-text-secondary">{description}</p>
</div>
{#if user}
<div class="mt-4 sm:mt-0">
<a
href="/{kind === 'world'
? 'worlds'
: kind === 'character'
? 'characters'
: kind === 'place'
? 'places'
: kind === 'object'
? 'objects'
: 'stories'}/new"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700"
>
{kindLabel} erstellen
</a>
</div>
{/if}
</div>
{#if loading}
<div class="py-12 text-center">
<p class="text-theme-text-secondary">Lade {kindLabelPlural}...</p>
</div>
{:else if error}
<div class="rounded-md bg-red-50/50 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{:else if nodes.length === 0}
<div class="rounded-lg bg-theme-surface py-12 text-center shadow">
<p class="text-theme-text-secondary">Noch keine {kindLabelPlural} vorhanden</p>
{#if user}
<a
href="/{kind === 'world'
? 'worlds'
: kind === 'character'
? 'characters'
: kind === 'place'
? 'places'
: kind === 'object'
? 'objects'
: 'stories'}/new"
class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-theme-primary-600 hover:text-theme-primary-500"
>
Erste {kindLabel} erstellen
</a>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each nodes as node}
<NodeCard {node} href={getNodeUrl(node)} />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,506 @@
<script lang="ts">
import type {
CharacterMemory,
ShortTermMemory,
MediumTermMemory,
LongTermMemory,
NodeKind,
} from '$lib/types/content';
import { parseReferences } from '$lib/utils/markdown';
interface Props {
nodeSlug: string;
nodeKind: NodeKind;
memory: CharacterMemory | null;
editable?: boolean;
onMemoryUpdate?: (memory: CharacterMemory) => void;
}
let {
nodeSlug,
nodeKind,
memory: initialMemory,
editable = false,
onMemoryUpdate,
}: Props = $props();
// Make memory reactive with $state
let memory = $state<CharacterMemory | null>(initialMemory);
// Update memory when prop changes
$effect(() => {
memory = initialMemory;
});
let activeTab = $state<'short' | 'medium' | 'long'>('short');
let showAddMemory = $state(false);
let newMemoryContent = $state('');
let newMemoryTier = $state<'short' | 'medium' | 'long'>('short');
let newMemoryImportance = $state(5);
let addingMemory = $state(false);
// Format timestamp for display
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) {
return 'Gerade eben';
} else if (diffHours < 24) {
return `Vor ${diffHours} Stunde${diffHours === 1 ? '' : 'n'}`;
} else if (diffDays < 7) {
return `Vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
} else if (diffDays < 30) {
const weeks = Math.floor(diffDays / 7);
return `Vor ${weeks} Woche${weeks === 1 ? '' : 'n'}`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `Vor ${months} Monat${months === 1 ? '' : 'en'}`;
} else {
const years = Math.floor(diffDays / 365);
return `Vor ${years} Jahr${years === 1 ? '' : 'en'}`;
}
}
// Get importance color
function getImportanceColor(importance: number): string {
if (importance >= 8) return 'text-theme-error';
if (importance >= 6) return 'text-theme-warning';
if (importance >= 4) return 'text-theme-primary-600';
return 'text-theme-text-secondary';
}
// Get category emoji
function getCategoryEmoji(category?: string): string {
switch (category) {
case 'trauma':
return '😰';
case 'triumph':
return '🏆';
case 'relationship':
return '💕';
case 'skill':
return '📚';
case 'secret':
return '🤫';
default:
return '💭';
}
}
// Add new memory
async function addMemory() {
if (!newMemoryContent.trim()) return;
addingMemory = true;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newMemoryContent,
tier: newMemoryTier,
importance: newMemoryImportance,
}),
});
if (response.ok) {
// Reload memory
await loadMemory();
// Reset form
newMemoryContent = '';
newMemoryImportance = 5;
showAddMemory = false;
}
} catch (err) {
console.error('Failed to add memory:', err);
} finally {
addingMemory = false;
}
}
// Delete memory
async function deleteMemory(memoryId: string) {
if (!confirm('Diese Erinnerung wirklich löschen?')) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory/${memoryId}`, {
method: 'DELETE',
});
if (response.ok) {
await loadMemory();
}
} catch (err) {
console.error('Failed to delete memory:', err);
}
}
// Process memories (age them)
async function processMemories() {
if (!confirm('Erinnerungen altern lassen? Kurzzeit → Mittelzeit → Langzeit')) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
await loadMemory();
}
} catch (err) {
console.error('Failed to process memories:', err);
}
}
// Load memory from API
async function loadMemory() {
try {
const response = await fetch(`/api/nodes/${nodeSlug}/memory`);
if (response.ok) {
const data = await response.json();
memory = data;
if (onMemoryUpdate) {
onMemoryUpdate(data);
}
}
} catch (err) {
console.error('Failed to load memory:', err);
}
}
// Count memories per tier
let shortTermCount = $derived(memory?.short_term_memory?.length || 0);
let mediumTermCount = $derived(memory?.medium_term_memory?.length || 0);
let longTermCount = $derived(memory?.long_term_memory?.length || 0);
// Get tab labels based on node kind
function getTabLabels() {
switch (nodeKind) {
case 'world':
return { short: 'Aktuelle', medium: 'Jüngere', long: 'Historie' };
case 'place':
return { short: 'Kürzlich', medium: 'Ereignisse', long: 'Geschichte' };
case 'object':
return { short: 'Aktuell', medium: 'Verlauf', long: 'Ursprung' };
default:
return { short: 'Kurzzeit', medium: 'Mittelzeit', long: 'Langzeit' };
}
}
const tabLabels = getTabLabels();
// Load memory on mount if not provided
$effect(() => {
if (!memory && nodeSlug) {
loadMemory();
}
});
</script>
<div class="memory-container">
<!-- Tab Navigation -->
<div class="flex items-center justify-between mb-4">
<div class="flex space-x-1 bg-theme-elevated rounded-lg p-1">
<button
onclick={() => (activeTab = 'short')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'short'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.short} ({shortTermCount})
</button>
<button
onclick={() => (activeTab = 'medium')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'medium'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.medium} ({mediumTermCount})
</button>
<button
onclick={() => (activeTab = 'long')}
class="px-3 py-1.5 rounded text-sm font-medium transition-colors {activeTab === 'long'
? 'bg-theme-surface text-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
{tabLabels.long} ({longTermCount})
</button>
</div>
{#if editable}
<div class="flex space-x-2">
<button
onclick={() => (showAddMemory = !showAddMemory)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded text-sm hover:bg-theme-primary-700"
>
+ Neue Erinnerung
</button>
<button
onclick={processMemories}
class="px-3 py-1.5 border border-theme-border-default rounded text-sm hover:bg-theme-elevated"
>
⏰ Altern lassen
</button>
</div>
{/if}
</div>
<!-- Add Memory Form -->
{#if showAddMemory && editable}
<div class="mb-4 p-4 bg-theme-elevated rounded-lg">
<div class="space-y-3">
<div>
<label
for="memory-content"
class="block text-sm font-medium text-theme-text-primary mb-1"
>
Erinnerung
</label>
<textarea
id="memory-content"
bind:value={newMemoryContent}
placeholder="Was ist passiert?"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface text-theme-text-primary"
rows="3"
></textarea>
</div>
<div class="flex space-x-3">
<div class="flex-1">
<label for="memory-tier" class="block text-sm font-medium text-theme-text-primary mb-1">
Ebene
</label>
<select
id="memory-tier"
bind:value={newMemoryTier}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface text-theme-text-primary"
>
<option value="short">Kurzzeit (1-3 Tage)</option>
<option value="medium">Mittelzeit (1-3 Monate)</option>
<option value="long">Langzeit (Permanent)</option>
</select>
</div>
<div class="flex-1">
<label
for="memory-importance"
class="block text-sm font-medium text-theme-text-primary mb-1"
>
Wichtigkeit: {newMemoryImportance}
</label>
<input
id="memory-importance"
type="range"
min="1"
max="10"
bind:value={newMemoryImportance}
class="w-full"
/>
</div>
</div>
<div class="flex justify-end space-x-2">
<button
onclick={() => (showAddMemory = false)}
class="px-3 py-1.5 border border-theme-border-default rounded text-sm hover:bg-theme-elevated"
>
Abbrechen
</button>
<button
onclick={addMemory}
disabled={addingMemory || !newMemoryContent.trim()}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded text-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{addingMemory ? 'Wird gespeichert...' : 'Speichern'}
</button>
</div>
</div>
</div>
{/if}
<!-- Memory Content -->
<div class="space-y-3">
{#if !memory}
<div class="text-center py-8 text-theme-text-secondary">Keine Erinnerungen vorhanden</div>
{:else if activeTab === 'short'}
{#if shortTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Kurzzeiterinnerungen (letzte 3 Tage)
</div>
{:else}
{#each memory.short_term_memory as mem}
<div class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.importance)} text-xs">
{mem.importance}/10
</span>
{#if editable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary">
{@html parseReferences(mem.content)}
</div>
{#if mem.location || mem.involved?.length}
<div class="mt-2 flex flex-wrap gap-2">
{#if mem.location}
<span class="text-xs bg-theme-surface px-2 py-0.5 rounded">
📍 {mem.location}
</span>
{/if}
{#each mem.involved || [] as person}
<span class="text-xs bg-theme-surface px-2 py-0.5 rounded">
👤 {person}
</span>
{/each}
</div>
{/if}
{#if mem.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each mem.tags as tag}
<span class="text-xs text-theme-primary-600">
{tag}
</span>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{:else if activeTab === 'medium'}
{#if mediumTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Mittelzeiterinnerungen (1 Woche - 3 Monate)
</div>
{:else}
{#each memory.medium_term_memory as mem}
<div class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors">
<div class="flex justify-between items-start mb-2">
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.importance)} text-xs">
{mem.importance}/10
</span>
{#if editable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary">
{@html parseReferences(mem.content)}
</div>
{#if mem.context}
<div class="mt-2 text-xs text-theme-text-secondary italic">
Kontext: {mem.context}
</div>
{/if}
{#if mem.original_details}
<details class="mt-2">
<summary class="text-xs text-theme-primary-600 cursor-pointer">
Details anzeigen
</summary>
<div class="mt-1 text-sm text-theme-text-secondary">
{mem.original_details}
</div>
</details>
{/if}
</div>
{/each}
{/if}
{:else if activeTab === 'long'}
{#if longTermCount === 0}
<div class="text-center py-8 text-theme-text-secondary">
Keine Langzeiterinnerungen (permanent)
</div>
{:else}
{#each memory.long_term_memory as mem}
<div
class="p-4 bg-theme-elevated rounded-lg hover:bg-theme-surface transition-colors border-l-4 {mem.category ===
'trauma'
? 'border-theme-error'
: mem.category === 'triumph'
? 'border-theme-success'
: 'border-theme-primary-600'}"
>
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<span class="text-lg">
{getCategoryEmoji(mem.category)}
</span>
<span class="text-xs text-theme-text-secondary">
{formatTimestamp(mem.timestamp)}
</span>
</div>
<div class="flex items-center space-x-2">
<span class="{getImportanceColor(mem.emotional_weight)} text-xs">
💪 {mem.emotional_weight}/10
</span>
{#if editable && !mem.immutable}
<button
onclick={() => deleteMemory(mem.id)}
class="text-theme-error hover:text-theme-error/80 text-xs"
>
🗑️
</button>
{/if}
</div>
</div>
<div class="text-theme-text-primary font-medium">
{@html parseReferences(mem.content)}
</div>
{#if mem.effects}
<div class="mt-2 text-sm text-theme-warning">
Auswirkung: {mem.effects}
</div>
{/if}
{#if mem.triggers?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each mem.triggers as trigger}
<span class="text-xs bg-theme-error/10 text-theme-error px-2 py-0.5 rounded">
{trigger}
</span>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
{/if}
</div>
<!-- Memory Traits -->
{#if memory?.memory_traits}
<div class="mt-6 p-4 bg-theme-elevated rounded-lg">
<h4 class="text-sm font-medium text-theme-text-primary mb-2">Gedächtniseigenschaften</h4>
<div class="space-y-1 text-xs text-theme-text-secondary">
<div>Qualität: {memory.memory_traits.memory_quality}</div>
{#if memory.memory_traits.trauma_filter}
<div>⚠️ Trauma-Filter aktiv</div>
{/if}
{#if memory.memory_traits.selective_memory?.length}
<div>Selektiv: {memory.memory_traits.selective_memory.join(', ')}</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
interface Props {
worldSlug: string;
selectedPlace: string | null;
onSelectionChange: (selected: string | null) => void;
}
let { worldSlug, selectedPlace, onSelectionChange }: Props = $props();
let places = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadPlaces() {
if (!worldSlug) return;
try {
const response = await fetch(`/api/nodes?kind=place&world_slug=${worldSlug}`);
if (!response.ok) throw new Error('Failed to load places');
places = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Laden der Orte';
} finally {
loading = false;
}
}
function selectPlace(placeSlug: string) {
// Toggle selection - if already selected, deselect
if (selectedPlace === placeSlug) {
onSelectionChange(null);
} else {
onSelectionChange(placeSlug);
}
}
$effect(() => {
loadPlaces();
});
</script>
<div>
<label class="block text-sm font-medium text-theme-text-primary mb-3">
📍 Ort auswählen (optional)
</label>
{#if loading}
<div class="text-sm text-theme-text-secondary">Lade Orte...</div>
{:else if error}
<div class="text-sm text-theme-error">
{error}
</div>
{:else if places.length === 0}
<div class="text-sm text-theme-text-secondary">
Keine Orte in dieser Welt gefunden.
<a
href="/worlds/{worldSlug}/places/new"
class="text-theme-primary-600 hover:text-theme-primary-500"
>
Ersten Ort erstellen
</a>
</div>
{:else}
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-48 overflow-y-auto border border-theme-border-default rounded-md p-3"
>
{#each places as place}
<button
type="button"
onclick={() => selectPlace(place.slug)}
class="flex items-start space-x-3 p-2 rounded text-left transition-colors
{selectedPlace === place.slug
? 'bg-theme-primary-100 dark:bg-theme-primary-900/30 border-2 border-theme-primary-500'
: 'hover:bg-theme-elevated border-2 border-transparent'}"
>
{#if place.image_url}
<img
src={place.image_url}
alt={place.title}
class="w-12 h-12 rounded object-cover flex-shrink-0"
/>
{:else}
<div
class="w-12 h-12 rounded bg-theme-elevated flex items-center justify-center flex-shrink-0"
>
📍
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-theme-text-primary">
{place.title}
</div>
<div class="text-xs text-theme-text-secondary">
@{place.slug}
</div>
{#if place.summary}
<div class="text-xs text-theme-text-secondary mt-1 line-clamp-2">
{place.summary}
</div>
{/if}
</div>
</button>
{/each}
</div>
{#if selectedPlace}
<div class="mt-2 text-xs text-theme-text-secondary">
Ausgewählt: @{selectedPlace}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,288 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
let {
node,
class: className = '',
}: {
node: ContentNode;
class?: string;
} = $props();
let showFullPrompt = $state(false);
let showFullContext = $state(false);
function formatDate(dateString: string | undefined) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
async function reusePrompt() {
// Kopiere den Prompt in die Zwischenablage
if (node.generation_prompt) {
await navigator.clipboard.writeText(node.generation_prompt);
alert('Prompt wurde in die Zwischenablage kopiert!');
}
}
async function copyFullContext() {
if (node.generation_context) {
const contextText = `USER PROMPT:\n${node.generation_context.userPrompt}\n\nSYSTEM PROMPT:\n${node.generation_context.systemPrompt}`;
await navigator.clipboard.writeText(contextText);
alert('Vollständiger Kontext wurde in die Zwischenablage kopiert!');
}
}
</script>
{#if node.generation_prompt}
<div class={className}>
<div class="space-y-6">
<!-- Main Generation Info -->
<div>
<h3 class="mb-4 flex items-center text-lg font-medium text-theme-text-primary">
<svg
class="mr-2 h-5 w-5 text-theme-primary-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
KI-Generiert
</h3>
<div class="rounded-lg bg-theme-elevated p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<p class="text-sm font-medium text-theme-text-primary mb-2">Verwendeter Prompt:</p>
<p class="text-sm text-theme-text-secondary {showFullPrompt ? '' : 'line-clamp-3'}">
{node.generation_prompt}
</p>
{#if node.generation_prompt.length > 150}
<button
type="button"
onclick={() => (showFullPrompt = !showFullPrompt)}
class="mt-2 text-xs font-medium text-theme-primary-600 hover:text-theme-primary-500"
>
{showFullPrompt ? 'Weniger anzeigen' : 'Mehr anzeigen'}
</button>
{/if}
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-theme-text-secondary">
{#if node.generation_model}
<span class="flex items-center">
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
{node.generation_model}
</span>
{/if}
{#if node.generation_date}
<span class="flex items-center">
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{formatDate(node.generation_date)}
</span>
{/if}
</div>
</div>
<div class="ml-4 flex flex-col gap-2">
<button
type="button"
onclick={reusePrompt}
class="inline-flex items-center rounded-md border border-theme-border-default bg-theme-surface px-3 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
User-Prompt
</button>
{#if node.generation_context}
<button
type="button"
onclick={() => (showFullContext = !showFullContext)}
class="inline-flex items-center rounded-md border border-theme-primary-300 bg-theme-primary-100/50 px-3 py-1.5 text-xs font-medium text-theme-primary-700 hover:bg-theme-primary-200/50 dark:border-theme-primary-600 dark:bg-theme-primary-900/30 dark:text-theme-primary-400 dark:hover:bg-theme-primary-900/50"
>
<svg class="mr-1 h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{showFullContext ? 'Debug ausblenden' : 'Debug-Infos'}
</button>
{/if}
</div>
</div>
</div>
</div>
<!-- Debug Context Display -->
{#if showFullContext && node.generation_context}
<div class="border-t border-theme-border-subtle pt-6">
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-medium text-theme-text-primary">
Debug: Vollständiger LLM-Input
</h4>
<button
type="button"
onclick={copyFullContext}
class="inline-flex items-center rounded border border-theme-border-default bg-theme-surface px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-interactive-hover"
>
Volltext kopieren
</button>
</div>
<div class="space-y-4">
<!-- User Prompt -->
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🟢 User-Prompt</h5>
<div
class="rounded bg-green-500/10 dark:bg-green-400/10 p-3 text-xs text-theme-text-secondary font-mono whitespace-pre-wrap"
>
{node.generation_context.userPrompt}
</div>
</div>
<!-- World Context -->
{#if node.generation_context.worldDetails}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🌍 Welt-Kontext</h5>
<div class="rounded bg-theme-primary-100/50 dark:bg-theme-primary-900/30 p-3">
<div
class="text-xs font-medium text-theme-primary-800 dark:text-theme-primary-400"
>
{node.generation_context.worldDetails.title}
</div>
{#if node.generation_context.worldDetails.summary}
<div class="text-xs text-theme-primary-600 dark:text-theme-primary-500 mt-1">
📝 {node.generation_context.worldDetails.summary}
</div>
{/if}
{#if node.generation_context.worldDetails.appearance}
<div class="text-xs text-theme-primary-600 dark:text-theme-primary-500 mt-1">
🎨 {node.generation_context.worldDetails.appearance}
</div>
{/if}
</div>
</div>
{/if}
<!-- Selected Characters -->
{#if node.generation_context.selectedCharacters && node.generation_context.selectedCharacters.length > 0}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">
👥 Ausgewählte Charaktere
</h5>
<div class="rounded bg-blue-500/10 dark:bg-blue-400/10 p-3">
{#each node.generation_context.selectedCharacters as char}
<div class="mb-2 last:mb-0">
<div class="text-xs font-medium text-blue-700 dark:text-blue-400">
@{char.slug} ({char.name})
</div>
{#if char.summary}<div class="text-xs text-blue-600 dark:text-blue-500">
📄 {char.summary}
</div>{/if}
{#if char.appearance}<div class="text-xs text-blue-600 dark:text-blue-500">
👀 {char.appearance}
</div>{/if}
{#if char.motivations}<div class="text-xs text-blue-600 dark:text-blue-500">
🎯 {char.motivations}
</div>{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Selected Place -->
{#if node.generation_context.selectedPlace}
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">
📍 Ausgewählter Ort
</h5>
<div class="rounded bg-amber-500/10 dark:bg-amber-400/10 p-3">
<div class="text-xs font-medium text-amber-700 dark:text-amber-400">
@{node.generation_context.selectedPlace.slug} ({node.generation_context
.selectedPlace.name})
</div>
{#if node.generation_context.selectedPlace.summary}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
📄 {node.generation_context.selectedPlace.summary}
</div>{/if}
{#if node.generation_context.selectedPlace.appearance}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
🎨 {node.generation_context.selectedPlace.appearance}
</div>{/if}
{#if node.generation_context.selectedPlace.capabilities}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
{node.generation_context.selectedPlace.capabilities}
</div>{/if}
{#if node.generation_context.selectedPlace.constraints}<div
class="text-xs text-amber-600 dark:text-amber-500"
>
⚠️ {node.generation_context.selectedPlace.constraints}
</div>{/if}
</div>
</div>
{/if}
<!-- System Prompt -->
<div>
<h5 class="text-xs font-medium text-theme-text-primary mb-1">🔧 System-Prompt</h5>
<div
class="rounded bg-theme-elevated p-3 text-xs text-theme-text-secondary font-mono whitespace-pre-wrap max-h-64 overflow-y-auto"
>
{node.generation_context.systemPrompt}
</div>
</div>
<!-- Metadata -->
<div class="flex items-center space-x-4 text-xs text-theme-text-secondary">
<span>🤖 {node.generation_context.model}</span>
<span>{formatDate(node.generation_context.timestamp)}</span>
{#if node.generation_context.worldContext}
<span>🌍 {node.generation_context.worldContext}</span>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { PromptTemplate, NodeKind } from '$lib/types/content';
import { currentWorld } from '$lib/stores/worldContext';
let {
kind,
onSelect,
class: className = '',
}: {
kind: NodeKind;
onSelect: (template: PromptTemplate | null) => void;
class?: string;
} = $props();
let templates = $state<PromptTemplate[]>([]);
let loading = $state(true);
let selectedTemplateId = $state<string>('');
async function loadTemplates() {
try {
// Lade eigene und öffentliche Templates
const response = await fetch(`/api/prompt-templates?kind=${kind}`);
if (response.ok) {
templates = await response.json();
}
} catch (err) {
console.error('Failed to load templates:', err);
} finally {
loading = false;
}
}
function handleSelection(e: Event) {
const select = e.target as HTMLSelectElement;
const template = templates.find((t) => t.id === select.value);
onSelect(template || null);
}
function applyVariables(template: string): string {
let result = template;
if ($currentWorld) {
result = result.replace(/{world_name}/g, $currentWorld.title);
}
return result;
}
$effect(() => {
loadTemplates();
});
</script>
<div class="prompt-template-selector {className}">
<label for="template-select" class="mb-1 block text-sm font-medium text-theme-text-primary">
Prompt-Vorlage (optional)
</label>
<select
id="template-select"
bind:value={selectedTemplateId}
onchange={handleSelection}
disabled={loading}
class="w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
>
<option value="">-- Keine Vorlage --</option>
{#if loading}
<option disabled>Lade Vorlagen...</option>
{:else}
<optgroup label="Meine Vorlagen">
{#each templates.filter((t) => t.owner_id) as template}
<option value={template.id}>
{template.title}
{#if template.usage_count > 0}
({template.usage_count}x verwendet)
{/if}
</option>
{/each}
</optgroup>
<optgroup label="Community-Vorlagen">
{#each templates.filter((t) => t.is_public && !t.owner_id) as template}
<option value={template.id}>
{template.title}
{#if template.usage_count > 0}
({template.usage_count}x verwendet)
{/if}
</option>
{/each}
</optgroup>
{/if}
</select>
</div>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { renderMarkdownSmart, renderMarkdown } from '$lib/utils/markdown';
import { onMount } from 'svelte';
interface Props {
text: string;
class?: string;
immediateRender?: boolean; // Sofort mit Fallback rendern
references?: string; // Optional references field from story
}
let { text, class: className = '', immediateRender = true, references }: Props = $props();
let renderedHtml = $state('');
let loading = $state(true);
async function renderContent() {
if (!text) {
renderedHtml = '';
loading = false;
return;
}
console.log('📖 SmartMarkdown: Rendering text:', text.substring(0, 300));
if (references) {
console.log('📚 Story references field:', references);
}
// Parse references to extract slugs
let context: any = undefined;
if (references && /REF_\d+/.test(text)) {
// Extract character and place slugs from references field
const lines = references.split('\n');
const characters: { slug: string }[] = [];
let place: { slug: string } | undefined;
lines.forEach((line) => {
if (line.startsWith('cast:')) {
// Extract character slugs: "cast: @finn-zahnrad, @zahnkiel"
const matches = line.matchAll(/@([\w-]+)/g);
for (const match of matches) {
characters.push({ slug: match[1] });
}
} else if (line.startsWith('places:')) {
// Extract place slug: "places: @kupferloge"
const match = line.match(/@([\w-]+)/);
if (match) {
place = { slug: match[1] };
}
}
});
if (characters.length > 0 || place) {
context = { characters, place };
console.log('📝 Extracted context for REF replacement:', context);
}
}
// Immediate render with formatted slugs if requested
if (immediateRender) {
const immediateHtml = renderMarkdown(text);
console.log('⚡ Immediate render result:', immediateHtml.substring(0, 300));
renderedHtml = immediateHtml;
}
// Then fetch real names and update
try {
const smartHtml = await renderMarkdownSmart(text, context);
console.log('✨ Smart render result:', smartHtml.substring(0, 300));
renderedHtml = smartHtml;
} catch (error) {
console.error('Failed to render with smart display:', error);
// Keep the immediate render as fallback
if (!immediateRender) {
renderedHtml = renderMarkdown(text);
}
} finally {
loading = false;
}
}
// Re-render when text changes
$effect(() => {
loading = true;
renderContent();
});
</script>
<div class="smart-markdown {className}">
{#if loading && !immediateRender}
<div class="animate-pulse">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
{:else}
{@html renderedHtml}
{/if}
</div>
<style>
:global(.smart-markdown p) {
margin-bottom: 1rem;
}
:global(.smart-markdown h2) {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.75rem;
margin-top: 1.5rem;
}
:global(.smart-markdown h3) {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
margin-top: 1rem;
}
:global(.smart-markdown ul, .smart-markdown ol) {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
:global(.smart-markdown li) {
margin-bottom: 0.25rem;
}
:global(.smart-markdown blockquote) {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
font-style: italic;
}
:global(.smart-markdown code) {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
:global(.smart-markdown pre) {
background-color: #1f2937;
color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin-bottom: 1rem;
}
:global(.smart-markdown a[data-kind='character']) {
border-bottom: 2px dotted currentColor;
text-decoration: none;
}
:global(.smart-markdown a[data-kind='place']) {
border-bottom: 1px dashed currentColor;
text-decoration: none;
}
:global(.smart-markdown a[data-kind='object']) {
border-bottom: 1px solid currentColor;
text-decoration: none;
opacity: 0.9;
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { theme, type ThemeName, type ThemeMode } from '$lib/themes/themeStore';
let showDropdown = $state(false);
let themes = theme.getAvailableThemes();
// Get current state reactively
let currentTheme = $state<ThemeName>('default');
let currentMode = $state<ThemeMode>('light');
// Subscribe to theme changes
theme.subscribe((state) => {
currentTheme = state.theme;
currentMode = state.mode;
});
function selectTheme(themeId: ThemeName) {
theme.setTheme(themeId);
showDropdown = false;
}
function toggleMode() {
theme.toggleMode();
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-switcher')) {
showDropdown = false;
}
}
$effect(() => {
if (showDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
// Theme icons mapping
const themeIcons: Record<ThemeName, string> = {
default: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4 4 4 0 004-4V5z" />
</svg>`,
forest: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>`,
ocean: `<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>`,
};
// Mode icons
const modeIcons: Record<ThemeMode, string> = {
light: `<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>`,
dark: `<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>`,
};
</script>
<div class="theme-switcher relative flex items-center gap-1">
<!-- Mode toggle button -->
<button
onclick={toggleMode}
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Toggle {currentMode === 'light' ? 'dark' : 'light'} mode"
title={currentMode === 'light' ? 'Zu Dark Mode wechseln' : 'Zu Light Mode wechseln'}
>
{@html modeIcons[currentMode]}
</button>
<!-- Theme selector -->
<button
onclick={() => (showDropdown = !showDropdown)}
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Theme auswählen"
aria-expanded={showDropdown}
title="Theme: {themes.find((t) => t.id === currentTheme)?.name}"
>
{@html themeIcons[currentTheme] || themeIcons.default}
</button>
{#if showDropdown}
<div
class="absolute right-0 z-50 mt-2 w-56 overflow-hidden rounded-lg border border-theme-border-subtle bg-theme-surface shadow-lg"
>
<div class="py-1">
<div
class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-theme-text-secondary"
>
Themes
</div>
{#each themes as themeOption}
<button
onclick={() => selectTheme(themeOption.id)}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-theme-text-primary transition-colors hover:bg-theme-interactive-hover
{currentTheme === themeOption.id ? 'bg-theme-interactive-active' : ''}"
>
<span class="flex-shrink-0">
{@html themeIcons[themeOption.id]}
</span>
<span class="flex-1">{themeOption.name}</span>
{#if currentTheme === themeOption.id}
<div class="flex items-center gap-1">
{@html modeIcons[currentMode]}
<svg
class="h-4 w-4 flex-shrink-0 text-theme-primary-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
</div>
<div class="border-t border-theme-border-subtle px-3 py-2">
<div class="text-xs text-theme-text-tertiary">
Aktuell: {themes.find((t) => t.id === currentTheme)?.name} ({currentMode === 'light'
? 'Hell'
: 'Dunkel'})
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,520 @@
<script lang="ts">
import type {
CustomFieldSchema,
CustomFieldData,
CustomFieldDefinition,
} from '$lib/types/customFields';
import { getDefaultValueForType } from '$lib/types/customFields';
interface Props {
schema: CustomFieldSchema;
data?: CustomFieldData;
readonly?: boolean;
onChange?: (data: CustomFieldData) => void;
onSave?: (data: CustomFieldData) => void;
}
let { schema, data = {}, readonly = false, onChange, onSave }: Props = $props();
// Initialize form data with defaults
let formData = $state<CustomFieldData>({ ...data });
let isDirty = $state(false);
let errors = $state<Record<string, string>>({});
// Group fields by category
let fieldsByCategory = $derived(() => {
const categories = new Map<string, CustomFieldDefinition[]>();
// Add uncategorized fields first
const uncategorized = schema.fields.filter((f) => !f.category);
if (uncategorized.length > 0) {
categories.set('_uncategorized', uncategorized);
}
// Group by category
for (const field of schema.fields) {
if (field.category) {
if (!categories.has(field.category)) {
categories.set(field.category, []);
}
categories.get(field.category)!.push(field);
}
}
return categories;
});
// Initialize missing fields with defaults
$effect(() => {
for (const field of schema.fields) {
if (!(field.key in formData)) {
formData[field.key] = getDefaultValueForType(field.type, field.config);
}
}
});
// Track changes
function handleFieldChange(key: string, value: any) {
formData = { ...formData, [key]: value };
isDirty = true;
errors = { ...errors, [key]: '' }; // Clear error on change
if (onChange) {
onChange(formData);
}
// Handle formula dependencies
updateDependentFormulas(key);
}
// Update formulas that depend on changed field
function updateDependentFormulas(changedKey: string) {
for (const field of schema.fields) {
if (field.type === 'formula' && field.config.dependencies?.includes(changedKey)) {
// TODO: Recalculate formula
// For now, just mark as needs recalculation
formData[field.key] = `[Recalculating...]`;
}
}
}
// Validate field
function validateField(field: CustomFieldDefinition, value: any): string | null {
// Required validation
if (field.required && (value === null || value === undefined || value === '')) {
return `${field.label} ist erforderlich`;
}
// Type-specific validation
switch (field.type) {
case 'number':
case 'range':
if (value !== null && value !== undefined) {
if (field.config.min !== undefined && value < field.config.min) {
return `Mindestwert ist ${field.config.min}`;
}
if (field.config.max !== undefined && value > field.config.max) {
return `Maximalwert ist ${field.config.max}`;
}
}
break;
case 'text':
if (value && field.config.maxLength && value.length > field.config.maxLength) {
return `Maximal ${field.config.maxLength} Zeichen`;
}
if (value && field.config.pattern) {
const regex = new RegExp(field.config.pattern);
if (!regex.test(value)) {
return 'Ungültiges Format';
}
}
break;
case 'list':
if (Array.isArray(value)) {
if (field.config.min_items && value.length < field.config.min_items) {
return `Mindestens ${field.config.min_items} Elemente erforderlich`;
}
if (field.config.max_items && value.length > field.config.max_items) {
return `Maximal ${field.config.max_items} Elemente erlaubt`;
}
}
break;
}
return null;
}
// Validate all fields
function validateAll(): boolean {
let isValid = true;
const newErrors: Record<string, string> = {};
for (const field of schema.fields) {
const error = validateField(field, formData[field.key]);
if (error) {
newErrors[field.key] = error;
isValid = false;
}
}
errors = newErrors;
return isValid;
}
// Handle save
function handleSave() {
if (validateAll() && onSave) {
onSave(formData);
isDirty = false;
}
}
// Render field based on type
function getFieldComponent(field: CustomFieldDefinition) {
const value = formData[field.key];
const error = errors[field.key];
switch (field.type) {
case 'text':
return renderTextField(field, value, error);
case 'number':
return renderNumberField(field, value, error);
case 'range':
return renderRangeField(field, value, error);
case 'select':
return renderSelectField(field, value, error);
case 'multiselect':
return renderMultiselectField(field, value, error);
case 'boolean':
return renderBooleanField(field, value, error);
case 'date':
return renderDateField(field, value, error);
case 'formula':
return renderFormulaField(field, value, error);
case 'list':
return renderListField(field, value, error);
case 'json':
return renderJsonField(field, value, error);
case 'reference':
return renderReferenceField(field, value, error);
default:
return null;
}
}
// Field renderers
function renderTextField(field: CustomFieldDefinition, value: any, error: string | undefined) {
if (field.config.multiline) {
return `
<textarea
value="${value || ''}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
${readonly ? 'disabled' : ''}
placeholder="${field.config.placeholder || ''}"
rows="3"
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
></textarea>
`;
}
return `
<input
type="text"
value="${value || ''}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
${readonly ? 'disabled' : ''}
placeholder="${field.config.placeholder || ''}"
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
/>
`;
}
function renderNumberField(field: CustomFieldDefinition, value: any, error: string | undefined) {
return `
<div class="flex items-center gap-2">
${field.config.prefix ? `<span class="text-sm text-theme-text-secondary">${field.config.prefix}</span>` : ''}
<input
type="number"
value="${value ?? field.config.default ?? ''}"
min="${field.config.min ?? ''}"
max="${field.config.max ?? ''}"
step="${field.config.step ?? 1}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
${readonly ? 'disabled' : ''}
class="flex-1 px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
/>
${field.config.unit ? `<span class="text-sm text-theme-text-secondary">${field.config.unit}</span>` : ''}
</div>
`;
}
function renderRangeField(field: CustomFieldDefinition, value: any, error: string | undefined) {
return `
<div class="space-y-2">
<div class="flex justify-between text-sm">
<span>${field.config.min ?? 0}</span>
<span class="font-medium">${value ?? field.config.default ?? 0}</span>
<span>${field.config.max ?? 100}</span>
</div>
<input
type="range"
value="${value ?? field.config.default ?? 0}"
min="${field.config.min ?? 0}"
max="${field.config.max ?? 100}"
step="${field.config.step ?? 1}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: parseFloat(this.value) }))"
${readonly ? 'disabled' : ''}
class="w-full disabled:opacity-50"
/>
</div>
`;
}
function renderSelectField(field: CustomFieldDefinition, value: any, error: string | undefined) {
const choices = field.config.choices || [];
return `
<select
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
${readonly ? 'disabled' : ''}
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
>
<option value="">-- Wählen --</option>
${choices
.map(
(choice) => `
<option value="${choice.value}" ${value === choice.value ? 'selected' : ''}>
${choice.label}
</option>
`
)
.join('')}
</select>
`;
}
function renderMultiselectField(
field: CustomFieldDefinition,
value: any,
error: string | undefined
) {
const choices = field.config.choices || [];
const selectedValues = Array.isArray(value) ? value : [];
// For now, render as checkboxes
return choices
.map(
(choice) => `
<label class="flex items-center space-x-2">
<input
type="checkbox"
value="${choice.value}"
${selectedValues.includes(choice.value) ? 'checked' : ''}
onchange="this.dispatchEvent(new CustomEvent('multiselectchange', { detail: { value: this.value, checked: this.checked } }))"
${readonly ? 'disabled' : ''}
class="disabled:opacity-50"
/>
<span class="text-sm">${choice.label}</span>
</label>
`
)
.join('');
}
function renderBooleanField(field: CustomFieldDefinition, value: any, error: string | undefined) {
return `
<label class="flex items-center space-x-2">
<input
type="checkbox"
${value ? 'checked' : ''}
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.checked }))"
${readonly ? 'disabled' : ''}
class="disabled:opacity-50"
/>
<span class="text-sm">Aktiviert</span>
</label>
`;
}
function renderDateField(field: CustomFieldDefinition, value: any, error: string | undefined) {
return `
<input
type="date"
value="${value || ''}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
${readonly ? 'disabled' : ''}
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
/>
`;
}
function renderFormulaField(field: CustomFieldDefinition, value: any, error: string | undefined) {
return `
<div class="p-3 bg-theme-elevated rounded-md">
<div class="text-sm text-theme-text-secondary mb-1">
Formel: ${field.config.formula}
</div>
<div class="font-medium">
${value ?? 'Wird berechnet...'}
</div>
</div>
`;
}
function renderListField(field: CustomFieldDefinition, value: any, error: string | undefined) {
const items = Array.isArray(value) ? value : [];
return `
<div class="space-y-2">
${items
.map(
(item, i) => `
<div class="flex items-center gap-2">
<input
type="${field.config.item_type === 'number' ? 'number' : 'text'}"
value="${item}"
onchange="this.dispatchEvent(new CustomEvent('listitemchange', { detail: { index: ${i}, value: this.value } }))"
${readonly ? 'disabled' : ''}
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface disabled:opacity-50"
/>
${
!readonly
? `
<button
onclick="this.dispatchEvent(new CustomEvent('listitemremove', { detail: ${i} }))"
class="text-theme-error hover:text-theme-error/80"
>
🗑️
</button>
`
: ''
}
</div>
`
)
.join('')}
${
!readonly && (!field.config.max_items || items.length < field.config.max_items)
? `
<button
onclick="this.dispatchEvent(new CustomEvent('listitemadd'))"
class="px-3 py-1 border border-theme-border-default rounded-md hover:bg-theme-elevated text-sm"
>
+ Element hinzufügen
</button>
`
: ''
}
</div>
`;
}
function renderJsonField(field: CustomFieldDefinition, value: any, error: string | undefined) {
const jsonString = JSON.stringify(value, null, 2);
return `
<textarea
value="${jsonString}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: JSON.parse(this.value) }))"
${readonly ? 'disabled' : ''}
rows="5"
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface font-mono text-sm disabled:opacity-50"
></textarea>
`;
}
function renderReferenceField(
field: CustomFieldDefinition,
value: any,
error: string | undefined
) {
// For now, just render as text input
// In production, this would be a node selector
return `
<input
type="text"
value="${value || ''}"
onchange="this.dispatchEvent(new CustomEvent('fieldchange', { detail: this.value }))"
${readonly ? 'disabled' : ''}
placeholder="Node-Slug eingeben"
class="w-full px-3 py-2 border ${error ? 'border-theme-error' : 'border-theme-border-default'}
rounded-md bg-theme-surface disabled:opacity-50"
/>
`;
}
</script>
<div class="custom-data-form space-y-6">
{#each fieldsByCategory() as [category, fields]}
<div class="category-group">
{#if category !== '_uncategorized'}
<h3 class="text-lg font-medium mb-3 text-theme-text-primary">
{category}
</h3>
{/if}
<div class="space-y-4">
{#each fields as field}
<div class="field-wrapper">
<label class="block text-sm font-medium mb-1 text-theme-text-primary">
{field.label}
{#if field.required}
<span class="text-theme-error">*</span>
{/if}
</label>
{#if field.description}
<p class="text-xs text-theme-text-secondary mb-2">
{field.description}
</p>
{/if}
<!-- Field Component -->
<div
class="field-component"
onfieldchange={(e: CustomEvent) => handleFieldChange(field.key, e.detail)}
onmultiselectchange={(e: CustomEvent) => {
const current = formData[field.key] || [];
if (e.detail.checked) {
handleFieldChange(field.key, [...current, e.detail.value]);
} else {
handleFieldChange(
field.key,
current.filter((v) => v !== e.detail.value)
);
}
}}
onlistitemchange={(e: CustomEvent) => {
const items = [...(formData[field.key] || [])];
items[e.detail.index] = e.detail.value;
handleFieldChange(field.key, items);
}}
onlistitemremove={(e: CustomEvent) => {
const items = [...(formData[field.key] || [])];
items.splice(e.detail, 1);
handleFieldChange(field.key, items);
}}
onlistitemadd={() => {
const items = [...(formData[field.key] || [])];
items.push(getDefaultValueForType(field.config.item_type || 'text'));
handleFieldChange(field.key, items);
}}
>
{@html getFieldComponent(field)}
</div>
{#if errors[field.key]}
<p class="text-sm text-theme-error mt-1">
{errors[field.key]}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/each}
{#if onSave && !readonly}
<div class="flex justify-end gap-3 pt-4 border-t">
<button
onclick={handleSave}
disabled={!isDirty}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 disabled:opacity-50"
>
Speichern
</button>
</div>
{/if}
</div>
<style>
.field-component :global(input),
.field-component :global(select),
.field-component :global(textarea) {
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,224 @@
<script lang="ts">
import type {
CustomFieldSchema,
CustomFieldData,
CustomFieldDefinition,
} from '$lib/types/customFields';
import { parseReferences } from '$lib/utils/markdown';
interface Props {
schema?: CustomFieldSchema;
data?: CustomFieldData;
}
let { schema, data = {} }: Props = $props();
// Group fields by category
let fieldsByCategory = $derived(() => {
if (!schema) return new Map();
const categories = new Map<string, CustomFieldDefinition[]>();
// Add uncategorized fields first
const uncategorized = schema.fields.filter((f) => !f.category);
if (uncategorized.length > 0) {
categories.set('_uncategorized', uncategorized);
}
// Group by category
for (const field of schema.fields) {
if (field.category) {
if (!categories.has(field.category)) {
categories.set(field.category, []);
}
categories.get(field.category)!.push(field);
}
}
return categories;
});
// Format value for display
function formatValue(field: CustomFieldDefinition, value: any): string {
if (value === null || value === undefined || value === '') {
return '—';
}
switch (field.type) {
case 'boolean':
return value ? '✓ Ja' : '✗ Nein';
case 'number':
case 'range':
const formatted = typeof value === 'number' ? value.toString() : value;
return field.config.unit ? `${formatted} ${field.config.unit}` : formatted;
case 'date':
return new Date(value).toLocaleDateString('de-DE');
case 'select':
const choice = field.config.choices?.find((c) => c.value === value);
return choice?.label || value;
case 'multiselect':
if (Array.isArray(value)) {
const labels = value.map((v) => {
const choice = field.config.choices?.find((c) => c.value === v);
return choice?.label || v;
});
return labels.join(', ');
}
return value;
case 'list':
if (Array.isArray(value)) {
return value.join(', ');
}
return value;
case 'json':
return JSON.stringify(value, null, 2);
case 'formula':
// Formulas might return complex results
return typeof value === 'object' ? JSON.stringify(value) : value;
case 'reference':
if (Array.isArray(value)) {
return value.map((v) => `@${v}`).join(', ');
}
return value ? `@${value}` : '—';
case 'text':
default:
return value;
}
}
// Check if value is empty
function isEmpty(value: any): boolean {
return (
value === null ||
value === undefined ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && Object.keys(value).length === 0)
);
}
// Check if we have any non-empty values
let hasData = $derived(() => {
if (!schema || !data) return false;
return schema.fields.some((field) => !isEmpty(data[field.key]));
});
</script>
{#if schema && schema.fields.length > 0}
{#if !hasData}
<div class="text-center py-8 text-theme-text-secondary">
Keine benutzerdefinierten Daten vorhanden
</div>
{:else}
<div class="space-y-6">
{#each fieldsByCategory() as [category, fields]}
<div class="category-section">
{#if category !== '_uncategorized'}
<h3 class="text-lg font-medium mb-3 text-theme-text-primary border-b pb-2">
{category}
</h3>
{/if}
<div class="grid gap-4 md:grid-cols-2">
{#each fields as field}
{#if !isEmpty(data[field.key])}
<div class="field-display">
<dt class="text-sm font-medium text-theme-text-secondary mb-1">
{field.label}
</dt>
<dd class="text-theme-text-primary">
{#if field.type === 'range'}
<!-- Special display for range fields -->
<div class="flex items-center gap-2">
<div class="flex-1 bg-theme-elevated rounded-full h-2 relative">
<div
class="absolute top-0 left-0 h-full bg-theme-primary-600 rounded-full"
style="width: {((data[field.key] - (field.config.min ?? 0)) /
((field.config.max ?? 100) - (field.config.min ?? 0))) *
100}%"
></div>
</div>
<span class="text-sm font-medium">
{formatValue(field, data[field.key])}
</span>
</div>
{:else if field.type === 'text' && field.config.multiline}
<!-- Multiline text with markdown support -->
<div class="prose prose-sm max-w-none">
{@html parseReferences(data[field.key])}
</div>
{:else if field.type === 'json'}
<!-- JSON display -->
<pre class="text-xs bg-theme-elevated p-2 rounded overflow-x-auto">
<code>{formatValue(field, data[field.key])}</code>
</pre>
{:else if field.type === 'boolean'}
<!-- Boolean with icon -->
<span
class={data[field.key] ? 'text-theme-success' : 'text-theme-text-secondary'}
>
{formatValue(field, data[field.key])}
</span>
{:else if field.type === 'multiselect' || field.type === 'list'}
<!-- Tags display for arrays -->
<div class="flex flex-wrap gap-1">
{#each Array.isArray(data[field.key]) ? data[field.key] : [] as item}
<span class="inline-block px-2 py-0.5 bg-theme-elevated rounded text-sm">
{field.type === 'multiselect'
? field.config.choices?.find((c) => c.value === item)?.label || item
: item}
</span>
{/each}
</div>
{:else}
<!-- Default display -->
<span class="break-words">
{@html parseReferences(formatValue(field, data[field.key]))}
</span>
{/if}
</dd>
</div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
{/if}
{:else}
<div class="text-center py-8 text-theme-text-secondary">
Keine benutzerdefinierten Felder definiert
</div>
{/if}
<style>
.field-display {
background-color: var(--theme-background-elevated);
border-radius: 0.5rem;
padding: 0.75rem;
}
.field-display dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.field-display dd {
margin-top: 0.25rem;
}
.category-section + .category-section {
padding-top: 1rem;
border-top: 1px solid var(--theme-border-default);
}
</style>

View file

@ -0,0 +1,388 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import type {
CustomFieldSchema,
CustomFieldData,
CustomFieldDefinition,
CustomFieldTemplate,
} from '$lib/types/customFields';
import { createEmptySchema } from '$lib/types/customFields';
import FieldDefinitionEditor from './FieldDefinitionEditor.svelte';
import CustomDataForm from './CustomDataForm.svelte';
interface Props {
node?: ContentNode;
nodeSlug?: string;
nodeKind: string;
worldSlug?: string;
onSchemaChange?: (schema: CustomFieldSchema) => void;
onDataChange?: (data: CustomFieldData) => void;
}
let { node, nodeSlug, nodeKind, worldSlug, onSchemaChange, onDataChange }: Props = $props();
let activeTab = $state<'data' | 'schema' | 'templates'>('data');
let schema = $state<CustomFieldSchema>(node?.custom_schema || createEmptySchema());
let customData = $state<CustomFieldData>(node?.custom_data || {});
let isEditingSchema = $state(false);
let editingField = $state<CustomFieldDefinition | null>(null);
let showFieldEditor = $state(false);
let templates = $state<CustomFieldTemplate[]>([]);
let loadingTemplates = $state(false);
let selectedTemplate = $state<string | null>(null);
// Load templates
async function loadTemplates() {
if (loadingTemplates) return;
loadingTemplates = true;
try {
const response = await fetch(`/api/templates?applicable_to=${nodeKind}&is_public=true`);
if (response.ok) {
const data = await response.json();
templates = data.templates || [];
}
} catch (err) {
console.error('Failed to load templates:', err);
} finally {
loadingTemplates = false;
}
}
// Save schema
async function saveSchema() {
if (!nodeSlug) return;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/schema`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schema }),
});
if (response.ok) {
isEditingSchema = false;
if (onSchemaChange) {
onSchemaChange(schema);
}
} else {
console.error('Failed to save schema');
}
} catch (err) {
console.error('Error saving schema:', err);
}
}
// Save custom data
async function saveCustomData(data: CustomFieldData) {
if (!nodeSlug) return;
customData = data;
try {
const response = await fetch(`/api/nodes/${nodeSlug}/custom-data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (response.ok) {
if (onDataChange) {
onDataChange(data);
}
} else {
console.error('Failed to save custom data');
}
} catch (err) {
console.error('Error saving custom data:', err);
}
}
// Apply template
async function applyTemplate(templateId: string) {
const template = templates.find((t) => t.id === templateId);
if (!template) return;
// Merge template fields with existing schema
const existingKeys = schema.fields.map((f) => f.key);
const newFields = template.fields.filter((f) => !existingKeys.includes(f.key));
schema = {
...schema,
fields: [...schema.fields, ...newFields],
template_id: templateId,
template_version: template.version,
};
// Initialize data for new fields
for (const field of newFields) {
if (!(field.key in customData)) {
customData[field.key] = getDefaultValue(field);
}
}
selectedTemplate = null;
}
// Add field to schema
function addField(field: CustomFieldDefinition) {
schema = {
...schema,
fields: [...schema.fields, field],
version: (schema.version || 0) + 1,
};
showFieldEditor = false;
}
// Edit existing field
function editField(field: CustomFieldDefinition) {
schema = {
...schema,
fields: schema.fields.map((f) => (f.id === field.id ? field : f)),
version: (schema.version || 0) + 1,
};
editingField = null;
}
// Remove field from schema
function removeField(fieldId: string) {
if (confirm('Dieses Feld wirklich entfernen? Die Daten gehen verloren.')) {
schema = {
...schema,
fields: schema.fields.filter((f) => f.id !== fieldId),
version: (schema.version || 0) + 1,
};
// Remove data for this field
const field = schema.fields.find((f) => f.id === fieldId);
if (field) {
delete customData[field.key];
}
}
}
// Get default value for field
function getDefaultValue(field: CustomFieldDefinition): any {
switch (field.type) {
case 'text':
return '';
case 'number':
case 'range':
return field.config.default ?? field.config.min ?? 0;
case 'boolean':
return false;
case 'date':
return new Date().toISOString().split('T')[0];
case 'select':
return field.config.choices?.[0]?.value ?? '';
case 'multiselect':
return [];
case 'list':
return [];
case 'json':
return {};
case 'reference':
return field.config.multiple ? [] : null;
default:
return null;
}
}
// Load templates when switching to templates tab
$effect(() => {
if (activeTab === 'templates' && templates.length === 0) {
loadTemplates();
}
});
// Check if we have any fields
let hasFields = $derived(schema.fields.length > 0);
</script>
<div class="custom-fields-manager">
<!-- Tab Navigation -->
<div class="flex border-b border-theme-border-default mb-4">
<button
onclick={() => (activeTab = 'data')}
class="px-4 py-2 text-sm font-medium {activeTab === 'data'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Daten
{#if hasFields}
<span class="ml-1 text-xs bg-theme-primary-100 text-theme-primary-700 px-2 py-0.5 rounded">
{schema.fields.length}
</span>
{/if}
</button>
<button
onclick={() => (activeTab = 'schema')}
class="px-4 py-2 text-sm font-medium {activeTab === 'schema'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Felder verwalten
</button>
<button
onclick={() => (activeTab = 'templates')}
class="px-4 py-2 text-sm font-medium {activeTab === 'templates'
? 'text-theme-primary-600 border-b-2 border-theme-primary-600'
: 'text-theme-text-secondary hover:text-theme-text-primary'}"
>
Vorlagen
</button>
</div>
<!-- Tab Content -->
{#if activeTab === 'data'}
{#if hasFields}
<CustomDataForm
{schema}
data={customData}
onChange={onDataChange}
onSave={nodeSlug ? saveCustomData : undefined}
/>
{:else}
<div class="text-center py-12 bg-theme-elevated rounded-lg">
<p class="text-theme-text-secondary mb-4">
Noch keine benutzerdefinierten Felder vorhanden
</p>
<button
onclick={() => (activeTab = 'schema')}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Felder hinzufügen
</button>
</div>
{/if}
{:else if activeTab === 'schema'}
<div class="space-y-4">
{#if !isEditingSchema}
<!-- Field List -->
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium">Benutzerdefinierte Felder</h3>
<button
onclick={() => (showFieldEditor = true)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 text-sm"
>
+ Neues Feld
</button>
</div>
{#if hasFields}
<div class="space-y-2">
{#each schema.fields as field}
<div class="flex items-center justify-between p-3 bg-theme-elevated rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{field.label}</span>
<span class="text-xs text-theme-text-secondary">({field.key})</span>
<span class="text-xs px-2 py-0.5 bg-theme-surface rounded">
{field.type}
</span>
</div>
{#if field.description}
<p class="text-sm text-theme-text-secondary mt-1">
{field.description}
</p>
{/if}
</div>
<div class="flex gap-2">
<button
onclick={() => (editingField = field)}
class="text-theme-primary-600 hover:text-theme-primary-700"
>
✏️
</button>
<button
onclick={() => removeField(field.id)}
class="text-theme-error hover:text-theme-error/80"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{#if nodeSlug}
<div class="flex justify-end mt-4">
<button
onclick={saveSchema}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Schema speichern
</button>
</div>
{/if}
{:else}
<p class="text-center text-theme-text-secondary py-8">Noch keine Felder definiert</p>
{/if}
{/if}
<!-- Field Editor -->
{#if showFieldEditor}
<FieldDefinitionEditor
onSave={addField}
onCancel={() => (showFieldEditor = false)}
existingKeys={schema.fields.map((f) => f.key)}
/>
{/if}
{#if editingField}
<FieldDefinitionEditor
field={editingField}
onSave={editField}
onCancel={() => (editingField = null)}
existingKeys={schema.fields.filter((f) => f.id !== editingField?.id).map((f) => f.key)}
/>
{/if}
</div>
{:else if activeTab === 'templates'}
<div class="space-y-4">
<h3 class="text-lg font-medium mb-4">Verfügbare Vorlagen</h3>
{#if loadingTemplates}
<p class="text-center text-theme-text-secondary py-8">Lade Vorlagen...</p>
{:else if templates.length === 0}
<p class="text-center text-theme-text-secondary py-8">
Keine Vorlagen für {nodeKind} verfügbar
</p>
{:else}
<div class="grid gap-4">
{#each templates as template}
<div class="p-4 bg-theme-elevated rounded-lg">
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-medium">{template.name}</h4>
{#if template.description}
<p class="text-sm text-theme-text-secondary mt-1">
{template.description}
</p>
{/if}
<div class="flex gap-2 mt-2">
{#each template.tags as tag}
<span class="text-xs px-2 py-0.5 bg-theme-surface rounded">
{tag}
</span>
{/each}
</div>
<p class="text-xs text-theme-text-secondary mt-2">
{template.fields.length} Felder •
{template.usage_count} mal verwendet
</p>
</div>
<button
onclick={() => applyTemplate(template.id)}
class="px-3 py-1.5 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700 text-sm"
>
Anwenden
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,445 @@
<script lang="ts">
import type { CustomFieldDefinition, FieldType, FieldConfig } from '$lib/types/customFields';
import { createFieldDefinition, validateFieldKey } from '$lib/types/customFields';
interface Props {
field?: CustomFieldDefinition;
onSave: (field: CustomFieldDefinition) => void;
onCancel: () => void;
existingKeys?: string[];
}
let { field, onSave, onCancel, existingKeys = [] }: Props = $props();
// Initialize form values
let editingField = $state<CustomFieldDefinition>(field || createFieldDefinition('', '', 'text'));
let keyError = $state('');
let labelError = $state('');
// Field type options
const fieldTypes: Array<{ value: FieldType; label: string; icon: string }> = [
{ value: 'text', label: 'Text', icon: '📝' },
{ value: 'number', label: 'Zahl', icon: '🔢' },
{ value: 'range', label: 'Bereich', icon: '📊' },
{ value: 'select', label: 'Auswahl', icon: '📋' },
{ value: 'multiselect', label: 'Mehrfachauswahl', icon: '☑️' },
{ value: 'boolean', label: 'Ja/Nein', icon: '✓' },
{ value: 'date', label: 'Datum', icon: '📅' },
{ value: 'formula', label: 'Formel', icon: '🧮' },
{ value: 'reference', label: 'Referenz', icon: '🔗' },
{ value: 'list', label: 'Liste', icon: '📚' },
{ value: 'json', label: 'JSON', icon: '{}' },
];
// Choice management for select/multiselect
let newChoiceLabel = $state('');
let newChoiceValue = $state('');
function addChoice() {
if (!newChoiceLabel || !newChoiceValue) return;
if (!editingField.config.choices) {
editingField.config.choices = [];
}
editingField.config.choices = [
...editingField.config.choices,
{ label: newChoiceLabel, value: newChoiceValue },
];
newChoiceLabel = '';
newChoiceValue = '';
}
function removeChoice(index: number) {
if (editingField.config.choices) {
editingField.config.choices = editingField.config.choices.filter((_, i) => i !== index);
}
}
// Validation
function validateField(): boolean {
let isValid = true;
// Validate key
if (!editingField.key) {
keyError = 'Schlüssel ist erforderlich';
isValid = false;
} else if (!validateFieldKey(editingField.key)) {
keyError =
'Schlüssel muss kleingeschrieben sein, mit Buchstaben beginnen und nur Buchstaben, Zahlen und Unterstriche enthalten';
isValid = false;
} else if (!field && existingKeys.includes(editingField.key)) {
keyError = 'Dieser Schlüssel existiert bereits';
isValid = false;
} else {
keyError = '';
}
// Validate label
if (!editingField.label) {
labelError = 'Bezeichnung ist erforderlich';
isValid = false;
} else {
labelError = '';
}
// Type-specific validation
if (editingField.type === 'select' || editingField.type === 'multiselect') {
if (!editingField.config.choices || editingField.config.choices.length === 0) {
isValid = false;
}
}
if (editingField.type === 'formula' && !editingField.config.formula) {
isValid = false;
}
return isValid;
}
function handleSave() {
if (validateField()) {
onSave(editingField);
}
}
// Update config when type changes
$effect(() => {
// Reset config when type changes
if (!field) {
editingField.config = getDefaultConfig(editingField.type);
}
});
function getDefaultConfig(type: FieldType): FieldConfig {
switch (type) {
case 'number':
case 'range':
return { min: 0, max: 100, default: 0 };
case 'select':
case 'multiselect':
return { choices: [] };
case 'text':
return { multiline: false, maxLength: 255 };
case 'formula':
return { formula: '', dependencies: [] };
case 'reference':
return { reference_type: 'character', multiple: false };
case 'list':
return { item_type: 'text', max_items: 10 };
default:
return {};
}
}
</script>
<div class="field-editor bg-theme-elevated rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">
{field ? 'Feld bearbeiten' : 'Neues Feld erstellen'}
</h3>
<div class="space-y-4">
<!-- Field Key -->
<div>
<label for="field-key" class="block text-sm font-medium mb-1">
Schlüssel <span class="text-theme-error">*</span>
</label>
<input
id="field-key"
type="text"
bind:value={editingField.key}
disabled={!!field}
placeholder="z.B. strength, health_points"
class="w-full px-3 py-2 border rounded-md {keyError
? 'border-theme-error'
: 'border-theme-border-default'}
bg-theme-surface disabled:opacity-50"
/>
{#if keyError}
<p class="text-sm text-theme-error mt-1">{keyError}</p>
{/if}
<p class="text-xs text-theme-text-secondary mt-1">
Eindeutiger Bezeichner für das Feld (kann nicht geändert werden)
</p>
</div>
<!-- Field Label -->
<div>
<label for="field-label" class="block text-sm font-medium mb-1">
Bezeichnung <span class="text-theme-error">*</span>
</label>
<input
id="field-label"
type="text"
bind:value={editingField.label}
placeholder="z.B. Stärke, Lebenspunkte"
class="w-full px-3 py-2 border rounded-md {labelError
? 'border-theme-error'
: 'border-theme-border-default'}
bg-theme-surface"
/>
{#if labelError}
<p class="text-sm text-theme-error mt-1">{labelError}</p>
{/if}
</div>
<!-- Field Type -->
<div>
<label for="field-type" class="block text-sm font-medium mb-1">
Typ <span class="text-theme-error">*</span>
</label>
<select
id="field-type"
bind:value={editingField.type}
disabled={!!field}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface disabled:opacity-50"
>
{#each fieldTypes as type}
<option value={type.value}>
{type.icon}
{type.label}
</option>
{/each}
</select>
</div>
<!-- Description -->
<div>
<label for="field-description" class="block text-sm font-medium mb-1"> Beschreibung </label>
<textarea
id="field-description"
bind:value={editingField.description}
placeholder="Optionale Beschreibung des Feldes"
rows="2"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
></textarea>
</div>
<!-- Category -->
<div>
<label for="field-category" class="block text-sm font-medium mb-1"> Kategorie </label>
<input
id="field-category"
type="text"
bind:value={editingField.category}
placeholder="z.B. Attribute, Ressourcen, Kampf"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<!-- Required -->
<div class="flex items-center">
<input
id="field-required"
type="checkbox"
bind:checked={editingField.required}
class="mr-2"
/>
<label for="field-required" class="text-sm"> Pflichtfeld </label>
</div>
<!-- Type-specific configuration -->
{#if editingField.type === 'number' || editingField.type === 'range'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Zahlen-Konfiguration</h4>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-sm mb-1">Min</label>
<input
type="number"
bind:value={editingField.config.min}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<div>
<label class="block text-sm mb-1">Max</label>
<input
type="number"
bind:value={editingField.config.max}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
<div>
<label class="block text-sm mb-1">Standard</label>
<input
type="number"
bind:value={editingField.config.default}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
<div>
<label class="block text-sm mb-1">Einheit</label>
<input
type="text"
bind:value={editingField.config.unit}
placeholder="z.B. kg, km, %"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
{#if editingField.type === 'text'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Text-Konfiguration</h4>
<div class="flex items-center">
<input
id="text-multiline"
type="checkbox"
bind:checked={editingField.config.multiline}
class="mr-2"
/>
<label for="text-multiline" class="text-sm"> Mehrzeiliger Text </label>
</div>
<div>
<label class="block text-sm mb-1">Max. Länge</label>
<input
type="number"
bind:value={editingField.config.maxLength}
placeholder="255"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
{#if editingField.type === 'select' || editingField.type === 'multiselect'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Auswahloptionen</h4>
<!-- Add choice -->
<div class="flex gap-2">
<input
type="text"
bind:value={newChoiceLabel}
placeholder="Bezeichnung"
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
<input
type="text"
bind:value={newChoiceValue}
placeholder="Wert"
class="flex-1 px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
<button
onclick={addChoice}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
Hinzufügen
</button>
</div>
<!-- Choice list -->
{#if editingField.config.choices && editingField.config.choices.length > 0}
<div class="space-y-2">
{#each editingField.config.choices as choice, i}
<div class="flex items-center justify-between p-2 bg-theme-surface rounded">
<span>{choice.label} ({choice.value})</span>
<button
onclick={() => removeChoice(i)}
class="text-theme-error hover:text-theme-error/80"
>
🗑️
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-theme-text-secondary">Noch keine Optionen hinzugefügt</p>
{/if}
</div>
{/if}
{#if editingField.type === 'formula'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Formel-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Formel</label>
<textarea
bind:value={editingField.config.formula}
placeholder="z.B. (strength + dexterity) / 2"
rows="3"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface font-mono text-sm"
></textarea>
<p class="text-xs text-theme-text-secondary mt-1">
Verwende andere Feldnamen in der Formel, z.B. strength, dexterity
</p>
</div>
</div>
{/if}
{#if editingField.type === 'reference'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Referenz-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Referenz-Typ</label>
<select
bind:value={editingField.config.reference_type}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
>
<option value="character">Charakter</option>
<option value="object">Objekt</option>
<option value="place">Ort</option>
<option value="story">Geschichte</option>
<option value="world">Welt</option>
</select>
</div>
<div class="flex items-center">
<input
id="ref-multiple"
type="checkbox"
bind:checked={editingField.config.multiple}
class="mr-2"
/>
<label for="ref-multiple" class="text-sm"> Mehrere Referenzen erlauben </label>
</div>
</div>
{/if}
{#if editingField.type === 'list'}
<div class="border-t pt-4 space-y-3">
<h4 class="font-medium">Listen-Konfiguration</h4>
<div>
<label class="block text-sm mb-1">Element-Typ</label>
<select
bind:value={editingField.config.item_type}
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
>
<option value="text">Text</option>
<option value="number">Zahl</option>
<option value="boolean">Ja/Nein</option>
<option value="date">Datum</option>
</select>
</div>
<div>
<label class="block text-sm mb-1">Max. Elemente</label>
<input
type="number"
bind:value={editingField.config.max_items}
placeholder="10"
class="w-full px-3 py-2 border border-theme-border-default rounded-md bg-theme-surface"
/>
</div>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 mt-6 pt-4 border-t">
<button
onclick={onCancel}
class="px-4 py-2 border border-theme-border-default rounded-md hover:bg-theme-elevated"
>
Abbrechen
</button>
<button
onclick={handleSave}
class="px-4 py-2 bg-theme-primary-600 text-white rounded-md hover:bg-theme-primary-700"
>
{field ? 'Speichern' : 'Feld hinzufügen'}
</button>
</div>
</div>

View file

@ -0,0 +1,833 @@
<script lang="ts">
import type { ContentNode, NodeKind } from '$lib/types/content';
import type { CreateNodeRequest, UpdateNodeRequest } from '$lib/services/nodeService';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
import { NodeService } from '$lib/services/nodeService';
import AiPromptField from '$lib/components/AiPromptField.svelte';
import AiImageGenerator from '$lib/components/AiImageGenerator.svelte';
import CollapsibleOptions from '$lib/components/CollapsibleOptions.svelte';
import CharacterSelector from '$lib/components/CharacterSelector.svelte';
import PlaceSelector from '$lib/components/PlaceSelector.svelte';
import CustomFieldsManager from '$lib/components/customFields/CustomFieldsManager.svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { loadingStore } from '$lib/stores/loadingStore';
interface Props {
mode: 'create' | 'edit';
kind: NodeKind;
initialData?: Partial<ContentNode>;
worldSlug?: string;
worldTitle?: string;
onSubmit: (data: ContentNode) => Promise<void>;
onCancel: () => void;
}
let { mode, kind, initialData = {}, worldSlug, worldTitle, onSubmit, onCancel }: Props = $props();
// Basic fields
let title = $state(initialData.title || '');
let slug = $state(initialData.slug || '');
let summary = $state(initialData.summary || '');
let visibility = $state(initialData.visibility || 'private');
let tags = $state(initialData.tags?.join(', ') || '');
let imageUrl = $state(initialData.image_url || null);
let generationPrompt = $state<string | null>(null);
let generationContext = $state<any | null>(null);
// Content fields based on node type
let contentFields = $state<Record<string, any>>({});
// Custom fields
let customSchema = $state<CustomFieldSchema | undefined>(initialData.custom_schema);
let customData = $state<CustomFieldData>(initialData.custom_data || {});
// Story Builder fields (only for stories)
let selectedCharacters = $state<string[]>([]);
let selectedPlace = $state<string | null>(null);
let objectsInput = $state('');
let suggestions = $state<{ characters: string[]; places: string[]; objects: string[] }>({
characters: [],
places: [],
objects: [],
});
let loading = $state(false);
let error = $state<string | null>(null);
let showFormSections = $state(mode === 'edit');
let autoCreating = $state(false); // Neuer State für automatische Erstellung
// Initialize content fields based on node kind
$effect(() => {
const content = initialData.content || {};
switch (kind) {
case 'world':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
canon_facts_text: content.canon_facts_text || '',
glossary_text: content.glossary_text || '',
constraints: content.constraints || '',
timeline_text: content.timeline_text || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
case 'character':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
voice_style: content.voice_style || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
motivations: content.motivations || '',
secrets: content.secrets || '',
relationships_text: content.relationships_text || '',
inventory_text: content.inventory_text || '',
timeline_text: content.timeline_text || '',
state_text: content.state_text || '',
};
break;
case 'place':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
secrets: content.secrets || '',
};
break;
case 'object':
contentFields = {
appearance: content.appearance || '',
lore: content.lore || '',
capabilities: content.capabilities || '',
constraints: content.constraints || '',
state_text: content.state_text || '',
};
break;
case 'story':
contentFields = {
lore: content.lore || '',
references: content.references || '',
prompt_guidelines: content.prompt_guidelines || '',
};
break;
}
});
// Auto-generate slug when title changes
function generateSlug() {
if (title && (mode === 'create' || slug === initialData.slug)) {
slug = NodeService.generateSlug(title);
}
}
// Handle AI generation
async function handleAiGenerated(generated: any, prompt: string) {
title = generated.title;
summary = generated.summary;
tags = generated.tags.join(', ');
generationPrompt = prompt;
generationContext = generated.generationContext;
// Apply generated content
Object.keys(generated.content).forEach((key) => {
if (contentFields.hasOwnProperty(key)) {
contentFields[key] = generated.content[key];
}
});
generateSlug();
showFormSections = true;
// Automatisch erstellen nach AI-Generierung
if (mode === 'create') {
autoCreating = true;
// Kurze Verzögerung damit UI aktualisiert wird
await new Promise((resolve) => setTimeout(resolve, 100));
// Direkt submitten
await handleSubmitDirect();
autoCreating = false;
}
}
// Neue Funktion für direktes Submit ohne Event
async function handleSubmitDirect() {
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
// For stories, merge Story Builder references if no manual references provided
let finalContentFields = { ...contentFields };
if (kind === 'story' && !contentFields.references?.trim()) {
finalContentFields.references = buildReferences();
}
const createData: CreateNodeRequest = {
kind,
slug,
title,
summary,
visibility,
world_slug: worldSlug,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: finalContentFields,
generation_prompt: generationPrompt || undefined,
generation_model: generationPrompt ? 'gpt-5-mini' : undefined,
generation_date: generationPrompt ? new Date().toISOString() : undefined,
generation_context: generationContext || undefined,
};
const created = await NodeService.create(createData);
// Nächster Schritt: Bild generieren
loadingStore.nextStep('Node erfolgreich erstellt');
// Nach Erstellung: Bild automatisch generieren
if (created && (kind !== 'story' || contentFields.lore)) {
// Bild-Generierung im Hintergrund starten
await generateImageInBackground(created);
}
// Letzter Schritt: Fertigstellung
loadingStore.nextStep('Bild wird generiert');
loadingStore.complete('Erfolgreich erstellt!');
await onSubmit(created);
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
loading = false;
}
}
// Funktion für Hintergrund-Bildgenerierung
async function generateImageInBackground(node: ContentNode) {
try {
// Bestimme den richtigen Prompt basierend auf Node-Typ
let imagePrompt = '';
if (kind === 'story') {
imagePrompt = `${node.title}: ${contentFields.lore || ''}`;
} else {
imagePrompt = `${node.title}: ${contentFields.appearance || ''}`;
}
// Übersetze deutschen Text ins Englische
console.log(`Generiere Bild für ${kind} mit Aspect Ratio:`, getAspectRatio(kind));
const translateResponse = await fetch('/api/ai/translate-image-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
germanDescription: contentFields.appearance || contentFields.lore || '',
kind,
title: node.title,
style: 'fantasy',
}),
});
if (!translateResponse.ok) {
console.error('Übersetzung für Bild fehlgeschlagen');
return;
}
const translateData = await translateResponse.json();
const englishPrompt = translateData.englishPrompt;
// Generiere das Bild
const imageResponse = await fetch('/api/ai/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
title: node.title,
description: englishPrompt,
style: 'fantasy',
aspectRatio: getAspectRatio(kind),
context: {
appearance: englishPrompt,
},
}),
});
if (!imageResponse.ok) {
console.error('Bildgenerierung fehlgeschlagen');
return;
}
const imageData = await imageResponse.json();
if (imageData.imageUrl) {
// Update die Node mit der Bild-URL
await NodeService.update(node.slug, {
image_url: imageData.imageUrl,
});
}
} catch (err) {
console.error('Fehler bei Hintergrund-Bildgenerierung:', err);
}
}
// Helper-Funktion für Aspect Ratio
function getAspectRatio(kind: NodeKind): string {
switch (kind) {
case 'world':
case 'place':
return '21:9';
case 'character':
return '9:16';
case 'object':
default:
return '1:1';
}
}
// Check if any content exists
let hasAnyContent = $derived(
title ||
summary ||
tags ||
Object.values(contentFields).some((value) => value?.trim()) ||
(kind === 'story' && (selectedCharacters.length > 0 || selectedPlace || objectsInput))
);
// Check optional fields for collapsible section
let hasOptionalContent = $derived(() => {
const optionalFields = getFieldsForKind(kind).filter((f) => f.optional);
return optionalFields.some((field) => contentFields[field.key]?.trim());
});
// Auto-show form when AI generates content
$effect(() => {
if (hasAnyContent && !showFormSections && mode === 'create') {
showFormSections = true;
}
});
// Story Builder functions
async function loadSuggestions() {
if (kind !== 'story' || !worldSlug) return;
try {
const [charactersRes, placesRes, objectsRes] = await Promise.all([
fetch(`/api/nodes?kind=character&world_slug=${worldSlug}`),
fetch(`/api/nodes?kind=place&world_slug=${worldSlug}`),
fetch(`/api/nodes?kind=object&world_slug=${worldSlug}`),
]);
if (charactersRes.ok) {
const chars = await charactersRes.json();
suggestions.characters = chars.map((c: any) => c.slug);
}
if (placesRes.ok) {
const places = await placesRes.json();
suggestions.places = places.map((p: any) => p.slug);
}
if (objectsRes.ok) {
const objs = await objectsRes.json();
suggestions.objects = objs.map((o: any) => o.slug);
}
} catch (err) {
console.error('Failed to load suggestions:', err);
}
}
function buildReferences(): string {
if (kind !== 'story') return '';
let refs = [];
if (selectedCharacters.length > 0) {
const cast = selectedCharacters.map((s) => `@${s}`).join(', ');
refs.push(`cast: ${cast}`);
}
if (selectedPlace) {
refs.push(`places: @${selectedPlace}`);
}
if (objectsInput.trim()) {
const objects = objectsInput
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => `@${s}`)
.join(', ');
refs.push(`objects: ${objects}`);
}
return refs.join('\n');
}
// Load suggestions for story builder
$effect(() => {
if (kind === 'story' && mode === 'create') {
loadSuggestions();
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title || !slug) {
error = 'Bitte füllen Sie alle Pflichtfelder aus';
return;
}
loading = true;
error = null;
try {
// For stories, merge Story Builder references if no manual references provided
let finalContentFields = { ...contentFields };
if (kind === 'story' && !contentFields.references?.trim()) {
finalContentFields.references = buildReferences();
}
if (mode === 'create') {
const createData: CreateNodeRequest = {
kind,
slug,
title,
summary,
visibility,
world_slug: worldSlug,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: finalContentFields,
custom_schema: customSchema,
custom_data: customData,
image_url: imageUrl || undefined,
generation_prompt: generationPrompt || undefined,
generation_model: generationPrompt ? 'gpt-5-mini' : undefined,
generation_date: generationPrompt ? new Date().toISOString() : undefined,
generation_context: generationContext || undefined,
};
const created = await NodeService.create(createData);
await onSubmit(created);
} else {
const updateData: UpdateNodeRequest = {
title,
slug,
summary,
visibility,
tags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
content: contentFields,
custom_schema: customSchema,
custom_data: customData,
image_url: imageUrl || undefined,
};
const updated = await NodeService.update(initialData.slug!, updateData);
await onSubmit(updated);
}
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
// Get field configuration based on node kind
function getKindConfig() {
const kindNames = {
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
};
return {
title: kindNames[kind] || 'Node',
fields: getFieldsForKind(kind),
};
}
function getFieldsForKind(kind: NodeKind) {
const commonFields = [
{ key: 'appearance', label: 'Erscheinungsbild', rows: 3 },
{ key: 'lore', label: 'Geschichte & Bedeutung', rows: 4 },
];
switch (kind) {
case 'world':
return [
...commonFields,
{ key: 'canon_facts_text', label: 'Kanon-Fakten', rows: 3 },
{ key: 'glossary_text', label: 'Glossar', rows: 3 },
{ key: 'constraints', label: 'Regeln & Einschränkungen', rows: 3 },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3 },
{ key: 'prompt_guidelines', label: 'KI-Richtlinien', rows: 3, optional: true },
];
case 'character':
return [
...commonFields,
{ key: 'voice_style', label: 'Stimme & Sprache', rows: 2 },
{ key: 'capabilities', label: 'Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen', rows: 3 },
{ key: 'motivations', label: 'Motivationen', rows: 3 },
{ key: 'relationships_text', label: 'Beziehungen', rows: 3, optional: true },
{ key: 'inventory_text', label: 'Inventar', rows: 3, optional: true },
{ key: 'timeline_text', label: 'Zeitlinie', rows: 3, optional: true },
{ key: 'secrets', label: 'Geheimnisse', rows: 2, optional: true },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
];
case 'place':
return [
...commonFields,
{ key: 'capabilities', label: 'Was ist hier möglich?', rows: 3 },
{ key: 'constraints', label: 'Gefahren & Einschränkungen', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand', rows: 2, optional: true },
{ key: 'secrets', label: 'Verborgene Aspekte', rows: 2, optional: true },
];
case 'object':
return [
{ key: 'appearance', label: 'Aussehen & Material', rows: 3 },
{ key: 'lore', label: 'Herkunft & Geschichte', rows: 4 },
{ key: 'capabilities', label: 'Eigenschaften & Fähigkeiten', rows: 3 },
{ key: 'constraints', label: 'Einschränkungen & Nachteile', rows: 3 },
{ key: 'state_text', label: 'Aktueller Zustand & Besitzer', rows: 2, optional: true },
];
case 'story':
return [
{ key: 'lore', label: 'Story-Verlauf / Plot', rows: 6 },
{ key: 'references', label: 'Referenzen', rows: 3, optional: true },
{ key: 'prompt_guidelines', label: 'LLM-Richtlinien', rows: 3, optional: true },
];
default:
return commonFields;
}
}
const config = getKindConfig();
const fields = config.fields;
const optionalFields = fields.filter((f) => f.optional);
const requiredFields = fields.filter((f) => !f.optional);
</script>
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<h1 class="text-2xl font-bold text-theme-text-primary">
{mode === 'create' ? `Neuer ${config.title}` : `${config.title} bearbeiten`}
</h1>
<p class="mt-1 text-sm text-theme-text-secondary">
{#if mode === 'create'}
{#if worldTitle}
Erstelle einen neuen {config.title.toLowerCase()} in
<span class="font-semibold">{worldTitle}</span>
{:else}
Erstelle einen neuen {config.title.toLowerCase()}
{/if}
{:else}
Bearbeite die Details für "{initialData.title}"
{/if}
</p>
</div>
{#if error}
<div class="mb-4 rounded-md bg-theme-error/10 border border-theme-error/20 p-4">
<p class="text-sm text-theme-error">{error}</p>
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6 rounded-lg bg-theme-surface p-6 shadow">
<!-- Story Elements Selection (only for stories) -->
{#if kind === 'story' && mode === 'create'}
<div class="space-y-4">
<CharacterSelector
worldSlug={worldSlug || ''}
{selectedCharacters}
onSelectionChange={(selected) => (selectedCharacters = selected)}
/>
<PlaceSelector
worldSlug={worldSlug || ''}
{selectedPlace}
onSelectionChange={(selected) => (selectedPlace = selected)}
/>
</div>
{/if}
<!-- AI Generation Field (only for create mode) -->
{#if mode === 'create'}
<div>
<AiPromptField
{kind}
context={{ world: worldTitle, worldData: $currentWorld }}
selectedCharacters={kind === 'story' ? selectedCharacters : undefined}
selectedPlace={kind === 'story' ? selectedPlace : undefined}
onGenerated={handleAiGenerated}
/>
</div>
{#if !showFormSections}
<div class="text-center">
<button
type="button"
onclick={() => (showFormSections = true)}
class="inline-flex items-center px-4 py-2 text-sm font-medium text-violet-600 hover:text-violet-500"
>
<svg class="mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
Mehr anzeigen
</button>
</div>
{/if}
{/if}
{#if showFormSections}
<!-- Basic Information -->
<div class={mode === 'create' ? 'border-t pt-6' : ''}>
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Grundinformationen</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="title" class="block text-sm font-medium text-theme-text-primary"
>Name *</label
>
<input
type="text"
id="title"
bind:value={title}
onblur={generateSlug}
required
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
<div>
<label for="slug" class="block text-sm font-medium text-theme-text-primary"
>Slug *</label
>
<input
type="text"
id="slug"
bind:value={slug}
required
pattern="[a-z0-9\-]+"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-4">
<label for="summary" class="block text-sm font-medium text-theme-text-primary"
>Zusammenfassung</label
>
<textarea
id="summary"
bind:value={summary}
rows="2"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="visibility" class="block text-sm font-medium text-theme-text-primary"
>Sichtbarkeit</label
>
<select
id="visibility"
bind:value={visibility}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
>
<option value="private">Privat</option>
<option value="shared">Geteilt</option>
<option value="public">Öffentlich</option>
</select>
</div>
<div>
<label for="tags" class="block text-sm font-medium text-theme-text-primary"
>Tags (kommagetrennt)</label
>
<input
type="text"
id="tags"
bind:value={tags}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
</div>
</div>
</div>
<!-- Story Builder - Additional Elements (only for stories) -->
{#if kind === 'story' && mode === 'create'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Weitere Story-Elemente</h2>
<p class="mb-4 text-sm text-theme-text-secondary">
Ergänze deine Story mit Objekten aus dieser Welt.
</p>
<div class="space-y-4">
<div>
<label for="objects" class="block text-sm font-medium text-theme-text-primary">
Objekte (kommagetrennt)
</label>
<input
type="text"
id="objects"
bind:value={objectsInput}
placeholder="magisches-amulett, altes-buch"
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
/>
{#if suggestions.objects.length > 0}
<p class="mt-1 text-xs text-theme-text-secondary">
Verfügbar: {suggestions.objects.slice(0, 5).join(', ')}{suggestions.objects
.length > 5
? '...'
: ''}
</p>
{/if}
</div>
</div>
</div>
{/if}
<!-- Image Generation -->
{#if kind === 'story'}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Story-Bild</h2>
<AiImageGenerator {kind} bind:imageUrl prompt={`${title}: ${contentFields.lore || ''}`} />
</div>
{:else}
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Bild</h2>
<AiImageGenerator {kind} bind:imageUrl prompt={`${title}: ${contentFields.appearance}`} />
</div>
{/if}
<!-- Main Content Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">
{kind === 'story' ? 'Story-Inhalt' : 'Details'}
</h2>
<div class="space-y-4">
{#each requiredFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
</div>
{/each}
</div>
</div>
<!-- Optional Fields -->
{#if optionalFields.length > 0}
<CollapsibleOptions title="Erweiterte Optionen" hasContent={hasOptionalContent}>
{#snippet children()}
{#each optionalFields as field}
<div>
<label for={field.key} class="block text-sm font-medium text-theme-text-primary"
>{field.label}</label
>
<textarea
id={field.key}
bind:value={contentFields[field.key]}
rows={field.rows}
class="mt-1 block w-full rounded-md border border-theme-border-default bg-theme-surface text-theme-text-primary shadow-sm focus:border-theme-primary-500 focus:ring-theme-primary-500 sm:text-sm"
></textarea>
{#if field.key === 'inventory_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @objekt-slug um Objekte zu verlinken
</p>
{:else if field.key === 'state_text' && kind === 'object'}
<p class="mt-1 text-xs text-theme-text-secondary">
z.B. 'Im Besitz von @charakter-slug'
</p>
{:else if field.key === 'relationships_text'}
<p class="mt-1 text-xs text-theme-text-secondary">
Verwende @slug für Referenzen zu anderen Charakteren
</p>
{:else if field.key === 'references' && kind === 'story'}
<p class="mt-1 text-xs text-theme-text-secondary">
Leer lassen, um die Story Builder Auswahl zu verwenden
</p>
{/if}
</div>
{/each}
{/snippet}
</CollapsibleOptions>
{/if}
<!-- Custom Fields -->
<div class="border-t pt-6">
<h2 class="mb-4 text-lg font-medium text-theme-text-primary">Benutzerdefinierte Felder</h2>
<CustomFieldsManager
node={initialData}
nodeSlug={initialData?.slug}
nodeKind={kind}
{worldSlug}
onSchemaChange={(schema) => (customSchema = schema)}
onDataChange={(data) => (customData = data)}
/>
</div>
{/if}
<!-- Actions -->
<div class="flex justify-end space-x-3">
<button
type="button"
onclick={onCancel}
disabled={autoCreating}
class="border-theme-border-default rounded-md border bg-theme-surface px-4 py-2 text-sm font-medium text-theme-text-primary shadow-sm hover:bg-theme-interactive-hover disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading || autoCreating}
class="rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-theme-primary-700 disabled:opacity-50"
>
{autoCreating
? 'Automatische Erstellung läuft...'
: loading
? mode === 'create'
? 'Wird erstellt...'
: 'Speichere...'
: mode === 'create'
? `${config.title} erstellen`
: 'Änderungen speichern'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,349 @@
import type {
CharacterMemory,
ShortTermMemory,
MediumTermMemory,
LongTermMemory,
MemoryEvent,
} from '$lib/types/content';
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
export class MemoryService {
/**
* Get node memory
*/
static async getMemory(nodeId: string): Promise<CharacterMemory | null> {
const { data, error } = await supabase
.from('content_nodes')
.select('memory')
.eq('id', nodeId)
.maybeSingle(); // Use maybeSingle to handle 0 or 1 rows
if (error) {
console.error('Error fetching memory:', error);
return this.getDefaultMemory();
}
// If no data or no memory field, return default memory
if (!data || !data.memory) {
return this.getDefaultMemory();
}
return data.memory;
}
/**
* Update node memory
*/
static async updateMemory(nodeId: string, memory: CharacterMemory): Promise<boolean> {
const { error } = await supabase
.from('content_nodes')
.update({
memory,
updated_at: new Date().toISOString(),
})
.eq('id', nodeId);
if (error) {
console.error('Error updating memory:', error);
return false;
}
return true;
}
/**
* Add a new memory to a node
*/
static async addMemory(
nodeId: string,
content: string,
tier: 'short' | 'medium' | 'long' = 'short',
options: {
importance?: number;
tags?: string[];
involved?: string[];
location?: string;
emotional_weight?: number;
} = {}
): Promise<boolean> {
let memory = await this.getMemory(nodeId);
// Always ensure we have a memory object
if (!memory) {
memory = this.getDefaultMemory();
}
const newMemoryId = crypto.randomUUID();
const timestamp = new Date().toISOString();
if (tier === 'short') {
const shortMemory: ShortTermMemory = {
id: newMemoryId,
timestamp,
content,
importance: options.importance || 5,
tags: options.tags || [],
involved: options.involved || [],
location: options.location,
decay_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
};
memory.short_term_memory.unshift(shortMemory);
// Keep only last 50 short-term memories
if (memory.short_term_memory.length > 50) {
memory.short_term_memory = memory.short_term_memory.slice(0, 50);
}
} else if (tier === 'medium') {
const mediumMemory: MediumTermMemory = {
id: newMemoryId,
timestamp,
content,
context: 'Manually added',
importance: options.importance || 5,
tags: options.tags || [],
involved: options.involved || [],
location: options.location,
decay_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), // 3 months
};
memory.medium_term_memory.unshift(mediumMemory);
// Keep only last 100 medium-term memories
if (memory.medium_term_memory.length > 100) {
memory.medium_term_memory = memory.medium_term_memory.slice(0, 100);
}
} else if (tier === 'long') {
const longMemory: LongTermMemory = {
id: newMemoryId,
timestamp,
content,
emotional_weight: options.emotional_weight || options.importance || 7,
category: 'manual',
triggers: options.tags,
involved: options.involved || [],
immutable: true,
};
memory.long_term_memory.unshift(longMemory);
// Keep only last 200 long-term memories
if (memory.long_term_memory.length > 200) {
memory.long_term_memory = memory.long_term_memory.slice(0, 200);
}
}
return await this.updateMemory(nodeId, memory);
}
/**
* Process and age memories
*/
static async processMemories(
nodeId: string,
currentDate?: Date
): Promise<CharacterMemory | null> {
const memory = await this.getMemory(nodeId);
if (!memory) return null;
const now = currentDate || new Date();
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
const threeMonthsAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
// Process short-term memories
const agedShortTerm = memory.short_term_memory.filter(
(m) => new Date(m.timestamp) < threeDaysAgo
);
// Move important short-term to medium-term
for (const mem of agedShortTerm) {
if (mem.importance >= 3) {
const mediumMemory: MediumTermMemory = {
id: mem.id,
timestamp: mem.timestamp,
content: this.compressMemory(mem.content),
original_details: mem.content,
context: 'Aged from short-term memory',
location: mem.location,
involved: mem.involved,
tags: mem.tags,
importance: mem.importance,
decay_at: new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(),
linked_memories: [],
};
memory.medium_term_memory.push(mediumMemory);
}
}
// Remove aged memories from short-term
memory.short_term_memory = memory.short_term_memory.filter(
(m) => new Date(m.timestamp) >= threeDaysAgo
);
// Process medium-term memories
const agedMediumTerm = memory.medium_term_memory.filter(
(m) => new Date(m.timestamp) < threeMonthsAgo
);
// Move very important medium-term to long-term
for (const mem of agedMediumTerm) {
if (mem.importance >= 7 || mem.tags?.includes('#trauma') || mem.tags?.includes('#triumph')) {
const longMemory: LongTermMemory = {
id: mem.id,
timestamp: mem.timestamp,
content: this.extractCore(mem.content),
emotional_weight: mem.importance,
category: this.categorizeMemory(mem),
triggers: mem.tags,
effects: `Based on: ${mem.context}`,
involved: mem.involved,
immutable: true,
};
memory.long_term_memory.push(longMemory);
}
}
// Remove aged memories from medium-term
memory.medium_term_memory = memory.medium_term_memory.filter(
(m) => new Date(m.timestamp) >= threeMonthsAgo
);
// Update last processed time
memory.last_processed = now.toISOString();
// Save processed memory
await this.updateMemory(nodeId, memory);
return memory;
}
/**
* Delete a specific memory
*/
static async deleteMemory(nodeId: string, memoryId: string): Promise<boolean> {
const memory = await this.getMemory(nodeId);
if (!memory) return false;
// Check and remove from each tier
memory.short_term_memory = memory.short_term_memory.filter((m) => m.id !== memoryId);
memory.medium_term_memory = memory.medium_term_memory.filter((m) => m.id !== memoryId);
memory.long_term_memory = memory.long_term_memory.filter((m) => m.id !== memoryId);
return await this.updateMemory(nodeId, memory);
}
/**
* Search memories for specific content
*/
static async searchMemories(
nodeId: string,
query: string
): Promise<Array<ShortTermMemory | MediumTermMemory | LongTermMemory>> {
const memory = await this.getMemory(nodeId);
if (!memory) return [];
const results: Array<ShortTermMemory | MediumTermMemory | LongTermMemory> = [];
const searchLower = query.toLowerCase();
// Search in all tiers
for (const mem of memory.short_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.tags?.some((tag) => tag.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
for (const mem of memory.medium_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.original_details?.toLowerCase().includes(searchLower) ||
mem.tags?.some((tag) => tag.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
for (const mem of memory.long_term_memory) {
if (
mem.content.toLowerCase().includes(searchLower) ||
mem.triggers?.some((trigger) => trigger.toLowerCase().includes(searchLower)) ||
mem.involved?.some((inv) => inv.toLowerCase().includes(searchLower))
) {
results.push(mem);
}
}
return results;
}
/**
* Get memory events for a node
*/
static async getMemoryEvents(nodeId: string): Promise<MemoryEvent[]> {
const { data, error } = await supabase
.from('memory_events')
.select('*')
.eq('node_id', nodeId)
.order('event_timestamp', { ascending: false })
.limit(50);
if (error) {
console.error('Error fetching memory events:', error);
return [];
}
return data || [];
}
/**
* Create a memory event
*/
static async createMemoryEvent(event: Omit<MemoryEvent, 'id' | 'created_at'>): Promise<boolean> {
const { error } = await supabase.from('memory_events').insert(event);
if (error) {
console.error('Error creating memory event:', error);
return false;
}
return true;
}
// Helper methods
private static getDefaultMemory(): CharacterMemory {
return {
short_term_memory: [],
medium_term_memory: [],
long_term_memory: [],
memory_traits: {
memory_quality: 'average',
},
};
}
private static compressMemory(content: string): string {
// Simple compression - take first 200 chars
// In production, this could use AI to summarize
return content.length > 200 ? content.substring(0, 197) + '...' : content;
}
private static extractCore(content: string): string {
// Extract the most important part
// In production, this could use AI to extract key points
return content.length > 150 ? content.substring(0, 147) + '...' : content;
}
private static categorizeMemory(
memory: MediumTermMemory
): 'trauma' | 'triumph' | 'relationship' | 'skill' | 'secret' | 'manual' {
// Simple categorization based on tags
if (memory.tags?.includes('#trauma')) return 'trauma';
if (memory.tags?.includes('#triumph') || memory.tags?.includes('#success')) return 'triumph';
if (memory.tags?.includes('#relationship') || memory.involved?.length) return 'relationship';
if (memory.tags?.includes('#skill') || memory.tags?.includes('#learned')) return 'skill';
if (memory.tags?.includes('#secret')) return 'secret';
return 'manual';
}
}

View file

@ -0,0 +1,121 @@
import type { ContentNode, NodeKind, ContentData, VisibilityLevel } from '$lib/types/content';
import type { CustomFieldSchema, CustomFieldData } from '$lib/types/customFields';
export interface CreateNodeRequest {
kind: NodeKind;
slug: string;
title: string;
summary?: string;
visibility: VisibilityLevel;
world_slug?: string;
tags: string[];
content: ContentData;
custom_schema?: CustomFieldSchema;
custom_data?: CustomFieldData;
image_url?: string;
generation_prompt?: string;
generation_model?: string;
generation_date?: string;
generation_context?: any;
}
export interface UpdateNodeRequest {
title?: string;
slug?: string;
summary?: string;
visibility?: VisibilityLevel;
tags?: string[];
content?: ContentData;
custom_schema?: CustomFieldSchema;
custom_data?: CustomFieldData;
image_url?: string;
}
export interface NodeFilters {
kind?: NodeKind;
world_slug?: string;
search?: string;
limit?: number;
offset?: number;
}
export class NodeService {
static async create(node: CreateNodeRequest): Promise<ContentNode> {
const response = await fetch('/api/nodes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(node),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Erstellen');
}
return response.json();
}
static async update(slug: string, updates: UpdateNodeRequest): Promise<ContentNode> {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Aktualisieren');
}
return response.json();
}
static async get(slug: string): Promise<ContentNode> {
const response = await fetch(`/api/nodes/${slug}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Node nicht gefunden');
}
return response.json();
}
static async list(filters: NodeFilters = {}): Promise<ContentNode[]> {
const params = new URLSearchParams();
if (filters.kind) params.set('kind', filters.kind);
if (filters.world_slug) params.set('world_slug', filters.world_slug);
if (filters.search) params.set('search', filters.search);
if (filters.limit) params.set('limit', filters.limit.toString());
if (filters.offset) params.set('offset', filters.offset.toString());
const response = await fetch(`/api/nodes?${params}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Laden der Nodes');
}
return response.json();
}
static async delete(slug: string): Promise<void> {
const response = await fetch(`/api/nodes/${slug}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Fehler beim Löschen');
}
}
static generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[äöü]/g, (char) => ({ ä: 'ae', ö: 'oe', ü: 'ue' })[char] || char)
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
}

View file

@ -0,0 +1,145 @@
import type { ContentNode } from '$lib/types/content';
interface ReferenceData {
slug: string;
title: string;
kind: 'character' | 'place' | 'object';
image_url?: string;
}
// Cache für geladene Referenzen (Client-side)
const referenceCache = new Map<string, ReferenceData>();
const pendingRequests = new Map<string, Promise<ReferenceData | null>>();
/**
* Lädt Referenzdaten für einen Slug
*/
async function fetchReference(slug: string): Promise<ReferenceData | null> {
console.log('🔍 Fetching reference for slug:', slug);
// Check cache first
if (referenceCache.has(slug)) {
console.log('✅ Found in cache:', slug);
return referenceCache.get(slug)!;
}
// Check if request is already pending
if (pendingRequests.has(slug)) {
console.log('⏳ Request already pending for:', slug);
return pendingRequests.get(slug)!;
}
// Create new request
console.log('🌐 Making API request for:', slug);
const request = fetch(`/api/nodes/${slug}`)
.then(async (response) => {
console.log(`📡 API response for ${slug}:`, response.status);
if (!response.ok) {
console.error(`❌ Failed to fetch ${slug}:`, response.status);
return null;
}
const node: ContentNode = await response.json();
console.log(`✨ Got node data for ${slug}:`, node.title);
const reference: ReferenceData = {
slug: node.slug,
title: node.title,
kind: node.kind as 'character' | 'place' | 'object',
image_url: node.image_url,
};
// Cache the result
referenceCache.set(slug, reference);
return reference;
})
.catch((error) => {
console.error(`❌ Error fetching ${slug}:`, error);
return null;
})
.finally(() => {
pendingRequests.delete(slug);
});
pendingRequests.set(slug, request);
return request;
}
/**
* Lädt mehrere Referenzen parallel
*/
export async function fetchReferences(slugs: string[]): Promise<Map<string, ReferenceData>> {
const uniqueSlugs = [...new Set(slugs)];
const results = await Promise.all(uniqueSlugs.map((slug) => fetchReference(slug)));
const referenceMap = new Map<string, ReferenceData>();
results.forEach((data, index) => {
if (data) {
referenceMap.set(uniqueSlugs[index], data);
}
});
return referenceMap;
}
/**
* Extrahiert alle @-Referenzen aus einem Text
*/
export function extractReferences(text: string): string[] {
const matches = text.matchAll(/@([\w-]+)/g);
return [...new Set([...matches].map((m) => m[1]))];
}
/**
* Ersetzt @-Referenzen mit formatierten Links
*/
export function replaceReferences(
text: string,
references: Map<string, ReferenceData>,
options: {
showAvatar?: boolean;
linkClass?: string;
} = {}
): string {
const { showAvatar = false, linkClass = 'character-link' } = options;
// Replace each @reference with formatted link
let result = text;
for (const [slug, data] of references) {
const pattern = new RegExp(`@${slug}(?![-\\w])`, 'g');
let replacement = `<a href="/${slug}" class="${linkClass}" data-kind="${data.kind}">`;
if (showAvatar && data.image_url) {
replacement += `<img src="${data.image_url}" alt="${data.title}" class="inline-avatar" />`;
}
replacement += `${data.title}</a>`;
result = result.replace(pattern, replacement);
}
// Handle any remaining @references that weren't found
result = result.replace(/@([\w-]+)/g, (match, slug) => {
// Fallback: Zeige formatierten Slug
const displayName = slug
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<a href="/${slug}" class="${linkClass} reference-unknown">${displayName}</a>`;
});
return result;
}
/**
* Clear cache (z.B. nach Updates)
*/
export function clearReferenceCache(slug?: string) {
if (slug) {
referenceCache.delete(slug);
} else {
referenceCache.clear();
}
}

View file

@ -0,0 +1,82 @@
import type { SupabaseClient } from '@supabase/supabase-js';
const BUCKET_NAME = 'content-images';
export async function uploadImage(
supabase: SupabaseClient,
userId: string,
nodeId: string,
imageData: string | Blob,
fileName?: string
): Promise<{ url: string; path: string } | null> {
try {
// Generate unique file name
const timestamp = Date.now();
const extension = fileName?.split('.').pop() || 'png';
const filePath = `${userId}/${nodeId}/${timestamp}.${extension}`;
// Convert base64 to blob if needed
let uploadData: Blob;
if (typeof imageData === 'string') {
// Remove data URL prefix if present
const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
uploadData = new Blob([byteArray], { type: `image/${extension}` });
} else {
uploadData = imageData;
}
// Upload to Supabase Storage
const { data, error } = await supabase.storage.from(BUCKET_NAME).upload(filePath, uploadData, {
contentType: `image/${extension}`,
upsert: true,
});
if (error) {
console.error('Upload error:', error);
return null;
}
// Get public URL
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET_NAME).getPublicUrl(filePath);
return {
url: publicUrl,
path: filePath,
};
} catch (error) {
console.error('Error uploading image:', error);
return null;
}
}
export async function deleteImage(supabase: SupabaseClient, filePath: string): Promise<boolean> {
try {
const { error } = await supabase.storage.from(BUCKET_NAME).remove([filePath]);
if (error) {
console.error('Delete error:', error);
return false;
}
return true;
} catch (error) {
console.error('Error deleting image:', error);
return false;
}
}
export function getImageUrl(supabase: SupabaseClient, filePath: string): string {
const {
data: { publicUrl },
} = supabase.storage.from(BUCKET_NAME).getPublicUrl(filePath);
return publicUrl;
}

View file

@ -0,0 +1,117 @@
import { writable } from 'svelte/store';
import type { ContentNode } from '$lib/types/content';
type AiMode = 'text' | 'image';
type ImageStyle = 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
interface AiAuthorState {
isVisible: boolean;
currentNode: ContentNode | null;
isOwner: boolean;
mode: AiMode;
imageGenerationState: {
loading: boolean;
generatedUrl: string | null;
prompt: string;
style: ImageStyle;
error: string | null;
};
}
function createAiAuthorStore() {
const { subscribe, set, update } = writable<AiAuthorState>({
isVisible: false,
currentNode: null,
isOwner: false,
mode: 'text',
imageGenerationState: {
loading: false,
generatedUrl: null,
prompt: '',
style: 'fantasy',
error: null,
},
});
return {
subscribe,
// Show bar with context
show: (node: ContentNode, isOwner: boolean) => {
update((state) => ({
...state,
isVisible: true,
currentNode: node,
isOwner,
}));
},
// Hide bar
hide: () => {
update((state) => ({
...state,
isVisible: false,
}));
},
// Toggle visibility (keeps current context)
toggle: () => {
update((state) => ({
...state,
isVisible: !state.isVisible,
}));
},
// Update context without changing visibility
setContext: (node: ContentNode, isOwner: boolean) => {
update((state) => ({
...state,
currentNode: node,
isOwner,
}));
},
// Update node after AI edit
updateNode: (updatedNode: ContentNode) => {
update((state) => ({
...state,
currentNode: updatedNode,
}));
},
// Switch mode
setMode: (mode: AiMode) => {
update((state) => ({
...state,
mode,
}));
},
// Update image generation state
setImageState: (imageState: Partial<AiAuthorState['imageGenerationState']>) => {
update((state) => ({
...state,
imageGenerationState: {
...state.imageGenerationState,
...imageState,
},
}));
},
// Reset image generation state
resetImageState: () => {
update((state) => ({
...state,
imageGenerationState: {
loading: false,
generatedUrl: null,
prompt: '',
style: 'fantasy',
error: null,
},
}));
},
};
}
export const aiAuthorStore = createAiAuthorStore();

View file

@ -0,0 +1,243 @@
import { writable } from 'svelte/store';
export interface LoadingStep {
id: string;
label: string;
status: 'pending' | 'active' | 'completed' | 'error';
message?: string;
duration?: number;
startTime?: number;
}
interface LoadingState {
isLoading: boolean;
title: string;
steps: LoadingStep[];
currentStep: number;
error?: string;
funFact?: string;
estimatedTime?: number;
startTime?: number;
}
// Fun Facts für Worldbuilding
const worldbuildingFacts = [
'💡 Wusstest du? Tolkien erfand Mittelerde ursprünglich für seine selbst erfundenen Sprachen.',
'🌍 Die detailliertesten fiktiven Welten haben oft ihre eigene Zeitrechnung und Kalender.',
'📚 George R.R. Martin schrieb 400.000 Wörter Hintergrundgeschichte, die nie veröffentlicht wurden.',
'🗺️ Die Karte von Westeros basiert teilweise auf einem umgedrehten Irland.',
'✨ Brandon Sanderson erstellt für jede seiner Welten eigene Magiesysteme mit festen Regeln.',
'🎭 Gute Charaktere haben oft Widersprüche - das macht sie menschlich.',
'🏰 Die besten Fantasy-Welten fühlen sich "gelebt" an, mit eigener Geschichte und Kultur.',
'🌟 J.K. Rowling plante die Harry Potter Serie 5 Jahre lang, bevor sie zu schreiben begann.',
'🐉 Drachen erscheinen in fast jeder Kultur der Welt - unabhängig voneinander.',
"📖 Terry Pratchett's Scheibenwelt hat über 40 Romane und ist eine der detailliertesten Fantasywelten.",
'🎨 Concept Art kann helfen, die Vision deiner Welt zu konkretisieren.',
'🗣️ Erfundene Sprachen (Conlangs) geben deiner Welt zusätzliche Tiefe.',
'⚔️ Die besten Konflikte entstehen aus den Motivationen der Charaktere, nicht aus dem Plot.',
'🌙 Viele Autoren träumen von ihren Welten und Charakteren.',
'🎬 Star Wars begann als 200-seitige Rohfassung, die niemand verstand.',
];
function getRandomFunFact(): string {
return worldbuildingFacts[Math.floor(Math.random() * worldbuildingFacts.length)];
}
function createLoadingStore() {
const { subscribe, set, update } = writable<LoadingState>({
isLoading: false,
title: '',
steps: [],
currentStep: -1,
});
let funFactInterval: NodeJS.Timeout | null = null;
return {
subscribe,
// Start loading with steps
start(title: string, steps: string[]) {
const now = Date.now();
set({
isLoading: true,
title,
steps: steps.map((label, index) => ({
id: `step-${index}`,
label,
status: 'pending',
startTime: undefined,
})),
currentStep: 0,
funFact: getRandomFunFact(),
startTime: now,
estimatedTime: now + steps.length * 7500, // Rough estimate: 7.5s per step for ~30s total
});
// Rotate fun facts every 5 seconds
funFactInterval = setInterval(() => {
update((state) => ({
...state,
funFact: getRandomFunFact(),
}));
}, 5000);
// Activate first step
this.nextStep();
},
// Move to next step
nextStep(message?: string) {
update((state) => {
if (!state.isLoading) return state;
const now = Date.now();
// Complete current step
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
state.steps[state.currentStep].status = 'completed';
if (message) {
state.steps[state.currentStep].message = message;
}
// Calculate duration for completed step
if (state.steps[state.currentStep].startTime) {
state.steps[state.currentStep].duration =
now - state.steps[state.currentStep].startTime;
}
}
// Move to next step
const nextIndex = state.currentStep + 1;
if (nextIndex < state.steps.length) {
state.steps[nextIndex].status = 'active';
state.steps[nextIndex].startTime = now;
state.currentStep = nextIndex;
// Update estimated time based on completed steps
const completedSteps = state.steps.filter((s) => s.status === 'completed').length;
const remainingSteps = state.steps.length - completedSteps - 1; // -1 for current active step
if (completedSteps > 0 && remainingSteps > 0) {
const totalDuration = state.steps
.filter((s) => s.duration)
.reduce((sum, s) => sum + (s.duration || 0), 0);
const avgDuration = totalDuration / completedSteps;
// Estimated time is: now + (average duration * remaining steps)
state.estimatedTime = now + avgDuration * remainingSteps;
} else {
// Default estimate: ~7.5 seconds per remaining step (for ~30s total with 4 steps)
state.estimatedTime = now + remainingSteps * 7500;
}
}
return state;
});
},
// Update current step
updateStep(message: string) {
update((state) => {
if (!state.isLoading) return state;
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
state.steps[state.currentStep].message = message;
}
return state;
});
},
// Mark step as error
setError(error: string) {
update((state) => {
if (!state.isLoading) return state;
if (state.currentStep >= 0 && state.currentStep < state.steps.length) {
state.steps[state.currentStep].status = 'error';
state.steps[state.currentStep].message = error;
}
state.error = error;
return state;
});
},
// Complete loading
complete(message?: string) {
update((state) => {
// Complete all remaining steps
state.steps = state.steps.map((step) => ({
...step,
status: step.status === 'error' ? 'error' : 'completed',
}));
if (message && state.currentStep >= 0) {
state.steps[state.currentStep].message = message;
}
return state;
});
// Clear fun fact interval
if (funFactInterval) {
clearInterval(funFactInterval);
funFactInterval = null;
}
// Hide after a short delay
setTimeout(() => {
this.reset();
}, 1500);
},
// Reset loading state
reset() {
// Clear fun fact interval
if (funFactInterval) {
clearInterval(funFactInterval);
funFactInterval = null;
}
set({
isLoading: false,
title: '',
steps: [],
currentStep: -1,
});
},
// Helper for AI generation steps
startAiGeneration(kind: string) {
const steps =
kind === 'world'
? [
'🔍 Analysiere Anforderungen...',
'🌍 Erstelle Grundlagen der Welt...',
'📚 Generiere erweiterte Details...',
'✨ Finalisiere Welt...',
]
: ['🔍 Analysiere Kontext...', '🎨 Generiere Inhalte...', '✨ Optimiere Ergebnis...'];
this.start(`${kind.charAt(0).toUpperCase() + kind.slice(1)} wird erstellt`, steps);
},
// Helper for complete creation process with image
startCompleteCreation(kind: string) {
const kindLabel =
{
world: 'Welt',
character: 'Charakter',
place: 'Ort',
object: 'Objekt',
story: 'Story',
}[kind] || kind;
const steps = [
'🤖 Generiere mit KI...',
`💾 Erstelle ${kindLabel}...`,
'🎨 Generiere Bild...',
'✅ Fertigstellung...',
];
this.start(`${kindLabel} wird komplett erstellt`, steps);
},
};
}
export const loadingStore = createLoadingStore();

View file

@ -0,0 +1,49 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import type { ContentNode } from '$lib/types/content';
// Store for the current world context
function createWorldStore() {
const STORAGE_KEY = 'worldream-current-world';
// Initialize from localStorage if available
const initialWorld =
browser && localStorage.getItem(STORAGE_KEY)
? JSON.parse(localStorage.getItem(STORAGE_KEY)!)
: null;
const { subscribe, set, update } = writable<ContentNode | null>(initialWorld);
return {
subscribe,
// Set the current world
setWorld(world: ContentNode) {
if (browser) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(world));
}
set(world);
},
// Clear the current world
clearWorld() {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
set(null);
},
// Get the current world (for non-reactive access)
getCurrent() {
return get({ subscribe });
},
};
}
export const currentWorld = createWorldStore();
// Derived store for world slug
export const currentWorldSlug = derived(currentWorld, ($world) => $world?.slug || null);
// Derived store for checking if we're in a world context
export const hasWorldContext = derived(currentWorld, ($world) => $world !== null);

View file

@ -0,0 +1,6 @@
import { createBrowserClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export function createClient() {
return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
}

View file

@ -0,0 +1,18 @@
import { createServerClient } from '@supabase/ssr';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import type { RequestEvent } from '@sveltejs/kit';
export function createClient(event: RequestEvent) {
return createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
getAll() {
return event.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
},
},
});
}

View file

@ -0,0 +1,120 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import { themes, generateCssVariables, getTheme } from './themes.config';
export type ThemeName = keyof typeof themes;
export type ThemeMode = 'light' | 'dark';
export interface ThemeState {
theme: ThemeName;
mode: ThemeMode;
}
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>({ theme: 'default', mode: 'light' });
function applyTheme(state: ThemeState) {
if (!browser) return;
const theme = getTheme(state.theme);
if (!theme) return;
// Set data attributes
document.documentElement.setAttribute('data-theme', state.theme);
document.documentElement.setAttribute('data-mode', state.mode);
// Apply CSS variables dynamically
const cssVariables = generateCssVariables(theme, state.mode === 'dark');
const root = document.documentElement;
Object.entries(cssVariables).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
// Update dark mode class for Tailwind compatibility
if (state.mode === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Store preferences
localStorage.setItem('selectedTheme', state.theme);
localStorage.setItem('selectedMode', state.mode);
}
return {
subscribe,
init: () => {
if (!browser) return;
// Check for saved preferences
const savedTheme = localStorage.getItem('selectedTheme') as ThemeName | null;
const savedMode = localStorage.getItem('selectedMode') as ThemeMode | null;
// Check system preference as fallback
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Determine initial state
const initialState: ThemeState = {
theme: savedTheme && themes[savedTheme] ? savedTheme : 'default',
mode: savedMode || (systemPrefersDark ? 'dark' : 'light'),
};
// Apply the initial theme
set(initialState);
applyTheme(initialState);
},
setTheme: (themeName: ThemeName) => {
if (!themes[themeName]) {
console.warn(`Theme "${themeName}" not found`);
return;
}
update((current) => {
const newState = { ...current, theme: themeName };
applyTheme(newState);
return newState;
});
},
setMode: (mode: ThemeMode) => {
update((current) => {
const newState = { ...current, mode };
applyTheme(newState);
return newState;
});
},
toggleMode: () => {
update((current) => {
const newMode = current.mode === 'light' ? 'dark' : 'light';
const newState = { ...current, mode: newMode };
applyTheme(newState);
return newState;
});
},
cycleTheme: () => {
update((current) => {
// Cycle through all available themes
const themeNames = Object.keys(themes) as ThemeName[];
const currentIndex = themeNames.indexOf(current.theme);
const nextIndex = (currentIndex + 1) % themeNames.length;
const newTheme = themeNames[nextIndex];
const newState = { ...current, theme: newTheme };
applyTheme(newState);
return newState;
});
},
getAvailableThemes: () => {
return Object.entries(themes).map(([key, theme]) => ({
id: key as ThemeName,
name: theme.name,
}));
},
getCurrentTheme: () => {
let currentState: ThemeState;
subscribe((state) => (currentState = state))();
return currentState!;
},
};
}
export const theme = createThemeStore();

View file

@ -0,0 +1,374 @@
export interface ThemeColors {
// Primary brand colors
primary: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
950: string;
};
// Background colors
background: {
base: string;
surface: string;
elevated: string;
overlay: string;
};
// Text colors
text: {
primary: string;
secondary: string;
tertiary: string;
inverse: string;
};
// Border colors
border: {
default: string;
subtle: string;
strong: string;
};
// State colors
state: {
success: string;
warning: string;
error: string;
info: string;
};
// Interactive elements
interactive: {
hover: string;
active: string;
focus: string;
disabled: string;
};
}
export interface Theme {
name: string;
light: ThemeColors;
dark: ThemeColors;
}
export const themes: Record<string, Theme> = {
default: {
name: 'Standard',
light: {
primary: {
50: 'rgb(245 243 255)', // violet-50
100: 'rgb(237 233 254)', // violet-100
200: 'rgb(221 214 254)', // violet-200
300: 'rgb(196 181 253)', // violet-300
400: 'rgb(167 139 250)', // violet-400
500: 'rgb(139 92 246)', // violet-500
600: 'rgb(124 58 237)', // violet-600
700: 'rgb(109 40 217)', // violet-700
800: 'rgb(91 33 182)', // violet-800
900: 'rgb(76 29 149)', // violet-900
950: 'rgb(46 16 101)', // violet-950
},
background: {
base: 'rgb(248 250 252)', // slate-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(15 23 42)', // slate-900
secondary: 'rgb(71 85 105)', // slate-600
tertiary: 'rgb(148 163 184)', // slate-400
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(203 213 225)', // slate-300
subtle: 'rgb(226 232 240)', // slate-200
strong: 'rgb(148 163 184)', // slate-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(248 250 252)', // slate-50
active: 'rgb(241 245 249)', // slate-100
focus: 'rgb(139 92 246)', // violet-500
disabled: 'rgb(226 232 240)', // slate-200
},
},
dark: {
primary: {
50: 'rgb(250 250 250)', // zinc-50
100: 'rgb(244 244 245)', // zinc-100
200: 'rgb(228 228 231)', // zinc-200
300: 'rgb(212 212 216)', // zinc-300
400: 'rgb(161 161 170)', // zinc-400
500: 'rgb(113 113 122)', // zinc-500
600: 'rgb(82 82 91)', // zinc-600
700: 'rgb(63 63 70)', // zinc-700
800: 'rgb(39 39 42)', // zinc-800
900: 'rgb(24 24 27)', // zinc-900
950: 'rgb(9 9 11)', // zinc-950
},
background: {
base: 'rgb(9 9 11)', // zinc-950
surface: 'rgb(39 39 42)', // zinc-800
elevated: 'rgb(63 63 70)', // zinc-700
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(244 244 245)', // zinc-100
secondary: 'rgb(161 161 170)', // zinc-400
tertiary: 'rgb(82 82 91)', // zinc-600
inverse: 'rgb(24 24 27)', // zinc-900
},
border: {
default: 'rgb(82 82 91)', // zinc-600
subtle: 'rgb(63 63 70)', // zinc-700
strong: 'rgb(113 113 122)', // zinc-500
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(63 63 70)', // zinc-700
active: 'rgb(82 82 91)', // zinc-600
focus: 'rgb(167 139 250)', // violet-400
disabled: 'rgb(39 39 42)', // zinc-800
},
},
},
forest: {
name: 'Wald',
light: {
primary: {
50: 'rgb(240 253 244)', // green-50
100: 'rgb(220 252 231)', // green-100
200: 'rgb(187 247 208)', // green-200
300: 'rgb(134 239 172)', // green-300
400: 'rgb(74 222 128)', // green-400
500: 'rgb(34 197 94)', // green-500
600: 'rgb(22 163 74)', // green-600
700: 'rgb(21 128 61)', // green-700
800: 'rgb(22 101 52)', // green-800
900: 'rgb(20 83 45)', // green-900
950: 'rgb(5 46 22)', // green-950
},
background: {
base: 'rgb(240 253 244)', // green-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(20 83 45)', // green-900
secondary: 'rgb(22 101 52)', // green-800
tertiary: 'rgb(22 163 74)', // green-600
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(134 239 172)', // green-300
subtle: 'rgb(187 247 208)', // green-200
strong: 'rgb(74 222 128)', // green-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(220 252 231)', // green-100
active: 'rgb(187 247 208)', // green-200
focus: 'rgb(34 197 94)', // green-500
disabled: 'rgb(220 252 231)', // green-100
},
},
dark: {
primary: {
50: 'rgb(240 253 244)', // green-50
100: 'rgb(220 252 231)', // green-100
200: 'rgb(187 247 208)', // green-200
300: 'rgb(134 239 172)', // green-300
400: 'rgb(74 222 128)', // green-400
500: 'rgb(34 197 94)', // green-500
600: 'rgb(22 163 74)', // green-600
700: 'rgb(21 128 61)', // green-700
800: 'rgb(22 101 52)', // green-800
900: 'rgb(20 83 45)', // green-900
950: 'rgb(5 46 22)', // green-950
},
background: {
base: 'rgb(5 46 22)', // green-950
surface: 'rgb(22 101 52)', // green-800
elevated: 'rgb(21 128 61)', // green-700
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(220 252 231)', // green-100
secondary: 'rgb(134 239 172)', // green-300
tertiary: 'rgb(74 222 128)', // green-400
inverse: 'rgb(20 83 45)', // green-900
},
border: {
default: 'rgb(21 128 61)', // green-700
subtle: 'rgb(22 101 52)', // green-800
strong: 'rgb(22 163 74)', // green-600
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(59 130 246)', // blue-500
},
interactive: {
hover: 'rgb(21 128 61)', // green-700
active: 'rgb(22 163 74)', // green-600
focus: 'rgb(74 222 128)', // green-400
disabled: 'rgb(22 101 52)', // green-800
},
},
},
ocean: {
name: 'Ozean',
light: {
primary: {
50: 'rgb(240 249 255)', // sky-50
100: 'rgb(224 242 254)', // sky-100
200: 'rgb(186 230 253)', // sky-200
300: 'rgb(125 211 252)', // sky-300
400: 'rgb(56 189 248)', // sky-400
500: 'rgb(14 165 233)', // sky-500
600: 'rgb(2 132 199)', // sky-600
700: 'rgb(3 105 161)', // sky-700
800: 'rgb(7 89 133)', // sky-800
900: 'rgb(12 74 110)', // sky-900
950: 'rgb(8 47 73)', // sky-950
},
background: {
base: 'rgb(240 249 255)', // sky-50
surface: 'rgb(255 255 255)', // white
elevated: 'rgb(255 255 255)', // white
overlay: 'rgba(0 0 0 / 0.5)',
},
text: {
primary: 'rgb(12 74 110)', // sky-900
secondary: 'rgb(7 89 133)', // sky-800
tertiary: 'rgb(3 105 161)', // sky-700
inverse: 'rgb(255 255 255)', // white
},
border: {
default: 'rgb(125 211 252)', // sky-300
subtle: 'rgb(186 230 253)', // sky-200
strong: 'rgb(56 189 248)', // sky-400
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(14 165 233)', // sky-500
},
interactive: {
hover: 'rgb(224 242 254)', // sky-100
active: 'rgb(186 230 253)', // sky-200
focus: 'rgb(14 165 233)', // sky-500
disabled: 'rgb(224 242 254)', // sky-100
},
},
dark: {
primary: {
50: 'rgb(240 249 255)', // sky-50
100: 'rgb(224 242 254)', // sky-100
200: 'rgb(186 230 253)', // sky-200
300: 'rgb(125 211 252)', // sky-300
400: 'rgb(56 189 248)', // sky-400
500: 'rgb(14 165 233)', // sky-500
600: 'rgb(2 132 199)', // sky-600
700: 'rgb(3 105 161)', // sky-700
800: 'rgb(7 89 133)', // sky-800
900: 'rgb(12 74 110)', // sky-900
950: 'rgb(8 47 73)', // sky-950
},
background: {
base: 'rgb(8 47 73)', // sky-950
surface: 'rgb(12 74 110)', // sky-900
elevated: 'rgb(7 89 133)', // sky-800
overlay: 'rgba(0 0 0 / 0.8)',
},
text: {
primary: 'rgb(224 242 254)', // sky-100
secondary: 'rgb(125 211 252)', // sky-300
tertiary: 'rgb(56 189 248)', // sky-400
inverse: 'rgb(12 74 110)', // sky-900
},
border: {
default: 'rgb(3 105 161)', // sky-700
subtle: 'rgb(7 89 133)', // sky-800
strong: 'rgb(2 132 199)', // sky-600
},
state: {
success: 'rgb(34 197 94)', // green-500
warning: 'rgb(251 146 60)', // orange-400
error: 'rgb(239 68 68)', // red-500
info: 'rgb(14 165 233)', // sky-500
},
interactive: {
hover: 'rgb(7 89 133)', // sky-800
active: 'rgb(3 105 161)', // sky-700
focus: 'rgb(56 189 248)', // sky-400
disabled: 'rgb(12 74 110)', // sky-900
},
},
},
};
// Helper function to get CSS variable name
export function getCssVariableName(path: string): string {
return `--theme-${path.replace(/\./g, '-')}`;
}
// Helper function to generate CSS variables from theme
export function generateCssVariables(
theme: Theme,
isDark: boolean = false
): Record<string, string> {
const variables: Record<string, string> = {};
const colors = isDark ? theme.dark : theme.light;
// Flatten the theme colors into CSS variables
Object.entries(colors).forEach(([category, values]) => {
if (typeof values === 'object') {
Object.entries(values).forEach(([key, value]) => {
variables[getCssVariableName(`${category}.${key}`)] = value;
});
} else {
variables[getCssVariableName(category)] = values;
}
});
return variables;
}
// Get available theme names
export function getThemeNames(): string[] {
return Object.keys(themes);
}
// Get theme by name
export function getTheme(name: string): Theme | undefined {
return themes[name];
}

View file

@ -0,0 +1,44 @@
/* Theme CSS Variables */
/* This file defines CSS variables that are dynamically updated based on the selected theme and mode */
:root {
/* Default theme (Standard Light) */
--theme-primary-50: rgb(245 243 255);
--theme-primary-100: rgb(237 233 254);
--theme-primary-200: rgb(221 214 254);
--theme-primary-300: rgb(196 181 253);
--theme-primary-400: rgb(167 139 250);
--theme-primary-500: rgb(139 92 246);
--theme-primary-600: rgb(124 58 237);
--theme-primary-700: rgb(109 40 217);
--theme-primary-800: rgb(91 33 182);
--theme-primary-900: rgb(76 29 149);
--theme-primary-950: rgb(46 16 101);
--theme-background-base: rgb(248 250 252);
--theme-background-surface: rgb(255 255 255);
--theme-background-elevated: rgb(255 255 255);
--theme-background-overlay: rgba(0 0 0 / 0.5);
--theme-text-primary: rgb(15 23 42);
--theme-text-secondary: rgb(71 85 105);
--theme-text-tertiary: rgb(148 163 184);
--theme-text-inverse: rgb(255 255 255);
--theme-border-default: rgb(203 213 225);
--theme-border-subtle: rgb(226 232 240);
--theme-border-strong: rgb(148 163 184);
--theme-state-success: rgb(34 197 94);
--theme-state-warning: rgb(251 146 60);
--theme-state-error: rgb(239 68 68);
--theme-state-info: rgb(59 130 246);
--theme-interactive-hover: rgb(248 250 252);
--theme-interactive-active: rgb(241 245 249);
--theme-interactive-focus: rgb(139 92 246);
--theme-interactive-disabled: rgb(226 232 240);
}
/* CSS variables are now dynamically updated by the theme store */
/* No need for static theme definitions here anymore */

View file

@ -0,0 +1,208 @@
export type NodeKind = 'world' | 'character' | 'object' | 'place' | 'story';
export type VisibilityLevel = 'private' | 'shared' | 'public';
export type StoryEntryType = 'narration' | 'dialog' | 'note';
export interface GenerationContext {
userPrompt: string;
systemPrompt: string;
worldContext?: string;
selectedCharacters?: Array<{
name: string;
slug: string;
summary?: string;
appearance?: string;
voice_style?: string;
motivations?: string;
capabilities?: string;
}>;
model: string;
timestamp: string;
}
export interface ContentNode {
id: string;
kind: NodeKind;
slug: string;
title: string;
summary?: string;
owner_id?: string;
visibility: VisibilityLevel;
tags: string[];
world_slug?: string;
content: ContentData;
memory?: CharacterMemory;
skills?: CharacterSkills;
custom_schema?: any; // Will be CustomFieldSchema from customFields.ts
custom_data?: Record<string, any>; // CustomFieldData
schema_version?: number;
generation_prompt?: string;
generation_model?: string;
generation_date?: string;
generation_context?: GenerationContext;
image_url?: string;
created_at: string;
updated_at: string;
}
export interface ContentData {
appearance?: string;
image_prompt?: string;
lore?: string;
voice_style?: string;
capabilities?: string;
constraints?: string;
motivations?: string;
secrets?: string;
relationships_text?: string;
inventory_text?: string;
timeline_text?: string;
glossary_text?: string;
canon_facts_text?: string;
state_text?: string;
prompt_guidelines?: string;
references?: string;
_links?: Record<string, string[]>;
_aliases?: string[];
_i18n?: Record<string, any>;
}
export interface StoryEntry {
id: string;
story_slug: string;
position: number;
type: StoryEntryType;
speaker_slug?: string;
body: string;
created_by?: string;
created_at: string;
}
export interface PromptTemplate {
id: string;
owner_id?: string;
world_slug?: string;
kind: NodeKind;
title: string;
prompt_template: string;
description?: string;
tags?: string[];
usage_count: number;
is_public: boolean;
created_at: string;
updated_at: string;
}
export interface PromptHistory {
id: string;
user_id: string;
node_id: string;
prompt: string;
response?: any;
model?: string;
created_at: string;
}
// Memory System Types
export interface ShortTermMemory {
id: string;
timestamp: string;
content: string;
location?: string;
involved?: string[];
tags?: string[];
importance: number;
decay_at: string;
}
export interface MediumTermMemory {
id: string;
timestamp: string;
content: string;
original_details?: string;
context?: string;
location?: string;
involved?: string[];
tags?: string[];
importance: number;
decay_at: string;
linked_memories?: string[];
}
export interface LongTermMemory {
id: string;
timestamp: string;
content: string;
emotional_weight: number;
category: 'trauma' | 'triumph' | 'relationship' | 'skill' | 'secret' | 'manual';
triggers?: string[];
effects?: string;
involved?: string[];
immutable: boolean;
}
export interface MemoryTraits {
memory_quality: 'excellent' | 'good' | 'average' | 'poor';
trauma_filter?: boolean;
selective_memory?: string[];
memory_conditions?: {
drunk?: 'partial_blackout' | 'full_blackout' | 'fuzzy';
stressed?: 'detail_loss' | 'time_gaps';
happy?: 'enhanced_positive' | 'forget_negative';
};
}
export interface CharacterMemory {
short_term_memory: ShortTermMemory[];
medium_term_memory: MediumTermMemory[];
long_term_memory: LongTermMemory[];
memory_traits: MemoryTraits;
last_processed?: string;
}
// Skills System Types
export interface Skill {
name: string;
level: number;
level_text?: string;
subskills?: Record<string, string>;
learned_from?: string;
learned_at?: string;
training_years?: number;
last_used?: string;
conditions?: Record<string, number>;
}
export interface LearningSkill {
name: string;
progress: number;
teacher?: string;
started: string;
blocked_by?: string;
next_milestone?: string;
}
export interface SkillCondition {
trigger: string;
effect: string;
}
export interface CharacterSkills {
primary: Skill[];
learning: LearningSkill[];
conditions: Record<string, SkillCondition>;
}
// Memory Event for story integration
export interface MemoryEvent {
id: string;
node_id: string;
story_id?: string;
event_timestamp: string;
event_type: 'observed' | 'experienced' | 'told' | 'dreamed' | 'remembered';
raw_event: string;
processed_memory?: any;
memory_tier?: 'short' | 'medium' | 'long';
importance?: number;
created_at: string;
updated_at?: string;
}

View file

@ -0,0 +1,269 @@
// Custom Fields System Types
export type FieldType =
| 'text' // Simple text input
| 'number' // Numeric input
| 'range' // Slider between min/max
| 'select' // Single selection dropdown
| 'multiselect' // Multiple selection
| 'boolean' // Yes/No checkbox
| 'date' // Date picker
| 'formula' // Calculated field
| 'reference' // Reference to another node
| 'list' // Array of values
| 'json'; // Structured JSON data
export interface FieldConfig {
// For number/range types
min?: number;
max?: number;
step?: number;
default?: number;
unit?: string;
// For select/multiselect
choices?: Array<{
value: string;
label: string;
color?: string;
}>;
// For text
multiline?: boolean;
maxLength?: number;
pattern?: string; // regex pattern
placeholder?: string;
// For formula
formula?: string;
dependencies?: string[]; // field keys this formula depends on
// For reference
reference_type?: 'character' | 'object' | 'place' | 'story' | 'world';
multiple?: boolean;
// For list
item_type?: FieldType;
max_items?: number;
min_items?: number;
}
export interface DisplayConfig {
width?: 'full' | 'half' | 'third' | 'quarter';
hidden?: boolean;
readonly?: boolean;
help_text?: string;
prefix?: string;
suffix?: string;
icon?: string;
color?: string;
}
export interface ValidationRule {
type: 'required' | 'min' | 'max' | 'pattern' | 'custom';
value?: any;
message?: string;
condition?: string; // condition when this rule applies
}
export interface FieldPermissions {
view?: 'owner' | 'collaborator' | 'public';
edit?: 'owner' | 'collaborator';
}
export interface CustomFieldDefinition {
id: string;
key: string; // Unique key for the field (e.g., "strength")
label: string; // Display name (e.g., "Stärke")
type: FieldType;
category?: string; // For grouping fields
description?: string;
required?: boolean;
config: FieldConfig;
display?: DisplayConfig;
validation?: ValidationRule[];
permissions?: FieldPermissions;
order?: number; // Display order
}
export interface FieldCategory {
id: string;
name: string;
description?: string;
icon?: string;
color?: string;
collapsed?: boolean; // Default collapsed state
order?: number;
}
export interface CustomFieldSchema {
version: number;
fields: CustomFieldDefinition[];
categories?: FieldCategory[];
validation_rules?: ValidationRule[];
template_id?: string; // If created from a template
template_version?: string;
}
export interface CustomFieldTemplate {
id: string;
slug: string;
name: string;
description?: string;
category: 'official' | 'community' | 'personal';
tags: string[];
applicable_to: Array<'character' | 'object' | 'place' | 'story' | 'world'>;
fields: CustomFieldDefinition[];
example_data?: Record<string, any>;
author_id?: string;
world_slug?: string;
version: string;
dependencies?: string[]; // Other template slugs
usage_count: number;
is_public: boolean;
created_at: string;
updated_at: string;
}
// Custom field data is a simple key-value object
export type CustomFieldData = Record<string, any>;
// Validation result
export interface ValidationResult {
valid: boolean;
errors: Array<{
field: string;
message: string;
rule?: string;
}>;
warnings?: Array<{
field: string;
message: string;
}>;
}
// Formula evaluation context
export interface FormulaContext {
fields: CustomFieldData;
node?: any; // Current node data
world?: any; // World context
references?: Record<string, any>; // Referenced nodes
}
// Field change event
export interface FieldChangeEvent {
field: string;
oldValue: any;
newValue: any;
timestamp: string;
triggeredBy?: string; // Which field triggered this change (for formulas)
}
// Helper type for field values
export type FieldValue<T extends FieldType> = T extends 'text'
? string
: T extends 'number'
? number
: T extends 'range'
? number
: T extends 'select'
? string
: T extends 'multiselect'
? string[]
: T extends 'boolean'
? boolean
: T extends 'date'
? string
: T extends 'formula'
? any
: T extends 'reference'
? string | string[]
: T extends 'list'
? any[]
: T extends 'json'
? any
: any;
// Schema builder helper types
export interface SchemaBuilder {
addField(field: Omit<CustomFieldDefinition, 'id'>): SchemaBuilder;
addCategory(category: FieldCategory): SchemaBuilder;
removeField(key: string): SchemaBuilder;
updateField(key: string, updates: Partial<CustomFieldDefinition>): SchemaBuilder;
reorderFields(order: string[]): SchemaBuilder;
build(): CustomFieldSchema;
}
// Template filters for browsing
export interface TemplateFilter {
category?: 'official' | 'community' | 'personal';
tags?: string[];
applicable_to?: Array<'character' | 'object' | 'place' | 'story' | 'world'>;
author_id?: string;
world_slug?: string;
search?: string;
is_public?: boolean;
sort_by?: 'usage_count' | 'created_at' | 'updated_at' | 'name';
sort_order?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
// Export utility functions
export function createEmptySchema(): CustomFieldSchema {
return {
version: 1,
fields: [],
categories: [],
validation_rules: [],
};
}
export function createFieldDefinition(
key: string,
label: string,
type: FieldType,
config?: Partial<FieldConfig>
): CustomFieldDefinition {
return {
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
key,
label,
type,
config: config || {},
required: false,
};
}
export function validateFieldKey(key: string): boolean {
// Must be lowercase, alphanumeric with underscores, no spaces
return /^[a-z][a-z0-9_]*$/.test(key);
}
export function getDefaultValueForType(type: FieldType, config?: FieldConfig): any {
switch (type) {
case 'text':
return '';
case 'number':
case 'range':
return config?.default ?? config?.min ?? 0;
case 'select':
return config?.choices?.[0]?.value ?? '';
case 'multiselect':
return [];
case 'boolean':
return false;
case 'date':
return new Date().toISOString().split('T')[0];
case 'list':
return [];
case 'json':
return {};
case 'reference':
return config?.multiple ? [] : null;
case 'formula':
return null;
default:
return null;
}
}

View file

@ -0,0 +1,174 @@
// Logger utility für API-Calls und Debugging
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
class Logger {
private level: LogLevel = LogLevel.INFO;
private prefix: string;
constructor(prefix: string = 'Worldream') {
this.prefix = prefix;
// In Dev-Modus mehr loggen
if (process.env.NODE_ENV === 'development') {
this.level = LogLevel.DEBUG;
}
}
private formatMessage(level: string, message: string, data?: any): string {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${this.prefix}] [${level}] ${message}`;
}
private logWithData(level: string, message: string, data?: any) {
const formattedMessage = this.formatMessage(level, message);
if (data) {
console.log(formattedMessage, data);
} else {
console.log(formattedMessage);
}
}
debug(message: string, data?: any) {
if (this.level <= LogLevel.DEBUG) {
this.logWithData('DEBUG', message, data);
}
}
info(message: string, data?: any) {
if (this.level <= LogLevel.INFO) {
this.logWithData('INFO', message, data);
}
}
warn(message: string, data?: any) {
if (this.level <= LogLevel.WARN) {
console.warn(this.formatMessage('WARN', message), data || '');
}
}
error(message: string, error?: any) {
if (this.level <= LogLevel.ERROR) {
console.error(this.formatMessage('ERROR', message), error || '');
}
}
// Spezielle Methoden für API-Logging
apiRequest(service: string, endpoint: string, params: any) {
this.info(`API Request: ${service} - ${endpoint}`, {
service,
endpoint,
params: this.sanitizeParams(params),
});
}
apiResponse(service: string, endpoint: string, response: any, duration: number) {
this.info(`API Response: ${service} - ${endpoint} (${duration}ms)`, {
service,
endpoint,
duration,
response: this.sanitizeResponse(response),
});
}
apiError(service: string, endpoint: string, error: any, duration?: number) {
this.error(`API Error: ${service} - ${endpoint}${duration ? ` (${duration}ms)` : ''}`, {
service,
endpoint,
duration,
error: error.message || error,
stack: error.stack,
});
}
// Entfernt sensitive Daten aus Params
private sanitizeParams(params: any): any {
if (!params) return params;
const sanitized = { ...params };
// API Keys verstecken
if (sanitized.apiKey) {
sanitized.apiKey = '***HIDDEN***';
}
// Lange Texte kürzen
if (sanitized.messages) {
sanitized.messages = sanitized.messages.map((msg: any) => ({
...msg,
content:
msg.content?.length > 200
? msg.content.substring(0, 200) + '...[TRUNCATED]'
: msg.content,
}));
}
if (sanitized.prompt && sanitized.prompt.length > 200) {
sanitized.prompt = sanitized.prompt.substring(0, 200) + '...[TRUNCATED]';
}
return sanitized;
}
// Kürzt lange Responses
private sanitizeResponse(response: any): any {
if (!response) return response;
if (typeof response === 'string' && response.length > 500) {
return response.substring(0, 500) + '...[TRUNCATED]';
}
if (response.content && typeof response.content === 'string' && response.content.length > 500) {
return {
...response,
content: response.content.substring(0, 500) + '...[TRUNCATED]',
};
}
if (response.choices) {
return {
...response,
choices: response.choices.map((choice: any) => ({
...choice,
message: choice.message
? {
...choice.message,
content:
choice.message.content?.length > 500
? choice.message.content.substring(0, 500) + '...[TRUNCATED]'
: choice.message.content,
}
: choice.message,
})),
};
}
return response;
}
// Timer für Performance-Messung
startTimer(label: string): () => number {
const start = Date.now();
this.debug(`Timer started: ${label}`);
return () => {
const duration = Date.now() - start;
this.debug(`Timer ended: ${label} - ${duration}ms`);
return duration;
};
}
}
// Singleton-Instanzen für verschiedene Module
export const apiLogger = new Logger('API');
export const aiLogger = new Logger('AI');
export const dbLogger = new Logger('DB');
export const appLogger = new Logger('APP');
// Default export
export default Logger;

View file

@ -0,0 +1,191 @@
import { marked } from 'marked';
import {
extractReferences,
fetchReferences,
replaceReferences,
type ReferenceData,
} from '$lib/services/referenceResolver';
// Configure marked for safe rendering
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub Flavored Markdown
pedantic: false,
sanitize: false, // We'll trust our own content
});
/**
* Render markdown to HTML with smart @reference display
* This is the async version that fetches real names
*/
export async function renderMarkdownSmart(
text: string,
context?: { characters?: any[]; place?: any }
): Promise<string> {
if (!text) return '';
console.log('🎨 renderMarkdownSmart input:', text.substring(0, 200));
// Handle REF_X placeholders if they exist (for backward compatibility)
let processedText = text;
if (/REF_\d+/.test(text) && context) {
console.warn('⚠️ Found REF_X placeholders - attempting to fix them...');
// Build mapping from context
const refMapping: Record<string, string> = {};
let refIndex = 0;
// Add characters
if (context.characters) {
context.characters.forEach((char: any) => {
if (char.slug) {
refMapping[`REF_${refIndex}`] = `@${char.slug}`;
console.log(`Mapping REF_${refIndex} → @${char.slug}`);
refIndex++;
}
});
}
// Add place
if (context.place?.slug) {
refMapping[`REF_${refIndex}`] = `@${context.place.slug}`;
console.log(`Mapping REF_${refIndex} → @${context.place.slug}`);
}
// Replace all REF_X with mapped values
for (const [ref, replacement] of Object.entries(refMapping)) {
processedText = processedText.replace(new RegExp(ref, 'g'), replacement);
}
console.log('Fixed text:', processedText.substring(0, 200));
}
// 1. Extract all @references
const slugs = extractReferences(processedText);
console.log('📝 Found slugs in text:', slugs);
// 2. Fetch reference data (with caching)
const references = slugs.length > 0 ? await fetchReferences(slugs) : new Map();
console.log('📚 Fetched references:', Array.from(references.entries()));
// 3. Temporarily protect references from markdown processing
const placeholders: string[] = [];
let protectedText = processedText.replace(/@([\w-]+)/g, (match) => {
placeholders.push(match);
return `__MDREF_${placeholders.length - 1}_MDREF__`;
});
// 4. Render markdown
let html = marked(protectedText);
// 5. Restore references with smart display
placeholders.forEach((ref, index) => {
const slug = ref.substring(1);
const data = references.get(slug);
if (data) {
// Use real name from database
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium" data-kind="${data.kind}">${data.title}</a>`
);
} else {
// Fallback: format slug nicely
const displayName = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium opacity-75">${displayName}</a>`
);
}
});
return html;
}
/**
* Immediate markdown rendering (without async lookup)
* Uses simple slug formatting as fallback
*/
export function renderMarkdown(text: string): string {
if (!text) return '';
// First, temporarily replace @references to protect them from markdown
const references: string[] = [];
let protectedText = text.replace(/@([\w-]+)/g, (match) => {
references.push(match);
return `__MDREF_${references.length - 1}_MDREF__`;
});
// Render markdown
let html = marked(protectedText);
// Restore @references as links with formatted names
references.forEach((ref, index) => {
const slug = ref.substring(1);
// Simple formatting: finn-zahnrad → Finn Zahnrad
const displayName = slug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
html = html.replace(
`__MDREF_${index}_MDREF__`,
`<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium">${displayName}</a>`
);
});
return html;
}
/**
* Parse @references in plain text (non-markdown)
* This is the async version that fetches real names
*/
export async function parseReferencesSmart(text: string | undefined): Promise<string> {
if (!text) return '';
// Check if text contains markdown formatting
const hasMarkdown = /[#*_`~\[\]]/.test(text);
if (hasMarkdown) {
// Use full markdown rendering with smart display
return renderMarkdownSmart(text);
} else {
// Simple reference parsing for plain text
const slugs = extractReferences(text);
const references = slugs.length > 0 ? await fetchReferences(slugs) : new Map();
return replaceReferences(text, references, {
linkClass: 'text-theme-primary-600 hover:text-theme-primary-500 font-medium',
});
}
}
/**
* Parse @references and create links (immediate version)
*/
export function parseReferences(text: string | undefined): string {
if (!text) return '';
// Check if text contains markdown formatting
const hasMarkdown = /[#*_`~\[\]]/.test(text);
if (hasMarkdown) {
// Use full markdown rendering
return renderMarkdown(text);
} else {
// Simple reference parsing for plain text with formatted names
return text.replace(/@([\w-]+)/g, (match, slug) => {
const displayName = slug
.split('-')
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return `<a href="/${slug}" class="text-theme-primary-600 hover:text-theme-primary-500 font-medium">${displayName}</a>`;
});
}
}

View file

@ -0,0 +1,38 @@
/**
* Extracts @mentions from text
* @param text - The text to search for mentions
* @returns Array of slugs mentioned in the text
*/
export function extractMentions(text: string | undefined): string[] {
if (!text) return [];
const regex = /@([\w-]+)/g;
const matches = [...text.matchAll(regex)];
return [...new Set(matches.map((m) => m[1]))]; // Remove duplicates
}
/**
* Parses text and converts @mentions to clickable links
* @param text - The text containing @mentions
* @param baseUrl - Base URL for links (default: '/')
* @returns HTML string with clickable mentions
*/
export function parseReferences(text: string | undefined, baseUrl: string = '/'): string {
if (!text) return '';
return text.replace(
/@([\w-]+)/g,
`<a href="${baseUrl}$1" class="text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300">@$1</a>`
);
}
/**
* Checks if a text mentions a specific slug
* @param text - The text to search in
* @param slug - The slug to search for
* @returns true if the slug is mentioned
*/
export function hasMention(text: string | undefined, slug: string): boolean {
if (!text) return false;
return extractMentions(text).includes(slug);
}

View file

@ -0,0 +1,10 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
return {
session,
user,
};
};

View file

@ -0,0 +1,297 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/stores';
import { createClient } from '$lib/supabase/client';
import { invalidateAll, goto } from '$app/navigation';
import { onMount, onDestroy } from 'svelte';
import { currentWorld } from '$lib/stores/worldContext';
import { theme } from '$lib/themes/themeStore';
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import LoadingOverlay from '$lib/components/LoadingOverlay.svelte';
import GlobalAiAuthorBar from '$lib/components/GlobalAiAuthorBar.svelte';
let { children, data } = $props();
const supabase = createClient();
let navHidden = $state(false);
// Check if we're on a world or place detail page that needs transparent background
let isTransparentPage = $derived(
(() => {
const path = $page.url.pathname;
// Check for world detail pages: /worlds/[slug]
if (path.match(/^\/worlds\/[^\/]+$/)) {
return true;
}
// Check for place detail pages: /worlds/[world]/places/[slug]
if (path.match(/^\/worlds\/[^\/]+\/places\/[^\/]+$/)) {
return true;
}
return false;
})()
);
$effect(() => {
// Set transparent background on body for world/place detail pages
if (typeof document !== 'undefined') {
if (isTransparentPage) {
document.body.style.backgroundColor = 'transparent';
document.documentElement.style.backgroundColor = 'transparent';
} else {
document.body.style.backgroundColor = '';
document.documentElement.style.backgroundColor = '';
}
}
});
$effect(() => {
// Extract world slug from URL if present
const pathSegments = $page.url.pathname.split('/');
if (pathSegments[1] === 'worlds' && pathSegments[2] && pathSegments[2] !== 'new') {
// We're in a world context, ensure it's set
const worldSlug = pathSegments[2];
if (!$currentWorld || $currentWorld.slug !== worldSlug) {
// Load world data if not in store
loadWorld(worldSlug);
}
}
});
async function loadWorld(slug: string) {
const response = await fetch(`/api/nodes/${slug}`);
if (response.ok) {
const world = await response.json();
if (world.kind === 'world') {
currentWorld.setWorld(world);
}
}
}
function exitWorld() {
currentWorld.clearWorld();
goto('/');
}
onMount(() => {
theme.init();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange(() => {
invalidateAll();
});
// Auto-hide navigation on scroll for all pages
let lastScrollY = window.scrollY;
let ticking = false;
let mouseTimer: number | null = null;
function handleScroll() {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// Different behavior for transparent vs normal pages
if (isTransparentPage) {
// Hide nav when at top of page (to show full image)
if (currentScrollY < 100) {
navHidden = true;
} else {
navHidden = false;
}
} else {
// Hide nav when scrolling down, show when scrolling up
if (currentScrollY > lastScrollY && currentScrollY > 100) {
navHidden = true;
} else {
navHidden = false;
}
}
lastScrollY = currentScrollY;
ticking = false;
});
ticking = true;
}
}
function handleMouseMove(e: MouseEvent) {
// Show nav when mouse is near top of screen
if (e.clientY < 100) {
navHidden = false;
// Clear existing timer
if (mouseTimer) {
clearTimeout(mouseTimer);
}
// Hide again after 3 seconds if conditions are met
mouseTimer = window.setTimeout(() => {
const currentScrollY = window.scrollY;
if (isTransparentPage && currentScrollY < 100) {
navHidden = true;
} else if (!isTransparentPage && currentScrollY > 100) {
navHidden = true;
}
}, 3000);
}
}
window.addEventListener('scroll', handleScroll);
window.addEventListener('mousemove', handleMouseMove);
return () => {
subscription.unsubscribe();
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('mousemove', handleMouseMove);
if (mouseTimer) {
clearTimeout(mouseTimer);
}
};
});
onDestroy(() => {
// Reset background when component is destroyed
if (typeof document !== 'undefined') {
document.body.style.backgroundColor = '';
document.documentElement.style.backgroundColor = '';
}
});
// Navigation changes based on world context
let navigation = $derived(
$currentWorld
? [
{ name: 'Stories', href: `/worlds/${$currentWorld.slug}/stories`, kind: 'story' },
{
name: 'Charaktere',
href: `/worlds/${$currentWorld.slug}/characters`,
kind: 'character',
},
{ name: 'Orte', href: `/worlds/${$currentWorld.slug}/places`, kind: 'place' },
{ name: 'Objekte', href: `/worlds/${$currentWorld.slug}/objects`, kind: 'object' },
{ name: 'Welt', href: `/worlds/${$currentWorld.slug}`, kind: 'world' },
]
: [{ name: 'Welten', href: '/', kind: 'world' }]
);
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div
class="min-h-screen {isTransparentPage ? 'bg-transparent' : 'bg-theme-base'} transition-colors"
>
<nav
class="fixed top-0 left-0 right-0 z-50 border-b border-theme-border-subtle bg-theme-surface shadow-sm transition-all duration-300 {navHidden
? '-translate-y-full'
: 'translate-y-0'}"
>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<a href="/" class="text-xl font-bold text-theme-primary-600">Worldream</a>
{#if $currentWorld}
<span class="ml-2 text-theme-text-tertiary">/</span>
<span class="ml-2 text-lg font-semibold text-theme-text-primary"
>{$currentWorld.title}</span
>
{/if}
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
{#each navigation as item}
<a
href={item.href}
class="inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium {// Exact match
$page.url.pathname === item.href ||
// For non-world items, check if path starts with href
(item.kind !== 'world' && $page.url.pathname.startsWith(item.href + '/'))
? 'border-theme-primary-500 text-theme-text-primary'
: 'border-transparent text-theme-text-secondary hover:border-theme-border-subtle hover:text-theme-text-primary'}"
>
{item.name}
</a>
{/each}
</div>
</div>
<div class="flex items-center space-x-4">
{#if $currentWorld}
<button
onclick={exitWorld}
class="border-theme-border-default inline-flex items-center rounded-md border bg-theme-surface px-3 py-1 text-sm font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
/>
</svg>
Welt verlassen
</button>
{/if}
<!-- Theme Switcher -->
<ThemeSwitcher />
{#if data.user}
<a
href="/database"
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Datenbankstruktur"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
</a>
<a
href="/settings"
class="rounded-lg p-2 text-theme-text-secondary transition-colors hover:bg-theme-interactive-hover hover:text-theme-text-primary"
aria-label="Einstellungen"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</a>
{:else}
<a
href="/auth/login"
class="text-sm text-theme-primary-600 hover:text-theme-primary-500"
>
Anmelden
</a>
{/if}
</div>
</div>
</div>
</nav>
<!-- Nav spacer for all pages since nav is fixed -->
<div class="h-16"></div>
<main class={isTransparentPage ? '' : 'mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8'}>
{@render children?.()}
</main>
<LoadingOverlay />
<GlobalAiAuthorBar />
</div>

View file

@ -0,0 +1,262 @@
<script lang="ts">
import type { ContentNode } from '$lib/types/content';
import { currentWorld } from '$lib/stores/worldContext';
import { goto } from '$app/navigation';
let { data } = $props();
let worlds = $state<ContentNode[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
async function loadWorlds() {
if (!data.user) {
loading = false;
return;
}
try {
const response = await fetch('/api/nodes?kind=world');
if (!response.ok) throw new Error('Failed to load worlds');
worlds = await response.json();
} catch (err) {
error = err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten';
} finally {
loading = false;
}
}
function enterWorld(world: ContentNode) {
currentWorld.setWorld(world);
goto(`/worlds/${world.slug}`);
}
$effect(() => {
loadWorlds();
});
</script>
<div class="flex min-h-[80vh] flex-col">
<!-- Hero Section -->
<div
class="bg-gradient-to-br from-theme-primary-700 via-theme-primary-600 to-theme-primary-800 text-theme-inverse"
>
<div class="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="mb-4 text-5xl font-bold">Willkommen bei Worldream</h1>
<p class="mx-auto max-w-2xl text-xl text-theme-primary-100">
Erschaffe und erkunde fantastische Welten. Wähle eine Welt aus oder erstelle eine neue, um
deine Geschichten zum Leben zu erwecken.
</p>
</div>
</div>
</div>
{#if !data.user}
<!-- Not logged in -->
<div class="flex flex-1 items-center justify-center bg-theme-base">
<div class="text-center">
<svg
class="mx-auto h-24 w-24 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h2 class="mt-6 text-2xl font-semibold text-theme-text-primary">
Bereit, deine eigenen Welten zu erschaffen?
</h2>
<p class="mt-2 text-theme-text-secondary">
Melde dich an, um deine kreativen Ideen zum Leben zu erwecken.
</p>
<div class="mt-6 space-x-4">
<a
href="/auth/login"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-6 py-3 text-base font-medium text-theme-inverse hover:bg-theme-primary-700"
>
Anmelden
</a>
<a
href="/auth/login"
class="border-theme-border-default inline-flex items-center rounded-md border bg-theme-surface px-6 py-3 text-base font-medium text-theme-text-primary hover:bg-theme-interactive-hover"
>
Registrieren
</a>
</div>
</div>
</div>
{:else if loading}
<!-- Loading -->
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<div
class="mx-auto h-12 w-12 animate-spin rounded-full border-b-2 border-theme-primary-600"
></div>
<p class="mt-4 text-theme-text-secondary">Lade deine Welten...</p>
</div>
</div>
{:else if error}
<!-- Error -->
<div class="flex flex-1 items-center justify-center">
<div class="rounded-md bg-red-50/50 p-6">
<p class="text-sm text-theme-error">{error}</p>
</div>
</div>
{:else}
<!-- Worlds Grid -->
<div class="flex-1 bg-theme-base py-12">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-8 flex items-center justify-between">
<h2 class="text-2xl font-bold text-theme-text-primary">
{worlds.length > 0 ? 'Wähle eine Welt' : 'Erstelle deine erste Welt'}
</h2>
<a
href="/worlds/new"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-4 py-2 text-sm font-medium text-theme-inverse shadow-sm hover:bg-theme-primary-700"
>
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Neue Welt
</a>
</div>
{#if worlds.length === 0}
<div class="py-12 text-center">
<svg
class="mx-auto h-24 w-24 text-theme-text-tertiary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="mt-6 text-lg font-medium text-theme-text-primary">
Noch keine Welten vorhanden
</h3>
<p class="mt-2 text-sm text-theme-text-secondary">
Beginne dein Abenteuer, indem du deine erste Welt erschaffst.
</p>
<div class="mt-6">
<a
href="/worlds/new"
class="inline-flex items-center rounded-md border border-transparent bg-theme-primary-600 px-6 py-3 text-base font-medium text-theme-inverse hover:bg-theme-primary-700"
>
<svg class="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Erste Welt erschaffen
</a>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each worlds as world}
<button
onclick={() => enterWorld(world)}
class="group relative transform overflow-hidden rounded-lg bg-theme-surface text-left shadow-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
>
<!-- World Card Background with Image -->
<div
class="relative h-48 overflow-hidden bg-gradient-to-br from-theme-primary-500 to-theme-primary-600"
>
{#if world.image_url}
<img
src={world.image_url}
alt={world.title}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
<div
class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"
></div>
{:else}
<!-- Fallback pattern for worlds without images -->
<div class="absolute inset-0 opacity-10">
<svg class="h-full w-full" viewBox="0 0 100 100" fill="currentColor">
<pattern
id="world-pattern-{world.id}"
patternUnits="userSpaceOnUse"
width="20"
height="20"
>
<circle cx="10" cy="10" r="1.5" />
</pattern>
<rect width="100" height="100" fill="url(#world-pattern-{world.id})" />
</svg>
</div>
{/if}
</div>
<!-- World Content -->
<div class="p-6">
<h3
class="text-xl font-bold text-theme-text-primary transition-colors group-hover:text-theme-primary-600"
>
{world.title}
</h3>
{#if world.summary}
<p class="mt-2 line-clamp-2 text-sm text-theme-text-secondary">
{world.summary}
</p>
{/if}
<!-- World Stats -->
<div class="mt-4 flex items-center justify-between">
<div class="flex space-x-2">
{#if world.tags && world.tags.length > 0}
{#each world.tags.slice(0, 2) as tag}
<span
class="inline-flex items-center rounded-full bg-theme-primary-100 px-2.5 py-0.5 text-xs font-medium text-theme-primary-700"
>
{tag}
</span>
{/each}
{/if}
</div>
<span class="inline-flex items-center text-sm text-theme-text-secondary">
<svg
class="mr-1 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
Betreten
</span>
</div>
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,74 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { editContentWithAI } from '$lib/ai/editing';
import type { ContentNode } from '$lib/types/content';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { nodeSlug, command } = await request.json();
if (!nodeSlug || !command) {
return json({ error: 'Missing required fields: nodeSlug, command' }, { status: 400 });
}
if (typeof command !== 'string' || command.trim().length === 0) {
return json({ error: 'Command must be a non-empty string' }, { status: 400 });
}
const supabase = locals.supabase;
// Get current node data
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', nodeSlug)
.single();
if (fetchError || !node) {
return json({ error: 'Node not found' }, { status: 404 });
}
// Check ownership
if (node.owner_id !== session.user.id) {
return json({ error: 'Forbidden: You do not own this content' }, { status: 403 });
}
// Use AI to generate updates
const updates = await editContentWithAI({
node: node as ContentNode,
command: command.trim(),
});
// Apply updates to database
const { data: updatedNode, error: updateError } = await supabase
.from('content_nodes')
.update(updates)
.eq('slug', nodeSlug)
.select()
.single();
if (updateError) {
console.error('Database update failed:', updateError);
return json({ error: 'Failed to update content' }, { status: 500 });
}
return json({
success: true,
updatedNode,
appliedUpdates: updates,
});
} catch (error) {
console.error('AI editing failed:', error);
if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
}
return json({ error: 'Internal server error' }, { status: 500 });
}
};

View file

@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { enhanceContent } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { content, kind, instruction } = await request.json();
if (!content || !kind || !instruction) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const enhanced = await enhanceContent(content, kind, instruction);
return json({ content: enhanced });
} catch (error) {
console.error('AI enhancement error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to enhance content',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,139 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateImageWithFlux } from '$lib/ai/replicate-flux';
import { translateToImagePrompt } from '$lib/ai/openai';
import { createClient } from '$lib/supabase/server';
import { uploadImage } from '$lib/storage/images';
import type { NodeKind } from '$lib/types/content';
export const POST: RequestHandler = async (event) => {
const { request } = event;
try {
const supabase = createClient(event);
// Prüfe Authentifizierung
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return json({ error: 'Nicht authentifiziert' }, { status: 401 });
}
const body = await request.json();
const {
kind,
title,
description,
style = 'fantasy',
context,
nodeId,
aspectRatio,
imagePrompt,
} = body as {
kind: NodeKind;
title: string;
description?: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
context?: any;
nodeId?: string;
aspectRatio?: string;
imagePrompt?: string;
};
if (!kind || !title) {
return json({ error: 'Kind und Title sind erforderlich' }, { status: 400 });
}
// Bestimme die beste Beschreibung für die Bildgenerierung
let finalDescription = description;
// 1. Nutze vorhandenen imagePrompt falls vorhanden
if (imagePrompt) {
finalDescription = imagePrompt;
}
// 2. Falls deutsche Beschreibung vorhanden, übersetze sie
else if (context?.appearance && context.appearance.length > 10) {
try {
console.log('Übersetze deutsche Beschreibung ins Englische...');
finalDescription = await translateToImagePrompt(context.appearance, kind, title, style);
console.log('Übersetzung erfolgreich:', finalDescription.substring(0, 100) + '...');
} catch (error) {
console.warn('Übersetzung fehlgeschlagen, verwende deutsche Beschreibung:', error);
finalDescription = context.appearance;
}
}
// Generiere Bild mit Flux Schnell über Replicate
const result = await generateImageWithFlux({
kind,
title,
description: finalDescription,
style,
context: {
...context,
// Überschreibe appearance mit übersetzter Version
appearance: finalDescription,
},
aspectRatio,
});
// Wenn ein Bild generiert wurde, speichere es in Supabase
let uploadedImageUrl = null;
if (result.imageUrl) {
try {
let imageBlob: Blob;
// Prüfe ob es Base64 oder eine URL ist
if (result.imageUrl.startsWith('data:')) {
// Base64 zu Blob konvertieren
const base64Data = result.imageUrl.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
imageBlob = new Blob([byteArray], { type: 'image/png' });
} else {
// Lade das Bild von der URL herunter
const imageResponse = await fetch(result.imageUrl);
imageBlob = await imageResponse.blob();
}
// Generiere eine temporäre nodeId falls keine vorhanden
const tempNodeId = nodeId || `temp-${Date.now()}`;
const uploadResult = await uploadImage(
supabase,
user.id,
tempNodeId,
imageBlob,
`${title.toLowerCase().replace(/\s+/g, '-')}.png`
);
if (uploadResult) {
uploadedImageUrl = uploadResult.url;
}
} catch (uploadError) {
console.error('Fehler beim Hochladen des Bildes:', uploadError);
// Gebe trotzdem die Original-URL zurück
uploadedImageUrl = result.imageUrl;
}
}
return json({
success: true,
imageUrl: uploadedImageUrl || result.imageUrl || null,
prompt: result.prompt,
message: uploadedImageUrl
? 'Bild erfolgreich generiert und gespeichert'
: result.imageUrl
? 'Bild generiert (temporäre URL)'
: 'Bildgenerierung fehlgeschlagen',
});
} catch (error) {
console.error('Fehler bei Bildgenerierung:', error);
return json({ error: 'Fehler bei der Bildgenerierung' }, { status: 500 });
}
};

View file

@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateContent } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { kind, prompt, context, node_id } = await request.json();
if (!kind || !prompt) {
return json({ error: 'Missing required fields: kind and prompt' }, { status: 400 });
}
const result = await generateContent({
kind,
prompt,
context,
});
// Optionally save to prompt history if node_id is provided
if (node_id && locals.supabase) {
await locals.supabase.from('prompt_history').insert({
user_id: session.user.id,
node_id,
prompt,
response: result,
model: 'gpt-5-mini',
});
}
return json(result);
} catch (error) {
console.error('AI generation error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to generate content',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,36 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { generateSuggestions } from '$lib/ai/openai';
import { OPENAI_API_KEY } from '$env/static/private';
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (!OPENAI_API_KEY) {
return json({ error: 'OpenAI API key not configured' }, { status: 500 });
}
try {
const { field, context } = await request.json();
if (!field || !context?.kind) {
return json({ error: 'Missing required fields' }, { status: 400 });
}
const suggestions = await generateSuggestions(field, context);
return json({ suggestions });
} catch (error) {
console.error('AI suggestion error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Failed to generate suggestions',
},
{ status: 500 }
);
}
};

View file

@ -0,0 +1,58 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { translateToImagePrompt } from '$lib/ai/openai';
import { createClient } from '$lib/supabase/server';
import type { NodeKind } from '$lib/types/content';
export const POST: RequestHandler = async (event) => {
const { request } = event;
try {
const supabase = createClient(event);
// Prüfe Authentifizierung
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return json({ error: 'Nicht authentifiziert' }, { status: 401 });
}
const body = await request.json();
const {
germanDescription,
kind,
title,
style = 'fantasy',
} = body as {
germanDescription: string;
kind: NodeKind;
title: string;
style?: 'realistic' | 'fantasy' | 'anime' | 'concept-art' | 'illustration';
};
if (!germanDescription || !kind || !title) {
return json(
{ error: 'German description, kind und title sind erforderlich' },
{ status: 400 }
);
}
// Übersetze deutsche Beschreibung ins Englische
console.log('Übersetze deutsche Beschreibung:', germanDescription.substring(0, 100) + '...');
const englishPrompt = await translateToImagePrompt(germanDescription, kind, title, style);
console.log('Übersetzung erfolgreich:', englishPrompt.substring(0, 100) + '...');
return json({
success: true,
englishPrompt,
message: 'Übersetzung erfolgreich',
});
} catch (error) {
console.error('Fehler bei der Prompt-Übersetzung:', error);
return json({ error: 'Übersetzung fehlgeschlagen' }, { status: 500 });
}
};

View file

@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import type { ContentNode, NodeKind } from '$lib/types/content';
export const GET: RequestHandler = async ({ url, locals }) => {
const supabase = locals.supabase;
const kind = url.searchParams.get('kind') as NodeKind | null;
const world_slug = url.searchParams.get('world_slug');
const search = url.searchParams.get('search');
const limit = parseInt(url.searchParams.get('limit') || '50');
const offset = parseInt(url.searchParams.get('offset') || '0');
let query = supabase
.from('content_nodes')
.select('*')
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (kind) {
query = query.eq('kind', kind);
}
if (world_slug) {
query = query.eq('world_slug', world_slug);
}
if (search) {
query = query.textSearch('search_tsv', search);
}
const { data, error } = await query;
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const POST: RequestHandler = async ({ request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const supabase = locals.supabase;
const node: Partial<ContentNode> = {
...body,
owner_id: session.user.id,
};
const { data, error } = await supabase.from('content_nodes').insert(node).select().single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data, { status: 201 });
};

View file

@ -0,0 +1,146 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, locals }) => {
const supabase = locals.supabase;
const { slug } = params;
const { data, error } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', slug)
.single();
if (error) {
if (error.code === 'PGRST116') {
return json({ error: 'Node not found' }, { status: 404 });
}
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
const updates = await request.json();
// First, check if user owns this node
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Create revision before updating
const { data: currentNode } = await supabase
.from('content_nodes')
.select('*')
.eq('slug', slug)
.single();
if (currentNode) {
await supabase.from('node_revisions').insert({
node_id: currentNode.id,
node_slug: slug,
content_before: currentNode.content,
content_after: updates.content || currentNode.content,
edited_by: session.user.id,
});
}
// Update the node
const { data, error } = await supabase
.from('content_nodes')
.update(updates)
.eq('slug', slug)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
const updates = await request.json();
// Check ownership
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id, slug')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Handle slug changes
const newSlug = updates.slug || slug;
const updateData = {
...updates,
updated_at: new Date().toISOString(),
};
const { data, error } = await supabase
.from('content_nodes')
.update(updateData)
.eq('slug', slug)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json(data);
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const { session } = await locals.safeGetSession();
if (!session) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = locals.supabase;
const { slug } = params;
// Check ownership
const { data: existingNode } = await supabase
.from('content_nodes')
.select('owner_id')
.eq('slug', slug)
.single();
if (!existingNode || existingNode.owner_id !== session.user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
const { error } = await supabase.from('content_nodes').delete().eq('slug', slug);
if (error) {
return json({ error: error.message }, { status: 500 });
}
return new Response(null, { status: 204 });
};

View file

@ -0,0 +1,357 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { supabase } from '$lib/supabaseClient';
import type { CustomFieldData, CustomFieldSchema } from '$lib/types/customFields';
// GET /api/nodes/[slug]/custom-data - Get custom field data for a node
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
// Get the node with its custom data
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, slug, custom_data, custom_schema, owner_id, visibility')
.eq('slug', slug)
.single();
if (fetchError) {
throw error(404, 'Node not found');
}
// Check permissions
const canView =
node.owner_id === session.user.id ||
node.visibility === 'public' ||
(node.visibility === 'shared' && session.user);
if (!canView) {
throw error(403, 'Access denied');
}
// Calculate formula fields if schema exists
let processedData = node.custom_data || {};
if (node.custom_schema) {
processedData = await calculateFormulas(node.custom_schema, processedData);
}
return json({
data: processedData,
schema: node.custom_schema,
});
} catch (err) {
console.error('Error fetching custom data:', err);
throw error(500, 'Failed to fetch custom data');
}
};
// PUT /api/nodes/[slug]/custom-data - Update all custom data
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const customData = body.data as CustomFieldData;
// Get the node to check ownership and schema
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, owner_id, custom_schema')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can modify custom data');
}
// Validate data against schema
if (node.custom_schema) {
const validation = validateData(node.custom_schema, customData);
if (!validation.valid) {
throw error(400, JSON.stringify(validation.errors));
}
}
// Update the custom data
const { error: updateError } = await supabase
.from('content_nodes')
.update({
custom_data: customData,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to update custom data');
}
// Calculate formulas and return processed data
const processedData = node.custom_schema
? await calculateFormulas(node.custom_schema, customData)
: customData;
return json({
success: true,
data: processedData,
});
} catch (err) {
console.error('Error updating custom data:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update custom data');
}
};
// PATCH /api/nodes/[slug]/custom-data - Partial update of custom data
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const session = await locals.getSession();
if (!session?.user) {
throw error(401, 'Unauthorized');
}
try {
const body = await request.json();
const updates = body.data as Partial<CustomFieldData>;
// Get the current node data
const { data: node, error: fetchError } = await supabase
.from('content_nodes')
.select('id, owner_id, custom_schema, custom_data')
.eq('slug', slug)
.single();
if (fetchError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== session.user.id) {
throw error(403, 'Only the owner can modify custom data');
}
// Merge with existing data
const mergedData = {
...(node.custom_data || {}),
...updates,
};
// Validate merged data against schema
if (node.custom_schema) {
const validation = validateData(node.custom_schema, mergedData);
if (!validation.valid) {
throw error(400, JSON.stringify(validation.errors));
}
}
// Update the custom data
const { error: updateError } = await supabase
.from('content_nodes')
.update({
custom_data: mergedData,
updated_at: new Date().toISOString(),
})
.eq('slug', slug);
if (updateError) {
throw error(500, 'Failed to update custom data');
}
// Calculate formulas and return processed data
const processedData = node.custom_schema
? await calculateFormulas(node.custom_schema, mergedData)
: mergedData;
return json({
success: true,
data: processedData,
});
} catch (err) {
console.error('Error patching custom data:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to patch custom data');
}
};
// Helper function to validate data against schema
function validateData(
schema: CustomFieldSchema,
data: CustomFieldData
): { valid: boolean; errors: any[] } {
const errors: any[] = [];
for (const field of schema.fields) {
const value = data[field.key];
// Check required fields
if (field.required && (value === undefined || value === null || value === '')) {
errors.push({
field: field.key,
message: `${field.label} is required`,
});
continue;
}
// Skip validation if field is empty and not required
if (!field.required && (value === undefined || value === null)) {
continue;
}
// Type-specific validation
switch (field.type) {
case 'number':
case 'range':
if (typeof value !== 'number') {
errors.push({
field: field.key,
message: `${field.label} must be a number`,
});
} else {
if (field.config.min !== undefined && value < field.config.min) {
errors.push({
field: field.key,
message: `${field.label} must be at least ${field.config.min}`,
});
}
if (field.config.max !== undefined && value > field.config.max) {
errors.push({
field: field.key,
message: `${field.label} must be at most ${field.config.max}`,
});
}
}
break;
case 'text':
if (typeof value !== 'string') {
errors.push({
field: field.key,
message: `${field.label} must be text`,
});
} else {
if (field.config.maxLength && value.length > field.config.maxLength) {
errors.push({
field: field.key,
message: `${field.label} must be at most ${field.config.maxLength} characters`,
});
}
if (field.config.pattern) {
const regex = new RegExp(field.config.pattern);
if (!regex.test(value)) {
errors.push({
field: field.key,
message: `${field.label} has invalid format`,
});
}
}
}
break;
case 'select':
if (field.config.choices) {
const validValues = field.config.choices.map((c) => c.value);
if (!validValues.includes(value)) {
errors.push({
field: field.key,
message: `${field.label} has invalid value`,
});
}
}
break;
case 'multiselect':
if (!Array.isArray(value)) {
errors.push({
field: field.key,
message: `${field.label} must be an array`,
});
} else if (field.config.choices) {
const validValues = field.config.choices.map((c) => c.value);
for (const v of value) {
if (!validValues.includes(v)) {
errors.push({
field: field.key,
message: `${field.label} contains invalid value: ${v}`,
});
}
}
}
break;
case 'boolean':
if (typeof value !== 'boolean') {
errors.push({
field: field.key,
message: `${field.label} must be true or false`,
});
}
break;
case 'list':
if (!Array.isArray(value)) {
errors.push({
field: field.key,
message: `${field.label} must be a list`,
});
} else {
if (field.config.min_items && value.length < field.config.min_items) {
errors.push({
field: field.key,
message: `${field.label} must have at least ${field.config.min_items} items`,
});
}
if (field.config.max_items && value.length > field.config.max_items) {
errors.push({
field: field.key,
message: `${field.label} must have at most ${field.config.max_items} items`,
});
}
}
break;
}
}
return {
valid: errors.length === 0,
errors,
};
}
// Helper function to calculate formula fields
async function calculateFormulas(
schema: CustomFieldSchema,
data: CustomFieldData
): Promise<CustomFieldData> {
const result = { ...data };
// For now, just copy formula strings as-is
// In a real implementation, we'd evaluate them here
for (const field of schema.fields) {
if (field.type === 'formula' && field.config.formula) {
// TODO: Implement actual formula evaluation
// For now, just store the formula
result[field.key] = `[Formula: ${field.config.formula}]`;
}
}
return result;
}

View file

@ -0,0 +1,115 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
// Temporary endpoint that works without node_images table
// Until migration can be run with Docker/Supabase
export const GET: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Get the node - if it has an image_url, return it as primary image
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('image_url, generation_prompt')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
return json({ error: 'Node not found' }, { status: 404 });
}
// Convert existing image to new format
const images = [];
if (node.image_url) {
images.push({
id: 'temp-primary',
image_url: node.image_url,
prompt: node.generation_prompt,
is_primary: true,
sort_order: 0,
created_at: new Date().toISOString(),
});
}
return json(images);
};
export const POST: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the node and verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id, image_url, slug, title')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
console.error('Node lookup error for slug:', params.slug, 'Error:', nodeError);
console.error('Full params:', params);
// Try to find similar nodes for debugging
const { data: similarNodes } = await supabase
.from('content_nodes')
.select('slug, title')
.ilike('slug', `%${params.slug}%`)
.limit(5);
console.error('Similar nodes found:', similarNodes);
return json(
{
error: 'Node not found',
details: nodeError?.message,
searchedSlug: params.slug,
similarNodes: similarNodes,
},
{ status: 404 }
);
}
if (node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// For now, just update the main image_url field
// This is temporary until the migration can be run
const { data: updatedNode, error: updateError } = await supabase
.from('content_nodes')
.update({
image_url: body.image_url,
generation_prompt: body.prompt,
})
.eq('id', node.id)
.select()
.single();
if (updateError) {
return json({ error: updateError.message }, { status: 500 });
}
// Return in the expected format
const imageRecord = {
id: 'temp-new',
image_url: body.image_url,
prompt: body.prompt,
is_primary: true,
sort_order: 0,
created_at: new Date().toISOString(),
node_id: node.id,
};
return json(imageRecord, { status: 201 });
};

View file

@ -0,0 +1,103 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
export const GET: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Get all image attachments for this node
const { data: attachments, error } = await supabase
.from('attachments')
.select('*')
.eq('node_slug', params.slug)
.eq('kind', 'image')
.order('is_primary', { ascending: false })
.order('sort_order')
.order('created_at', { ascending: false });
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform attachments to expected image format
const images = (attachments || []).map((attachment) => ({
id: attachment.id,
image_url: attachment.url,
prompt: attachment.generation_prompt,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
}));
return json(images);
};
export const POST: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify node exists and user owns it
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', params.slug)
.single();
if (nodeError || !node) {
console.error('Node lookup error:', nodeError, 'Params:', params);
return json({ error: 'Node not found', details: nodeError?.message }, { status: 404 });
}
if (node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Check if this should be the primary image (first image or explicitly set)
const { count } = await supabase
.from('attachments')
.select('*', { count: 'exact', head: true })
.eq('node_slug', params.slug)
.eq('kind', 'image');
const isPrimary = body.is_primary !== undefined ? body.is_primary : count === 0;
// Insert the new image attachment
const { data: attachment, error } = await supabase
.from('attachments')
.insert({
node_slug: params.slug,
kind: 'image',
url: body.image_url,
generation_prompt: body.prompt,
is_primary: isPrimary,
sort_order: body.sort_order || count || 0,
})
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform to expected image format
const image = {
id: attachment.id,
image_url: attachment.url,
prompt: attachment.generation_prompt,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
};
return json(image, { status: 201 });
};

View file

@ -0,0 +1,126 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
export const PATCH: RequestHandler = async (event) => {
const { params, request } = event;
const supabase = createClient(event);
const body = await request.json();
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the attachment and verify ownership through the node
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.select(
`
*,
node:content_nodes!inner(owner_id, slug)
`
)
.eq('id', params.id)
.eq('kind', 'image')
.single();
if (attachmentError || !attachment) {
return json({ error: 'Image not found' }, { status: 404 });
}
if (attachment.node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Update the attachment
const updates: any = {};
if (body.is_primary !== undefined) updates.is_primary = body.is_primary;
if (body.sort_order !== undefined) updates.sort_order = body.sort_order;
const { data: updatedAttachment, error } = await supabase
.from('attachments')
.update(updates)
.eq('id', params.id)
.select()
.single();
if (error) {
return json({ error: error.message }, { status: 500 });
}
// Transform to expected image format
const updatedImage = {
id: updatedAttachment.id,
image_url: updatedAttachment.url,
prompt: updatedAttachment.generation_prompt,
is_primary: updatedAttachment.is_primary,
sort_order: updatedAttachment.sort_order,
created_at: updatedAttachment.created_at,
};
return json(updatedImage);
};
export const DELETE: RequestHandler = async (event) => {
const { params } = event;
const supabase = createClient(event);
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the attachment and verify ownership through the node
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.select(
`
*,
node:content_nodes!inner(owner_id)
`
)
.eq('id', params.id)
.eq('kind', 'image')
.single();
if (attachmentError || !attachment) {
return json({ error: 'Image not found' }, { status: 404 });
}
if (attachment.node.owner_id !== user.id) {
return json({ error: 'Forbidden' }, { status: 403 });
}
// Delete the attachment
const { error } = await supabase.from('attachments').delete().eq('id', params.id);
if (error) {
return json({ error: error.message }, { status: 500 });
}
// If this was the primary image, make the next image primary
if (attachment.is_primary) {
const { data: nextAttachment } = await supabase
.from('attachments')
.select('id')
.eq('node_slug', attachment.node_slug)
.eq('kind', 'image')
.order('sort_order')
.order('created_at')
.limit(1)
.single();
if (nextAttachment) {
await supabase.from('attachments').update({ is_primary: true }).eq('id', nextAttachment.id);
}
}
return json({ success: true });
};

View file

@ -0,0 +1,147 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createClient } from '$lib/supabase/server';
import { createId } from '@paralleldrive/cuid2';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
export const POST: RequestHandler = async (event) => {
const { request, params } = event;
const supabase = createClient(event);
const nodeSlug = params.slug;
// Verify user is authenticated
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw error(401, 'Unauthorized');
}
try {
// Get the node to verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', nodeSlug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
// Check ownership
if (node.owner_id !== user.id) {
throw error(403, 'Not authorized to upload images to this node');
}
// Parse multipart form data
const formData = await request.formData();
const imageFile = formData.get('image') as File;
const isPrimary = formData.get('is_primary') === 'true';
if (!imageFile) {
throw error(400, 'No image file provided');
}
// Validate file type
if (!ALLOWED_TYPES.includes(imageFile.type)) {
throw error(400, 'Invalid file type. Only JPEG, PNG, WebP and GIF are allowed');
}
// Validate file size
if (imageFile.size > MAX_FILE_SIZE) {
throw error(400, 'File too large. Maximum size is 10MB');
}
// Generate unique filename
const fileExt = imageFile.name.split('.').pop()?.toLowerCase() || 'jpg';
const fileName = `${nodeSlug}/${createId()}.${fileExt}`;
// Upload to Supabase Storage
const { data: uploadData, error: uploadError } = await supabase.storage
.from('node-images')
.upload(fileName, imageFile, {
contentType: imageFile.type,
cacheControl: '3600',
upsert: false,
});
if (uploadError) {
console.error('Storage upload error:', uploadError);
throw error(500, 'Failed to upload image');
}
// Get public URL
const {
data: { publicUrl },
} = supabase.storage.from('node-images').getPublicUrl(fileName);
// If this should be primary, unset other primary images first
if (isPrimary) {
await supabase
.from('attachments')
.update({ is_primary: false })
.eq('node_slug', nodeSlug)
.eq('kind', 'image');
}
// Check if there are any existing images
const { count } = await supabase
.from('attachments')
.select('*', { count: 'exact', head: true })
.eq('node_slug', nodeSlug)
.eq('kind', 'image');
// Create attachment record
const { data: attachment, error: attachmentError } = await supabase
.from('attachments')
.insert({
node_slug: nodeSlug,
kind: 'image',
file_url: publicUrl,
storage_path: fileName,
metadata: {
original_name: imageFile.name,
size: imageFile.size,
type: imageFile.type,
},
is_primary: isPrimary || count === 0, // Set as primary if requested or if it's the first image
sort_order: (count || 0) + 1,
})
.select()
.single();
if (attachmentError) {
// Try to clean up the uploaded file
await supabase.storage.from('node-images').remove([fileName]);
console.error('Attachment creation error:', attachmentError);
throw error(500, 'Failed to create attachment record');
}
// Dispatch event to update UI
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('images-updated', {
detail: { nodeSlug },
})
);
}
return json({
id: attachment.id,
image_url: publicUrl,
is_primary: attachment.is_primary,
sort_order: attachment.sort_order,
created_at: attachment.created_at,
});
} catch (err) {
console.error('Upload error:', err);
if (err instanceof Response) {
throw err;
}
throw error(500, 'Internal server error');
}
};

View file

@ -0,0 +1,154 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MemoryService } from '$lib/services/memoryService';
import { createClient } from '$lib/supabase/server';
// GET /api/nodes/[slug]/memory - Get node memory
export const GET: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Get the node to verify ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
// Check if user has access
if (node.owner_id !== user.id) {
throw error(403, "You do not have access to this node's memory");
}
const memory = await MemoryService.getMemory(node.id);
return json(memory);
} catch (err) {
console.error('Error fetching memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to fetch memory');
}
};
// POST /api/nodes/[slug]/memory - Add a new memory
export const POST: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const body = await request.json();
const {
content,
tier = 'short',
importance = 5,
tags = [],
involved = [],
location,
emotional_weight,
} = body;
if (!content) {
throw error(400, 'Memory content is required');
}
const success = await MemoryService.addMemory(node.id, content, tier, {
importance,
tags,
involved,
location,
emotional_weight,
});
if (!success) {
throw error(500, 'Failed to add memory');
}
return json({ success: true });
} catch (err) {
console.error('Error adding memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to add memory');
}
};
// PUT /api/nodes/[slug]/memory - Update entire memory object
export const PUT: RequestHandler = async (event) => {
const { params, request, locals } = event;
const { slug } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const memory = await request.json();
const success = await MemoryService.updateMemory(node.id, memory);
if (!success) {
throw error(500, 'Failed to update memory');
}
return json({ success: true });
} catch (err) {
console.error('Error updating memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to update memory');
}
};

View file

@ -0,0 +1,48 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { MemoryService } from '$lib/services/memoryService';
import { createClient } from '$lib/supabase/server';
// DELETE /api/nodes/[slug]/memory/[memoryId] - Delete a specific memory
export const DELETE: RequestHandler = async (event) => {
const { params, locals } = event;
const { slug, memoryId } = params;
const supabase = createClient(event);
try {
// Get authenticated user
const { user } = await locals.safeGetSession();
if (!user) {
throw error(401, 'Unauthorized');
}
// Verify node and ownership
const { data: node, error: nodeError } = await supabase
.from('content_nodes')
.select('id, owner_id')
.eq('slug', slug)
.single();
if (nodeError || !node) {
throw error(404, 'Node not found');
}
if (node.owner_id !== user.id) {
throw error(403, 'You do not have permission to modify this node');
}
const success = await MemoryService.deleteMemory(node.id, memoryId);
if (!success) {
throw error(500, 'Failed to delete memory');
}
return json({ success: true });
} catch (err) {
console.error('Error deleting memory:', err);
if (err instanceof Error && 'status' in err) {
throw err;
}
throw error(500, 'Failed to delete memory');
}
};

Some files were not shown because too many files have changed in this diff Show more