Initial commit: Character sandbox with React+Express frontend/backend, SvelteKit foundation
This commit is contained in:
@@ -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
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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 };
|
||||
Generated
+2102
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user