Initial commit: Character sandbox with React+Express frontend/backend, SvelteKit foundation

This commit is contained in:
2026-06-30 16:57:28 +02:00
commit 85454f9737
48 changed files with 4662 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
import db from '../db.js';
const LLM_HOST = process.env.LLM_HOST || null;
const LLM_MODEL = process.env.LLM_MODEL || 'qwen7b.Q4_K_M.gguf';
async function queryLLM(messages) {
if (!LLM_HOST) return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 180000);
try {
const response = await fetch(`${LLM_HOST}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: LLM_MODEL, messages, max_tokens: 500, temperature: 0.8, stream: false }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return null;
const data = await response.json();
return data.choices?.[0]?.message?.content || null;
} catch (e) {
clearTimeout(timeout);
return null;
}
}
const DEFAULT_KNOWLEDGE = {
character_archetypes: [
{
name: 'The Hero',
traits: ['brave', 'selfless', 'determined', 'courageous'],
backstory_template: 'A {adjective} individual driven by {motivation} to {goal}.',
needs_priority: { Food: 3, Energy: 4, Intimate: 1 }
},
{
name: 'The Sage',
traits: ['wise', 'patient', 'introspective', 'knowledgeable'],
backstory_template: 'Through years of study and experience, this {adjective} soul has gained {knowledge}.',
needs_priority: { Food: 1, Energy: 2, Intimate: 1 }
},
{
name: 'The Rogue',
traits: ['cunning', 'adaptive', 'independent', 'resourceful'],
backstory_template: 'Living on the edge, this {adjective} character uses their {skill} to survive.',
needs_priority: { Food: 4, Energy: 3, Intimate: 3 }
},
{
name: 'The Caregiver',
traits: ['nurturing', 'selfless', 'compassionate', 'protective'],
backstory_template: 'Driven by {motivation}, this {adjective} soul puts others before themselves.',
needs_priority: { Food: 2, Energy: 3, Intimate: 2 }
},
{
name: 'The Wild One',
traits: ['untamed', 'instinctual', 'free-spirited', 'primal'],
backstory_template: 'Born of {origin}, this {adjective} being follows the call of the wild.',
needs_priority: { Food: 5, Energy: 5, Bladder: 4, Bowel: 4, Hormones: 5, Intimate: 5 }
}
],
name_generators: {
fantasy: ['Kaelen', 'Lyra', 'Thorn', 'Elara', 'Bryn', 'Zephyr', 'Mira', 'Orion', 'Sable', 'Finn'],
modern: ['Alex', 'Jordan', 'Riley', 'Sam', 'Taylor', 'Morgan', 'Casey', 'Avery', 'Quinn', 'Drew'],
gothic: ['Vladimir', 'Isabella', 'Mortimer', 'Ophelia', 'Caspian', 'Seraphina', 'Damien', 'Raven', 'Lucian', 'Vesper'],
cyberpunk: ['Neon', 'Pixel', 'Cipher', 'Blade', 'Vex', 'Synthia', 'Zero', 'Echo', 'Byte', 'Nyx']
},
motivations: ['justice', 'knowledge', 'power', 'love', 'survival', 'redemption', 'freedom', 'discovery', 'revenge', 'balance'],
origins: ['the ancient forests', 'a forgotten kingdom', 'the stars above', 'the depths of the sea', 'a laboratory', 'the void between worlds', 'a nomadic tribe', 'an order of knights']
};
function getRandomItem(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function generateName(style) {
const generators = DEFAULT_KNOWLEDGE.name_generators;
const names = generators[style] || generators.fantasy;
return getRandomItem(names);
}
function generateArchetype() {
const archetypes = DEFAULT_KNOWLEDGE.character_archetypes;
const archetype = getRandomItem(archetypes);
const adjective = getRandomItem(archetype.traits);
const motivation = getRandomItem(DEFAULT_KNOWLEDGE.motivations);
const origin = getRandomItem(DEFAULT_KNOWLEDGE.origins);
const goal = getRandomItem(['save their people', 'find the truth', 'protect the innocent', 'gain ultimate power', 'achieve inner peace', 'survive the coming storm']);
const knowledge = getRandomItem(['ancient wisdom', 'forgotten secrets', 'the art of diplomacy', 'alchemical mastery', 'technological prowess']);
const backstory = archetype.backstory_template
.replace('{adjective}', adjective)
.replace('{motivation}', motivation)
.replace('{goal}', goal)
.replace('{knowledge}', knowledge)
.replace('{origin}', origin)
.replace('{skill}', `${motivation} and ${adjective}ness`);
return {
archetype: archetype.name,
traits: archetype.traits,
backstory,
needs_priority: archetype.needs_priority,
motivation,
origin
};
}
function getTrainingData(userId) {
const data = db.prepare('SELECT prompt, response, category FROM ai_training_data WHERE user_id = ? ORDER BY created_at DESC LIMIT 50').all(userId);
return data;
}
function generateCharacterSuggestion(prompt, userId) {
const userTraining = getTrainingData(userId);
const archetype = generateArchetype();
const nameStyle = prompt.toLowerCase().includes('fantasy') ? 'fantasy' :
prompt.toLowerCase().includes('cyber') ? 'cyberpunk' :
prompt.toLowerCase().includes('goth') ? 'gothic' :
prompt.toLowerCase().includes('modern') ? 'modern' : 'fantasy';
const name = generateName(nameStyle);
const defaultNeeds = [
{ name: 'Food', enabled: true, initial_value: 80, min_value: 0, max_value: 100, decay_rate: 1.5, priority: 3 },
{ name: 'Energy', enabled: true, initial_value: 90, min_value: 0, max_value: 100, decay_rate: 2, priority: 4 },
{ name: 'Bladder', enabled: true, initial_value: 30, min_value: 0, max_value: 100, decay_rate: 0.8, priority: 2 },
{ name: 'Bowel', enabled: false, initial_value: 20, min_value: 0, max_value: 100, decay_rate: 0.5, priority: 1 },
{ name: 'Hormones', enabled: true, initial_value: 40, min_value: 0, max_value: 100, decay_rate: 0.3, priority: 1 },
{ name: 'Intimate', enabled: true, initial_value: 30, min_value: 0, max_value: 100, decay_rate: 0.4, priority: 2 }
];
const needsWithPriority = defaultNeeds.map(n => ({
...n,
priority: archetype.needs_priority[n.name] || n.priority
}));
const response = {
name,
description: `A ${nameStyle.toLowerCase()} character embodying the ${archetype.archetype.toLowerCase()} archetype.`,
personality_traits: archetype.traits,
backstory: archetype.backstory,
suggested_needs: needsWithPriority,
suggested_ui_elements: needsWithPriority
.filter(n => n.enabled)
.map(n => ({
need_name: n.name,
element_type: n.name === 'Energy' ? 'gauge' : 'progress_bar',
config: { color: getColorForNeed(n.name), label: n.name, animation: 'smooth' }
})),
reasoning: `Based on your request, I drew inspiration from the ${archetype.archetype.toLowerCase()} archetype. ${archetype.motivation} drives this character forward. I prioritized needs that match their instincts.`
};
if (userTraining.length > 0) {
const recentTraining = userTraining[0];
response.training_influence = `Drawing from your past preferences: "${recentTraining.prompt}" -> "${recentTraining.response}"`;
}
return response;
}
function getColorForNeed(needName) {
const colors = {
Food: '#e74c3c',
Energy: '#f39c12',
Bladder: '#3498db',
Bowel: '#8e44ad',
Hormones: '#e91e63',
Intimate: '#ff6b6b'
};
return colors[needName] || '#95a5a6';
}
async function processTrainingPrompt(prompt, userId) {
try {
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Create a character based on this request: "${prompt}". Return ONLY valid JSON.` }
];
const ollamaResponse = await queryLLM(messages);
if (ollamaResponse) {
const cleaned = ollamaResponse.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
const parsed = JSON.parse(cleaned);
const valid = validateSuggestion(parsed);
if (valid) return parsed;
}
} catch {
// Fallback to rule-based
}
return generateCharacterSuggestion(prompt, userId);
}
function validateSuggestion(s) {
if (!s.name || !s.description || !Array.isArray(s.personality_traits)) return false;
if (!Array.isArray(s.suggested_needs) || s.suggested_needs.length === 0) return false;
return true;
}
export { generateCharacterSuggestion, processTrainingPrompt, DEFAULT_KNOWLEDGE };
+103
View File
@@ -0,0 +1,103 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
import bcrypt from 'bcrypt';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const db = new Database(path.join(__dirname, 'sandbox.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
personality_traits TEXT DEFAULT '[]',
backstory TEXT DEFAULT '',
avatar_url TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS character_needs (
id TEXT PRIMARY KEY,
character_id TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
initial_value REAL DEFAULT 50,
min_value REAL DEFAULT 0,
max_value REAL DEFAULT 100,
decay_rate REAL DEFAULT 1,
priority INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS character_ui_elements (
id TEXT PRIMARY KEY,
character_id TEXT NOT NULL,
need_id TEXT,
element_type TEXT NOT NULL,
config TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE,
FOREIGN KEY (need_id) REFERENCES character_needs(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS character_brain_rules (
id TEXT PRIMARY KEY,
character_id TEXT NOT NULL,
condition TEXT NOT NULL,
action TEXT NOT NULL,
priority INTEGER DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS lorebooks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS fragments (
id TEXT PRIMARY KEY,
lorebook_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
linked_characters TEXT DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (lorebook_id) REFERENCES lorebooks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ai_training_data (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
prompt TEXT NOT NULL,
response TEXT NOT NULL,
category TEXT DEFAULT 'general',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
export default db;
+27
View File
@@ -0,0 +1,27 @@
import express from 'express';
import cors from 'cors';
import authRoutes from './routes/auth.js';
import characterRoutes from './routes/characters.js';
import lorebookRoutes from './routes/lorebooks.js';
import fragmentRoutes from './routes/fragments.js';
import aiRoutes from './routes/ai.js';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/characters', characterRoutes);
app.use('/api/lorebooks', lorebookRoutes);
app.use('/api/fragments', fragmentRoutes);
app.use('/api/ai', aiRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Character Sandbox API running on http://localhost:${PORT}`);
});
+26
View File
@@ -0,0 +1,26 @@
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'sandbox-secret-key-change-in-production';
export function generateToken(userId) {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
}
export function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.userId = decoded.userId;
next();
});
}
export { JWT_SECRET };
+2102
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "character-sandbox-server",
"version": "1.0.0",
"description": "Backend for the character sandbox",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"uuid": "^9.0.0"
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { authenticateToken } from '../middleware/auth.js';
import { processTrainingPrompt, generateCharacterSuggestion, DEFAULT_KNOWLEDGE } from '../ai/trainer.js';
const router = Router();
router.post('/suggest', authenticateToken, async (req, res) => {
const { prompt } = req.body;
if (!prompt) return res.status(400).json({ error: 'Prompt is required' });
const suggestion = await processTrainingPrompt(prompt, req.userId);
res.json({ suggestion });
});
router.post('/generate-name', authenticateToken, (req, res) => {
const { style } = req.body;
const name = generateCharacterSuggestion('generate name', req.userId).name;
res.json({ name });
});
router.post('/train', authenticateToken, (req, res) => {
const { prompt, response, category } = req.body;
if (!prompt || !response) return res.status(400).json({ error: 'Prompt and response are required' });
const id = uuidv4();
db.prepare('INSERT INTO ai_training_data (id, user_id, prompt, response, category) VALUES (?, ?, ?, ?, ?)')
.run(id, req.userId, prompt, response, category || 'general');
res.status(201).json({ message: 'Training data added', id });
});
router.get('/training-data', authenticateToken, (req, res) => {
const data = db.prepare('SELECT id, prompt, response, category, created_at FROM ai_training_data WHERE user_id = ? ORDER BY created_at DESC').all(req.userId);
res.json({ training_data: data });
});
router.delete('/training-data/:id', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM ai_training_data WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!existing) return res.status(404).json({ error: 'Training data not found' });
db.prepare('DELETE FROM ai_training_data WHERE id = ?').run(req.params.id);
res.json({ message: 'Training data deleted' });
});
router.get('/knowledge', authenticateToken, (req, res) => {
const archetypes = DEFAULT_KNOWLEDGE.character_archetypes.map(a => ({
name: a.name,
traits: a.traits,
needs_priority: a.needs_priority
}));
res.json({ archetypes, name_styles: Object.keys(DEFAULT_KNOWLEDGE.name_generators) });
});
export default router;
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
const router = Router();
router.post('/register', (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
const existing = db.prepare('SELECT id FROM users WHERE username = ? OR email = ?').get(username, email);
if (existing) {
return res.status(409).json({ error: 'Username or email already exists' });
}
const id = uuidv4();
const passwordHash = bcrypt.hashSync(password, 10);
db.prepare('INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)').run(id, username, email, passwordHash);
const token = generateToken(id);
res.status(201).json({ token, user: { id, username, email } });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateToken(user.id);
res.json({ token, user: { id: user.id, username: user.username, email: user.email } });
});
router.get('/me', authenticateToken, (req, res) => {
const user = db.prepare('SELECT id, username, email, created_at FROM users WHERE id = ?').get(req.userId);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
});
export default router;
+238
View File
@@ -0,0 +1,238 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
router.get('/', authenticateToken, (req, res) => {
const characters = db.prepare('SELECT * FROM characters WHERE user_id = ? ORDER BY updated_at DESC').all(req.userId);
res.json({ characters });
});
router.post('/', authenticateToken, (req, res) => {
const { name, description, personality_traits, backstory } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const id = uuidv4();
const traits = JSON.stringify(personality_traits || []);
db.prepare('INSERT INTO characters (id, user_id, name, description, personality_traits, backstory) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.userId, name, description || '', traits, backstory || '');
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
res.status(201).json({ character });
});
router.get('/:id', authenticateToken, (req, res) => {
const character = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!character) return res.status(404).json({ error: 'Character not found' });
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ?').all(req.params.id);
const uiElements = db.prepare('SELECT * FROM character_ui_elements WHERE character_id = ?').all(req.params.id);
const brainRules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ?').all(req.params.id);
res.json({ character, needs, ui_elements: uiElements, brain_rules: brainRules });
});
router.put('/:id', authenticateToken, (req, res) => {
const { name, description, personality_traits, backstory, avatar_url } = req.body;
const existing = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!existing) return res.status(404).json({ error: 'Character not found' });
db.prepare('UPDATE characters SET name = ?, description = ?, personality_traits = ?, backstory = ?, avatar_url = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(
name || existing.name,
description !== undefined ? description : existing.description,
personality_traits ? JSON.stringify(personality_traits) : existing.personality_traits,
backstory !== undefined ? backstory : existing.backstory,
avatar_url !== undefined ? avatar_url : existing.avatar_url,
req.params.id
);
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(req.params.id);
res.json({ character });
});
router.delete('/:id', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!existing) return res.status(404).json({ error: 'Character not found' });
db.prepare('DELETE FROM characters WHERE id = ?').run(req.params.id);
res.json({ message: 'Character deleted' });
});
// --- Needs ---
router.get('/:id/needs', authenticateToken, (req, res) => {
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ?').all(req.params.id);
res.json({ needs });
});
router.post('/:id/needs', authenticateToken, (req, res) => {
const { name, enabled, initial_value, min_value, max_value, decay_rate, priority } = req.body;
if (!name) return res.status(400).json({ error: 'Need name is required' });
const needId = uuidv4();
db.prepare('INSERT INTO character_needs (id, character_id, name, enabled, initial_value, min_value, max_value, decay_rate, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(needId, req.params.id, name, enabled !== false ? 1 : 0, initial_value || 50, min_value || 0, max_value || 100, decay_rate || 1, priority || 0);
const need = db.prepare('SELECT * FROM character_needs WHERE id = ?').get(needId);
res.status(201).json({ need });
});
router.put('/:id/needs/:needId', authenticateToken, (req, res) => {
const { name, enabled, initial_value, min_value, max_value, decay_rate, priority } = req.body;
const existing = db.prepare('SELECT * FROM character_needs WHERE id = ? AND character_id = ?').get(req.params.needId, req.params.id);
if (!existing) return res.status(404).json({ error: 'Need not found' });
db.prepare('UPDATE character_needs SET name = ?, enabled = ?, initial_value = ?, min_value = ?, max_value = ?, decay_rate = ?, priority = ? WHERE id = ?')
.run(name || existing.name, enabled !== undefined ? (enabled ? 1 : 0) : existing.enabled, initial_value ?? existing.initial_value, min_value ?? existing.min_value, max_value ?? existing.max_value, decay_rate ?? existing.decay_rate, priority ?? existing.priority, req.params.needId);
const need = db.prepare('SELECT * FROM character_needs WHERE id = ?').get(req.params.needId);
res.json({ need });
});
router.delete('/:id/needs/:needId', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM character_needs WHERE id = ? AND character_id = ?').get(req.params.needId, req.params.id);
if (!existing) return res.status(404).json({ error: 'Need not found' });
db.prepare('DELETE FROM character_needs WHERE id = ?').run(req.params.needId);
res.json({ message: 'Need deleted' });
});
// --- UI Elements ---
router.get('/:id/ui-elements', authenticateToken, (req, res) => {
const elements = db.prepare('SELECT * FROM character_ui_elements WHERE character_id = ?').all(req.params.id);
res.json({ ui_elements: elements });
});
router.post('/:id/ui-elements', authenticateToken, (req, res) => {
const { need_id, element_type, config } = req.body;
if (!element_type) return res.status(400).json({ error: 'Element type is required' });
const elementId = uuidv4();
db.prepare('INSERT INTO character_ui_elements (id, character_id, need_id, element_type, config) VALUES (?, ?, ?, ?, ?)')
.run(elementId, req.params.id, need_id || null, element_type, JSON.stringify(config || {}));
const element = db.prepare('SELECT * FROM character_ui_elements WHERE id = ?').get(elementId);
res.status(201).json({ ui_element: element });
});
router.put('/:id/ui-elements/:elementId', authenticateToken, (req, res) => {
const { need_id, element_type, config } = req.body;
const existing = db.prepare('SELECT * FROM character_ui_elements WHERE id = ? AND character_id = ?').get(req.params.elementId, req.params.id);
if (!existing) return res.status(404).json({ error: 'UI element not found' });
db.prepare('UPDATE character_ui_elements SET need_id = ?, element_type = ?, config = ? WHERE id = ?')
.run(need_id !== undefined ? need_id : existing.need_id, element_type || existing.element_type, config ? JSON.stringify(config) : existing.config, req.params.elementId);
const element = db.prepare('SELECT * FROM character_ui_elements WHERE id = ?').get(req.params.elementId);
res.json({ ui_element: element });
});
router.delete('/:id/ui-elements/:elementId', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM character_ui_elements WHERE id = ? AND character_id = ?').get(req.params.elementId, req.params.id);
if (!existing) return res.status(404).json({ error: 'UI element not found' });
db.prepare('DELETE FROM character_ui_elements WHERE id = ?').run(req.params.elementId);
res.json({ message: 'UI element deleted' });
});
// --- Brain Rules ---
router.get('/:id/brain-rules', authenticateToken, (req, res) => {
const rules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ?').all(req.params.id);
res.json({ brain_rules: rules });
});
router.post('/:id/brain-rules', authenticateToken, (req, res) => {
const { condition, action, priority, enabled } = req.body;
if (!condition || !action) return res.status(400).json({ error: 'Condition and action are required' });
const ruleId = uuidv4();
db.prepare('INSERT INTO character_brain_rules (id, character_id, condition, action, priority, enabled) VALUES (?, ?, ?, ?, ?, ?)')
.run(ruleId, req.params.id, JSON.stringify(condition), JSON.stringify(action), priority || 0, enabled !== false ? 1 : 0);
const rule = db.prepare('SELECT * FROM character_brain_rules WHERE id = ?').get(ruleId);
res.status(201).json({ brain_rule: rule });
});
router.put('/:id/brain-rules/:ruleId', authenticateToken, (req, res) => {
const { condition, action, priority, enabled } = req.body;
const existing = db.prepare('SELECT * FROM character_brain_rules WHERE id = ? AND character_id = ?').get(req.params.ruleId, req.params.id);
if (!existing) return res.status(404).json({ error: 'Brain rule not found' });
db.prepare('UPDATE character_brain_rules SET condition = ?, action = ?, priority = ?, enabled = ? WHERE id = ?')
.run(condition ? JSON.stringify(condition) : existing.condition, action ? JSON.stringify(action) : existing.action, priority ?? existing.priority, enabled !== undefined ? (enabled ? 1 : 0) : existing.enabled, req.params.ruleId);
const rule = db.prepare('SELECT * FROM character_brain_rules WHERE id = ?').get(req.params.ruleId);
res.json({ brain_rule: rule });
});
router.delete('/:id/brain-rules/:ruleId', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM character_brain_rules WHERE id = ? AND character_id = ?').get(req.params.ruleId, req.params.id);
if (!existing) return res.status(404).json({ error: 'Brain rule not found' });
db.prepare('DELETE FROM character_brain_rules WHERE id = ?').run(req.params.ruleId);
res.json({ message: 'Brain rule deleted' });
});
// --- Simulation ---
router.post('/:id/simulate', authenticateToken, (req, res) => {
const { steps = 10, events = [] } = req.body;
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ? AND enabled = 1').all(req.params.id);
const rules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ? AND enabled = 1 ORDER BY priority DESC').all(req.params.id);
const simulation = [];
let currentValues = {};
needs.forEach(n => { currentValues[n.name] = { ...n }; });
for (let step = 0; step < steps; step++) {
for (const need of needs) {
if (currentValues[need.name]) {
let newVal = currentValues[need.name].current_value || need.initial_value;
newVal -= need.decay_rate;
newVal = Math.max(need.min_value, Math.min(need.max_value, newVal));
currentValues[need.name].current_value = newVal;
}
}
for (const event of events) {
for (const need of needs) {
if (event[need.name]) {
let val = currentValues[need.name].current_value || need.initial_value;
val += event[need.name];
val = Math.max(need.min_value, Math.min(need.max_value, val));
currentValues[need.name].current_value = val;
}
}
}
const triggeredRules = [];
for (const rule of rules) {
const cond = JSON.parse(rule.condition);
const needVal = currentValues[cond.need]?.current_value ?? need.initial_value;
if (cond.operator === 'lt' && needVal < cond.value) {
triggeredRules.push(rule);
} else if (cond.operator === 'gt' && needVal > cond.value) {
triggeredRules.push(rule);
} else if (cond.operator === 'eq' && needVal === cond.value) {
triggeredRules.push(rule);
} else if (cond.operator === 'lte' && needVal <= cond.value) {
triggeredRules.push(rule);
} else if (cond.operator === 'gte' && needVal >= cond.value) {
triggeredRules.push(rule);
}
}
const snapshot = {};
for (const need of needs) {
snapshot[need.name] = Math.round((currentValues[need.name].current_value || need.initial_value) * 100) / 100;
}
simulation.push({ step, values: snapshot, triggered_rules: triggeredRules.map(r => ({ id: r.id, action: JSON.parse(r.action) })) });
}
res.json({ simulation, final_values: simulation[simulation.length - 1]?.values });
});
export default router;
+84
View File
@@ -0,0 +1,84 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
router.get('/search', authenticateToken, (req, res) => {
const { q, tag } = req.query;
let query = `SELECT f.*, l.name as lorebook_name FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id WHERE l.user_id = ?`;
const params = [req.userId];
if (q) {
query += ` AND (f.title LIKE ? OR f.content LIKE ?)`;
params.push(`%${q}%`, `%${q}%`);
}
if (tag) {
query += ` AND f.tags LIKE ?`;
params.push(`%"${tag}"%`);
}
query += ` ORDER BY f.updated_at DESC LIMIT 50`;
const fragments = db.prepare(query).all(...params);
res.json({ fragments });
});
router.get('/:id', authenticateToken, (req, res) => {
const fragment = db.prepare(`
SELECT f.*, l.name as lorebook_name, l.user_id
FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
WHERE f.id = ? AND l.user_id = ?
`).get(req.params.id, req.userId);
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
res.json({ fragment });
});
router.post('/lorebook/:lorebookId', authenticateToken, (req, res) => {
const { title, content, tags, linked_characters } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const lorebook = db.prepare('SELECT * FROM lorebooks WHERE id = ? AND user_id = ?').get(req.params.lorebookId, req.userId);
if (!lorebook) return res.status(404).json({ error: 'Lorebook not found' });
const id = uuidv4();
db.prepare('INSERT INTO fragments (id, lorebook_id, title, content, tags, linked_characters) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.params.lorebookId, title, content || '', JSON.stringify(tags || []), JSON.stringify(linked_characters || []));
const fragment = db.prepare('SELECT * FROM fragments WHERE id = ?').get(id);
res.status(201).json({ fragment });
});
router.put('/:id', authenticateToken, (req, res) => {
const { title, content, tags, linked_characters } = req.body;
const fragment = db.prepare(`
SELECT f.* FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
WHERE f.id = ? AND l.user_id = ?
`).get(req.params.id, req.userId);
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
db.prepare('UPDATE fragments SET title = ?, content = ?, tags = ?, linked_characters = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(
title || fragment.title,
content !== undefined ? content : fragment.content,
tags ? JSON.stringify(tags) : fragment.tags,
linked_characters ? JSON.stringify(linked_characters) : fragment.linked_characters,
req.params.id
);
const updated = db.prepare('SELECT * FROM fragments WHERE id = ?').get(req.params.id);
res.json({ fragment: updated });
});
router.delete('/:id', authenticateToken, (req, res) => {
const fragment = db.prepare(`
SELECT f.* FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
WHERE f.id = ? AND l.user_id = ?
`).get(req.params.id, req.userId);
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
db.prepare('DELETE FROM fragments WHERE id = ?').run(req.params.id);
res.json({ message: 'Fragment deleted' });
});
export default router;
+51
View File
@@ -0,0 +1,51 @@
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import db from '../db.js';
import { authenticateToken } from '../middleware/auth.js';
const router = Router();
router.get('/', authenticateToken, (req, res) => {
const lorebooks = db.prepare('SELECT * FROM lorebooks WHERE user_id = ? ORDER BY updated_at DESC').all(req.userId);
res.json({ lorebooks });
});
router.post('/', authenticateToken, (req, res) => {
const { name, description } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
const id = uuidv4();
db.prepare('INSERT INTO lorebooks (id, user_id, name, description) VALUES (?, ?, ?, ?)').run(id, req.userId, name, description || '');
const lorebook = db.prepare('SELECT * FROM lorebooks WHERE id = ?').get(id);
res.status(201).json({ lorebook });
});
router.get('/:id', authenticateToken, (req, res) => {
const lorebook = db.prepare('SELECT * FROM lorebooks WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!lorebook) return res.status(404).json({ error: 'Lorebook not found' });
const fragments = db.prepare('SELECT * FROM fragments WHERE lorebook_id = ? ORDER BY created_at DESC').all(req.params.id);
res.json({ lorebook, fragments });
});
router.put('/:id', authenticateToken, (req, res) => {
const { name, description } = req.body;
const existing = db.prepare('SELECT * FROM lorebooks WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!existing) return res.status(404).json({ error: 'Lorebook not found' });
db.prepare('UPDATE lorebooks SET name = ?, description = ?, updated_at = datetime(\'now\') WHERE id = ?')
.run(name || existing.name, description !== undefined ? description : existing.description, req.params.id);
const lorebook = db.prepare('SELECT * FROM lorebooks WHERE id = ?').get(req.params.id);
res.json({ lorebook });
});
router.delete('/:id', authenticateToken, (req, res) => {
const existing = db.prepare('SELECT * FROM lorebooks WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
if (!existing) return res.status(404).json({ error: 'Lorebook not found' });
db.prepare('DELETE FROM lorebooks WHERE id = ?').run(req.params.id);
res.json({ message: 'Lorebook deleted' });
});
export default router;
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.static(path.join(__dirname, '..', 'client', 'build')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'client', 'build', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Frontend serving on http://localhost:${PORT}`);
});