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

This commit is contained in:
The Howling Whispers 2026-06-30 16:57:28 +02:00
commit 85454f9737
48 changed files with 4662 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules/
build/
.svelte-kit/
data/
*.db
.env
.env.local
.vite/
dist/
.tmp/
*.log
.DS_Store

1
client Submodule

@ -0,0 +1 @@
Subproject commit b9e538e1914933ac4eedbdf75328737e7df70caf

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "character-sandbox-monorepo",
"version": "1.0.0",
"description": "A monorepo for the advanced character sandbox application.",
"main": "index.js",
"scripts": {
"start": "concurrently \"npm run start --prefix client\" \"npm run start --prefix server\"",
"install-all": "npm install --prefix client && npm install --prefix server",
"dev": "concurrently \"npm run dev --prefix client\" \"npm run dev --prefix server\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"concurrently": "^8.2.2"
},
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^4.3.0",
"chalk": "^4.1.2",
"cliui": "^8.0.1",
"color-convert": "^2.0.1",
"color-name": "^1.1.4",
"date-fns": "^2.30.0",
"emoji-regex": "^8.0.0",
"escalade": "^3.2.0",
"get-caller-file": "^2.0.5",
"has-flag": "^4.0.0",
"is-fullwidth-code-point": "^3.0.0",
"lodash": "^4.18.1",
"require-directory": "^2.1.1",
"rxjs": "^7.8.2",
"shell-quote": "^1.9.0",
"spawn-command": "^0.0.2",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"tslib": "^2.8.1",
"wrap-ansi": "^7.0.0",
"y18n": "^5.0.8",
"yargs": "^17.7.3",
"yargs-parser": "^21.1.1"
}
}

17
run.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
# Wrapper for the Sandbox SvelteKit process.
set -e
DIR="/var/www/sandbox"
PORT="${PORT:-2027}"
HOST="${HOST:-127.0.0.1}"
ORIGIN="${ORIGIN:-https://sandbox.thehowlingwhispers.com}"
export PORT HOST ORIGIN
if [ -f "$DIR/.env" ]; then
set -a
# shellcheck disable=SC1090,SC1091
source "$DIR/.env"
set +a
fi
exec node "$DIR/build/index.js"

268
scripts/netcup-dns.sh Executable file
View File

@ -0,0 +1,268 @@
#!/bin/bash
# /var/www/rp/scripts/netcup-dns.sh
# Reusable Netcup DNS tool for thehowlingwhispers.com (and any Netcup-managed zone).
#
# Credentials: /root/.netcup-creds (chmod 600, NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD)
# Audit log: /var/log/netcup-dns.log
#
# Usage:
# netcup-dns.sh list <domain> # list all records
# netcup-dns.sh add <domain> <host> <type> <value> # add a record (idempotent)
# netcup-dns.sh delete <domain> <recordId> # delete a record
# netcup-dns.sh --dry-run add <domain> ... # preview without API calls
# netcup-dns.sh --yes add <domain> ... # skip confirmation prompt
# netcup-dns.sh --json list ... # raw JSON output
#
# Examples:
# netcup-dns.sh list thehowlingwhispers.com
# netcup-dns.sh add thehowlingwhispers.com rp A 159.195.194.180
# netcup-dns.sh add thehowlingwhispers.com sandbox CNAME rp.thehowlingwhispers.com
# netcup-dns.sh add thehowlingwhispers.com @ TXT "v=spf1 -all"
# netcup-dns.sh --dry-run add thehowlingwhispers.com blog A 159.195.194.180
# netcup-dns.sh --json list thehowlingwhispers.com
set -euo pipefail
# ─── config ────────────────────────────────────────────────────
CREDS_FILE="${NETCUP_CREDS_FILE:-/root/.netcup-creds}"
LOG_FILE="${NETCUP_DNS_LOG:-/var/log/netcup-dns.log}"
API_BASE="https://ccp.netcup.net/api/v1"
JQ_FORMAT='@json' # not used; kept for clarity
# ─── colors ───────────────────────────────────────────────────
if [ -t 1 ]; then
C_RED=$'\e[31m'; C_GRN=$'\e[32m'; C_YEL=$'\e[33m'; C_DIM=$'\e[2m'; C_RST=$'\e[0m'
else
C_RED=''; C_GRN=''; C_YEL=''; C_DIM=''; C_RST=''
fi
ok() { printf '%s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; }
warn() { printf '%s!%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RST" "$*" >&2; }
# ─── flag parsing ─────────────────────────────────────────────
DRY_RUN=false
ASSUME_YES=false
JSON_OUT=false
SUBCMD=""
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--yes|-y) ASSUME_YES=true; shift ;;
--json) JSON_OUT=true; shift ;;
--help|-h)
sed -n '2,28p' "$0"
exit 0
;;
list|add|delete)
SUBCMD="$1"; shift; ARGS=("$@"); break ;;
*)
err "Unknown flag or command: $1"; exit 2 ;;
esac
done
# ─── log helper ───────────────────────────────────────────────
log() {
printf '%s [%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" "$2" >> "$LOG_FILE"
}
# ─── load creds (chmod 600 enforced) ────────────────────────
if [ ! -f "$CREDS_FILE" ]; then
err "Credentials file not found: $CREDS_FILE"
err "Create it with NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD (chmod 600)"
exit 1
fi
PERMS=$(stat -c '%a' "$CREDS_FILE" 2>/dev/null || echo "")
if [ "$PERMS" != "600" ] && [ "$PERMS" != "400" ]; then
err "Credentials file must be chmod 600 or 400 (currently: $PERMS). Refusing to run."
exit 1
fi
# shellcheck disable=SC1090
source "$CREDS_FILE"
if [ -z "${NETCUP_CUSTOMER:-}" ] || [ -z "${NETCUP_API_KEY:-}" ] || [ -z "${NETCUP_API_PASSWORD:-}" ]; then
err "Missing one of NETCUP_CUSTOMER / NETCUP_API_KEY / NETCUP_API_PASSWORD in $CREDS_FILE"
exit 1
fi
# ─── API helpers ─────────────────────────────────────────────
# Netcup requires X-Api-Key + X-Customer-Number headers, plus
# X-Session-Id for authenticated calls. Login returns sessionId.
SESSION_ID=""
SESSION_LOGGED_IN_AT=0
SESSION_TTL=240 # refresh session every 4 minutes
login() {
local now
now=$(date +%s)
if [ -n "$SESSION_ID" ] && [ $((now - SESSION_LOGGED_IN_AT)) -lt $SESSION_TTL ]; then
return 0
fi
local response http_code body
response=$(curl -sS -w '\n%{http_code}' -X POST \
-H "X-Api-Key: ${NETCUP_API_KEY}" \
-H "X-Customer-Number: ${NETCUP_CUSTOMER}" \
-H "Content-Type: application/json" \
"${API_BASE}/login")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ]; then
err "Login failed (HTTP $http_code): $body"
log "ERROR" "login failed: HTTP $http_code"
exit 1
fi
SESSION_ID=$(printf '%s' "$body" | jq -r '.sessionId')
SESSION_LOGGED_IN_AT=$now
log "INFO" "login OK (session refreshed)"
}
api() {
local method=$1; shift
local path=$1; shift
local body=${1:-}
login
local args=(-sS -w '\n%{http_code}' -X "$method" -H "X-Session-Id: $SESSION_ID" -H "Content-Type: application/json")
if [ -n "$body" ]; then
args+=(-d "$body")
fi
curl "${args[@]}" "${API_BASE}${path}"
}
# ─── subdomain ──────────────────────────────────────────────
require_domain() {
if [ -z "${1:-}" ]; then err "Domain required"; exit 1; fi
echo "$1"
}
require_arg() {
if [ -z "${1:-}" ]; then err "$2 required"; exit 1; fi
echo "$1"
}
# ─── subcommands ─────────────────────────────────────────────
cmd_list() {
local domain
domain=$(require_domain "${ARGS[0]:-}")
local response http_code body
response=$(api GET "/dns/${domain}")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ]; then
err "List failed (HTTP $http_code): $body"
log "ERROR" "list $domain failed: HTTP $http_code"
exit 1
fi
if $JSON_OUT; then
printf '%s\n' "$body"
else
printf '%s%-8s %-30s %-25s %s%s\n' "$C_DIM" "TYPE" "HOST" "VALUE" "TTL" "$C_RST"
printf '%s\n' "$body" | jq -r '.[] | [.type, .hostname, (.value|tostring|.[0:25]), .ttl] | @tsv' | \
while IFS=$'\t' read -r t h v ttl; do
printf '%-8s %-30s %-25s %s\n' "$t" "$h" "$v" "$ttl"
done
fi
log "INFO" "list $domain (HTTP $http_code, $(printf '%s' "$body" | jq 'length') records)"
}
cmd_add() {
local domain host type value
domain=$(require_domain "${ARGS[0]:-}")
host=$(require_arg "${ARGS[1]:-}" "host")
type=$(require_arg "${ARGS[2]:-}" "type")
value=$(require_arg "${ARGS[3]:-}" "value")
# Idempotency check: list first
local list_body
list_body=$(api GET "/dns/${domain}" | sed '$d')
local existing
existing=$(printf '%s' "$list_body" | jq -r --arg h "$host" --arg t "$type" --arg v "$value" \
'.[] | select(.hostname == $h and .type == $t and (.value|tostring) == $v) | .id')
if [ -n "$existing" ] && [ "$existing" != "null" ]; then
ok "Already exists: $domain $host $type $value (recordId=$existing) — no-op"
log "INFO" "add $domain $host $type $value — no-op (already exists, id=$existing)"
return 0
fi
warn "About to add: $domain $host $type $value"
if ! $ASSUME_YES && [ -t 0 ]; then
read -r -p "Continue? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) err "Aborted."; exit 1 ;;
esac
fi
if $DRY_RUN; then
ok "[dry-run] would POST $host $type $value to $domain"
log "INFO" "add $domain $host $type $value — DRY RUN"
return 0
fi
# Netcup payload format
local payload
payload=$(jq -n --arg h "$host" --arg t "$type" --arg v "$value" \
'{hostname:$h, type:$t, value:$v, ttl:3600}')
local response http_code body
response=$(api POST "/dns/${domain}" "$payload")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ] && [ "$http_code" != "201" ]; then
err "Add failed (HTTP $http_code): $body"
log "ERROR" "add $domain $host $type $value failed: HTTP $http_code"
exit 1
fi
local new_id
new_id=$(printf '%s' "$body" | jq -r '.id // .recordId // empty')
ok "Added: $domain $host $type $value (recordId=$new_id)"
log "INFO" "add $domain $host $type $value OK (id=$new_id)"
}
cmd_delete() {
local domain record_id
domain=$(require_domain "${ARGS[0]:-}")
record_id=$(require_arg "${ARGS[1]:-}" "recordId")
warn "About to delete: $domain recordId=$record_id"
if ! $ASSUME_YES && [ -t 0 ]; then
read -r -p "Continue? [y/N] " reply
case "$reply" in
y|Y|yes|YES) ;;
*) err "Aborted."; exit 1 ;;
esac
fi
if $DRY_RUN; then
ok "[dry-run] would DELETE recordId=$record_id from $domain"
log "INFO" "delete $domain $record_id — DRY RUN"
return 0
fi
local response http_code body
response=$(api DELETE "/dns/${domain}/${record_id}")
http_code=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then
err "Delete failed (HTTP $http_code): $body"
log "ERROR" "delete $domain $record_id failed: HTTP $http_code"
exit 1
fi
ok "Deleted: $domain recordId=$record_id"
log "INFO" "delete $domain $record_id OK"
}
# ─── dispatch ────────────────────────────────────────────────
case "$SUBCMD" in
list) cmd_list ;;
add) cmd_add ;;
delete) cmd_delete ;;
"")
err "Subcommand required (list|add|delete). Try --help."
sed -n '2,28p' "$0" >&2
exit 2
;;
*)
err "Unknown subcommand: $SUBCMD"
exit 2
;;
esac

196
server/ai/trainer.js Normal file
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
server/db.js Normal file
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
server/index.js Normal file
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
server/middleware/auth.js Normal file
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
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
server/package.json Normal file
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
server/routes/ai.js Normal file
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
server/routes/auth.js Normal file
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
server/routes/characters.js Normal file
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;

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;

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;

BIN
server/sandbox.db-shm Normal file

Binary file not shown.

BIN
server/sandbox.db-wal Normal file

Binary file not shown.

17
server/serve-frontend.js Normal file
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}`);
});

13
src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="@sveltejs/kit" />
declare namespace App {
interface Locals {
user: {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
display_name: string | null;
} | null;
}
}

15
src/app.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>sandbox — character workbench</title>
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

43
src/hooks.server.ts Normal file
View File

@ -0,0 +1,43 @@
import { redirect, type Handle } from '@sveltejs/kit';
import { getSessionUser, SESSION_COOKIE } from '$lib/server/auth';
const PUBLIC_PATHS = new Set<string>([
'/login',
'/auth/discord',
'/auth/discord/callback',
'/auth/local',
'/api/auth/me',
'/api/auth/local-enabled',
'/api/health'
]);
const PUBLIC_PREFIXES = ['/auth/discord/callback', '/_app/', '/favicon'];
function isPublic(pathname: string): boolean {
if (PUBLIC_PATHS.has(pathname)) return true;
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(p));
}
export const handle: Handle = async ({ event, resolve }) => {
const { url, cookies } = event;
const cookie = cookies.get(SESSION_COOKIE);
const user = getSessionUser(cookie);
// Expose the user to all routes via event.locals
(event.locals as any).user = user;
if (!user && !isPublic(url.pathname)) {
// API routes: 401 JSON
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({ error: 'authentication required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
// Page routes: redirect to /login
const next = url.pathname + url.search;
throw redirect(302, `/login?next=${encodeURIComponent(next)}`);
}
return resolve(event);
};

View File

@ -0,0 +1,8 @@
// /var/www/sandbox/src/lib/server/allowlist.ts
// Allowlist controls who can use the sandbox.
// Currently allows everyone. Can be narrowed later.
export function isUserAllowed(userId: string): boolean {
// TODO: implement allowlist table or markdown file if needed
return true;
}

View File

@ -0,0 +1,74 @@
// /var/www/sandbox/src/lib/server/auth/discord.ts
// Discord OAuth2 flow. Uses $env/dynamic/private for the env vars.
// Returns the user object on success, or null on any failure.
const DISCORD_API = 'https://discord.com/api';
interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
email: string | null;
verified: boolean;
}
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function getRedirectUri(): string {
return requireEnv('DISCORD_REDIRECT_URI');
}
export function buildAuthorizeUrl(redirectAfter: string): string {
const state = redirectAfter;
const params = new URLSearchParams({
client_id: requireEnv('DISCORD_CLIENT_ID'),
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: 'identify',
state
});
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
}
export async function exchangeCodeForUser(code: string, state: string): Promise<{ user: DiscordUser; redirect: string }> {
const tokenRes = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: requireEnv('DISCORD_CLIENT_ID'),
client_secret: requireEnv('DISCORD_CLIENT_SECRET'),
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri()
}).toString()
});
if (!tokenRes.ok) {
const text = await tokenRes.text();
throw new Error(`Discord token exchange failed: ${tokenRes.status} ${text}`);
}
const token = (await tokenRes.json()) as DiscordTokenResponse;
const userRes = await fetch(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `Bearer ${token.access_token}` }
});
if (!userRes.ok) {
const text = await userRes.text();
throw new Error(`Discord user fetch failed: ${userRes.status} ${text}`);
}
const user = (await userRes.json()) as DiscordUser;
return { user, redirect: state };
}

View File

@ -0,0 +1,24 @@
// /var/www/sandbox/src/lib/server/auth/index.ts
// Re-exports for the auth surface.
export {
getSessionUser,
createSession,
destroySession,
upsertUser,
SESSION_COOKIE,
SESSION_TTL_DURATION,
type SessionUser
} from './session';
export {
isLocalLoginEnabled,
verifyLocalPassword,
type LocalUser
} from './local';
export {
buildAuthorizeUrl,
exchangeCodeForUser
} from './discord';
export { isUserAllowed } from '$lib/server/allowlist';

View File

@ -0,0 +1,38 @@
// /var/www/sandbox/src/lib/server/auth/local.ts
// Local password login — fallback for when Discord OAuth is unavailable.
import { timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
const LOCAL_USER_ID = 'local-admin';
export interface LocalUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
display_name: string | null;
}
export function isLocalLoginEnabled(): boolean {
return !!env.LOCAL_ADMIN_PASSWORD && env.LOCAL_ADMIN_PASSWORD.length > 0;
}
export function verifyLocalPassword(submitted: string): LocalUser | null {
if (!isLocalLoginEnabled()) return null;
const expected = env.LOCAL_ADMIN_PASSWORD!;
if (submitted.length !== expected.length) {
timingSafeEqual(Buffer.from(submitted), Buffer.from(submitted));
return null;
}
const ok = timingSafeEqual(Buffer.from(submitted), Buffer.from(expected));
if (!ok) return null;
return {
id: LOCAL_USER_ID,
username: 'local-admin',
global_name: 'Local Admin',
avatar: null,
display_name: null
};
}

View File

@ -0,0 +1,96 @@
// /var/www/sandbox/src/lib/server/auth/session.ts
// Signed cookie sessions. SESSION_SECRET must be set in .env (32+ chars).
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { db } from '../db';
const SESSION_TTL_DAYS = 30;
const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = 'thw_sandbox_session';
export const SESSION_TTL_DURATION = SESSION_TTL_DAYS * 24 * 60 * 60;
function getSessionSecret(): string {
const s = process.env.SESSION_SECRET;
if (!s || s.length < 32) {
throw new Error('SESSION_SECRET must be set in .env (32+ chars)');
}
return s;
}
function sign(value: string): string {
return createHmac('sha256', getSessionSecret()).update(value).digest('hex');
}
function makeCookieValue(sessionId: string): string {
return `${sessionId}.${sign(sessionId)}`;
}
function verifyCookieValue(cookieValue: string): string | null {
const dotIdx = cookieValue.lastIndexOf('.');
if (dotIdx < 1) return null;
const sessionId = cookieValue.slice(0, dotIdx);
const providedSig = cookieValue.slice(dotIdx + 1);
const expectedSig = sign(sessionId);
if (providedSig.length !== expectedSig.length) return null;
try {
if (!timingSafeEqual(Buffer.from(providedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
return null;
}
} catch {
return null;
}
return sessionId;
}
export interface SessionUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
display_name: string | null;
}
export function upsertUser(user: SessionUser): void {
db.prepare(
`INSERT INTO users (id, username, global_name, avatar, display_name)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
username = excluded.username,
global_name = excluded.global_name,
avatar = excluded.avatar,
display_name = excluded.display_name`
).run(user.id, user.username, user.global_name, user.avatar, user.display_name);
}
export function createSession(userId: string): { id: string; cookie: string; expiresAt: Date } {
const id = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
id, userId, expiresAt.toISOString()
);
return { id, cookie: makeCookieValue(id), expiresAt };
}
export function destroySession(sessionId: string): void {
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
export function getSessionUser(cookieValue: string | undefined | null): SessionUser | null {
if (!cookieValue) return null;
const sessionId = verifyCookieValue(cookieValue);
if (!sessionId) return null;
const row = db.prepare(
`SELECT u.id, u.username, u.global_name, u.avatar, u.display_name, s.expires_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now')`
).get(sessionId) as (SessionUser & { expires_at: string }) | undefined;
if (!row) return null;
return {
id: row.id,
username: row.username,
global_name: row.global_name,
avatar: row.avatar,
display_name: row.display_name
};
}

View File

@ -0,0 +1,50 @@
// /var/www/sandbox/src/lib/server/db/index.ts
// Minimal DB for the sandbox character workbench. Per-user isolated.
// 3 tables: users, sessions, characters.
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const DB_PATH = process.env.DATABASE_PATH || './data/sandbox.db';
mkdirSync(dirname(DB_PATH), { recursive: true });
export const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
global_name TEXT,
avatar TEXT,
display_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
personality TEXT,
scenario TEXT,
first_mes TEXT,
mes_example TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
CREATE INDEX IF NOT EXISTS idx_characters_updated ON characters(updated_at DESC);
`);

View File

@ -0,0 +1,8 @@
// /var/www/sandbox/src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: (locals as any).user ?? null
};
};

85
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,85 @@
<script lang="ts">
import { page } from '$app/stores';
$: user = $page.data?.user ?? null;
</script>
<svelte:head>
<title>sandbox — character workbench</title>
</svelte:head>
<header class="topbar">
<span class="logo">sandbox</span>
<span class="sep">|</span>
<span class="sub">character workbench</span>
<div class="spacer"></div>
{#if user}
<span class="user-label">{user.display_name || user.username || user.global_name || user.id}</span>
<form method="POST" action="/api/auth/logout" class="logout-form">
<button class="logout-btn">sign out</button>
</form>
{/if}
</header>
<div class="app-shell">
<slot />
</div>
<style>
:global(*) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
background: #0a0a0a;
color: #c8c8c8;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
}
:global(a) { color: #7a9fc0; text-decoration: none; }
:global(a:hover) { text-decoration: underline; }
:global(input, textarea, select, button) {
font-family: inherit;
font-size: inherit;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #0e0e0e;
border-bottom: 1px solid #1a1a1a;
font-size: 12px;
}
.logo {
font-weight: 700;
color: #e0e0e0;
letter-spacing: 0.5px;
}
.sep { color: #3a3a3a; }
.sub { color: #6a6a6a; }
.spacer { flex: 1; }
.user-label {
color: #8a8a8a;
font-size: 11px;
margin-right: 8px;
}
.logout-form { display: inline; }
.logout-btn {
background: none;
border: 1px solid #2a2a2a;
color: #8a8a8a;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
}
.logout-btn:hover {
background: #151515;
color: #c8c8c8;
}
.app-shell {
height: calc(100vh - 37px);
overflow: hidden;
}
</style>

391
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,391 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Character {
id: string;
name: string;
description: string | null;
personality: string | null;
scenario: string | null;
first_mes: string | null;
mes_example: string | null;
created_at: string;
updated_at: string;
}
let characters: Character[] = $state([]);
let selected: Character | null = $state(null);
let loading = $state(true);
let saving = $state(false);
let errorMsg = $state<string | null>(null);
let user = $state<any>(null);
function fmtDate(iso: string): string {
const d = new Date(iso + 'Z');
return d.toLocaleDateString('en-CA') + ' ' + d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
}
async function loadCharacters() {
loading = true;
errorMsg = null;
try {
const res = await fetch('/api/characters');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
characters = data.characters ?? [];
} catch (e: any) {
errorMsg = e.message || 'failed to load characters';
} finally {
loading = false;
}
}
async function loadUser() {
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
user = data.user ?? null;
}
} catch {}
}
async function selectChar(id: string) {
if (selected?.id === id) return;
try {
const res = await fetch(`/api/characters/${id}`);
if (!res.ok) return;
const data = await res.json();
selected = data.character ?? null;
} catch {
// fallback: show from list
selected = characters.find((c) => c.id === id) ?? null;
}
}
async function save() {
if (!selected) return;
saving = true;
errorMsg = null;
try {
const body: Record<string, string> = {};
for (const f of ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example']) {
const val = (selected as any)[f];
if (val != null) body[f] = val;
}
const isNew = (selected as any)._new === true;
let res: Response;
if (isNew) {
res = await fetch('/api/characters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
} else {
res = await fetch(`/api/characters/${selected.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
if (!res.ok) {
const txt = await res.text();
throw new Error(txt.slice(0, 100));
}
const data = await res.json();
selected = data.character as Character;
await loadCharacters();
} catch (e: any) {
errorMsg = e.message || 'save failed';
} finally {
saving = false;
}
}
async function remove() {
if (!selected || (selected as any)._new) return;
if (!confirm('Delete this character? This cannot be undone.')) return;
try {
const res = await fetch(`/api/characters/${selected.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('delete failed');
selected = null;
await loadCharacters();
} catch (e: any) {
errorMsg = e.message || 'delete failed';
}
}
async function exportJson() {
if (!selected || (selected as any)._new) return;
try {
const res = await fetch(`/api/characters/${selected.id}/json`);
if (!res.ok) throw new Error('export failed');
const card = await res.json();
const blob = new Blob([JSON.stringify(card, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selected.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (e: any) {
errorMsg = e.message || 'export failed';
}
}
function newChar() {
selected = {
id: '',
name: '',
description: '',
personality: '',
scenario: '',
first_mes: '',
mes_example: '',
created_at: '',
updated_at: ''
} as Character & { _new: boolean };
(selected as any)._new = true;
}
onMount(() => {
loadUser();
loadCharacters();
});
</script>
<svelte:head>
<title>sandbox — character workbench</title>
</svelte:head>
<div class="workbench">
<!-- LEFT: character list -->
<aside class="sidebar">
<div class="sidebar-header">
<span class="sidebar-title">characters</span>
<button class="btn-sm" onclick={newChar}>+ new</button>
</div>
{#if loading}
<p class="muted" style="padding: 12px;">loading…</p>
{:else if characters.length === 0}
<p class="muted" style="padding: 12px;">no characters yet</p>
{:else}
<ul class="char-list">
{#each characters as c}
<li>
<button
class="char-item"
class:active={selected?.id === c.id}
onclick={() => selectChar(c.id)}
>
<span class="char-name">{c.name}</span>
<span class="char-date">{fmtDate(c.updated_at)}</span>
</button>
</li>
{/each}
</ul>
{/if}
</aside>
<!-- RIGHT: editor -->
<section class="editor">
{#if !selected}
<div class="empty-state">
<p>select a character to edit</p>
</div>
{:else}
<div class="editor-toolbar">
<h2 class="editor-title">
{(selected as any)._new ? 'new character' : selected.name || 'untitled'}
</h2>
<div class="toolbar-actions">
<button class="btn-sm primary" onclick={save} disabled={saving}>
{saving ? 'saving…' : 'save'}
</button>
<button class="btn-sm" onclick={exportJson} disabled={(selected as any)._new}>
export
</button>
<button class="btn-sm danger" onclick={remove} disabled={(selected as any)._new}>
delete
</button>
</div>
</div>
{#if errorMsg}
<p class="error-bar">! {errorMsg}</p>
{/if}
<div class="form-grid">
{#each [
{ key: 'name', label: 'Name', big: false },
{ key: 'description', label: 'Description', big: true },
{ key: 'personality', label: 'Personality', big: true },
{ key: 'scenario', label: 'Scenario', big: true },
{ key: 'first_mes', label: 'First Message', big: true },
{ key: 'mes_example', label: 'Message Example', big: true }
] as field}
{@const val = (selected as any)[field.key] ?? ''}
<div class="field" class:big={field.big}>
<label for={'f-' + field.key}>{field.label}</label>
{#if field.big}
<textarea
id={'f-' + field.key}
value={val}
rows={field.key === 'mes_example' ? 10 : 5}
oninput={(e) => { (selected as any)[field.key] = (e.target as HTMLTextAreaElement).value; }}
></textarea>
{:else}
<input
id={'f-' + field.key}
type="text"
value={val}
oninput={(e) => { (selected as any)[field.key] = (e.target as HTMLInputElement).value; }}
/>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</div>
<style>
.workbench {
display: flex;
height: 100%;
overflow: hidden;
}
.sidebar {
width: 240px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid #1a1a1a;
background: #0c0c0c;
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid #1a1a1a;
}
.sidebar-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #5a5a5a;
}
.char-list {
list-style: none;
margin: 0;
padding: 4px 0;
}
.char-item {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: #b8b8b8;
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: 12px;
}
.char-item:hover { background: #141414; }
.char-item.active { background: #181818; color: #e0e0e0; }
.char-name { font-weight: 600; }
.char-date { font-size: 10px; color: #5a5a5a; margin-top: 2px; }
.editor {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #4a4a4a;
font-size: 13px;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #1a1a1a;
}
.editor-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #d8d8d8;
letter-spacing: 0.3px;
}
.toolbar-actions {
display: flex;
gap: 6px;
}
.btn-sm {
background: #1a1a1a;
border: 1px solid #2a2a2a;
color: #b8b8b8;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
font-family: inherit;
}
.btn-sm:hover { background: #232323; }
.btn-sm:disabled { opacity: 0.4; cursor: default; }
.btn-sm.primary { border-color: #3a5a6a; color: #8ab8d0; }
.btn-sm.danger { border-color: #4a2a2a; color: #c87a7a; }
.error-bar {
color: #d96a7a;
font-size: 11px;
padding: 6px 10px;
background: rgba(217, 106, 122, 0.08);
border: 1px solid rgba(217, 106, 122, 0.2);
margin: 0 0 12px;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #5a5a5a;
}
.field input,
.field textarea {
background: #0a0a0a;
border: 1px solid #1f1f1f;
color: #c8c8c8;
padding: 7px 9px;
font-size: 12px;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: #3a5a6a;
}
.muted { color: #5a5a5a; font-size: 12px; }
</style>

View File

@ -0,0 +1,6 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { isLocalLoginEnabled } from '$lib/server/auth/local';
export const GET: RequestHandler = async () => {
return json({ enabled: isLocalLoginEnabled() });
};

View File

@ -0,0 +1,14 @@
// /var/www/sandbox/src/routes/api/auth/logout/+server.ts
import { redirect, type RequestHandler } from '@sveltejs/kit';
export const POST: RequestHandler = async ({ cookies }) => {
const { destroySession, SESSION_COOKIE } = await import('$lib/server/auth');
const cookie = cookies.get(SESSION_COOKIE);
if (cookie) {
const dotIdx = cookie.lastIndexOf('.');
const sessionId = dotIdx > 0 ? cookie.slice(0, dotIdx) : cookie;
destroySession(sessionId);
}
cookies.delete(SESSION_COOKIE, { path: '/' });
throw redirect(302, '/login');
};

View File

@ -0,0 +1,10 @@
// /var/www/sandbox/src/routes/api/auth/me/+server.ts
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals }) => {
const user = (locals as any).user;
if (!user) {
return json({ user: null });
}
return json({ user });
};

View File

@ -0,0 +1,53 @@
// /var/www/sandbox/src/routes/api/characters/+server.ts
import { json, error, type RequestHandler } from '@sveltejs/kit';
import { randomBytes } from 'node:crypto';
function getDb() {
return import('$lib/server/db').then((m) => m.db);
}
// GET /api/characters — list user's characters
export const GET: RequestHandler = async ({ locals }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
const db = await getDb();
const rows = db.prepare(
'SELECT id, name, created_at, updated_at FROM characters WHERE user_id = ? ORDER BY updated_at DESC'
).all(user.id);
return json({ characters: rows });
};
// POST /api/characters — create a new character
export const POST: RequestHandler = async ({ locals, request }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
throw error(400, 'invalid json');
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : null;
if (!name) throw error(400, 'name is required');
const id = randomBytes(16).toString('hex');
const db = await getDb();
db.prepare(
`INSERT INTO characters (id, user_id, name, description, personality, scenario, first_mes, mes_example)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
id,
user.id,
name,
typeof body.description === 'string' ? body.description : null,
typeof body.personality === 'string' ? body.personality : null,
typeof body.scenario === 'string' ? body.scenario : null,
typeof body.first_mes === 'string' ? body.first_mes : null,
typeof body.mes_example === 'string' ? body.mes_example : null
);
const created = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
return json({ character: created }, { status: 201 });
};

View File

@ -0,0 +1,77 @@
// /var/www/sandbox/src/routes/api/characters/[id]/+server.ts
import { json, error, type RequestHandler } from '@sveltejs/kit';
function getDb() {
return import('$lib/server/db').then((m) => m.db);
}
function ownRow(db: any, id: string, userId: string) {
const row = db.prepare('SELECT * FROM characters WHERE id = ?').get(id) as any;
if (!row || row.user_id !== userId) return null;
return row;
}
// PUT /api/characters/:id — update fields
export const PUT: RequestHandler = async ({ locals, params, request }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
const db = await getDb();
const row = ownRow(db, params.id, user.id);
if (!row) throw error(404, 'not found');
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
throw error(400, 'invalid json');
}
const FIELDS = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example'] as const;
const updates: string[] = [];
const vals: unknown[] = [];
for (const f of FIELDS) {
if (body[f] !== undefined && typeof body[f] === 'string') {
updates.push(`${f} = ?`);
vals.push(body[f]!.trim());
}
}
if (updates.length === 0) {
return json({ character: row });
}
updates.push('updated_at = datetime(\'now\')');
vals.push(params.id);
db.prepare(`UPDATE characters SET ${updates.join(', ')} WHERE id = ?`).run(...vals);
const updated = db.prepare('SELECT * FROM characters WHERE id = ?').get(params.id);
return json({ character: updated });
};
// DELETE /api/characters/:id
export const DELETE: RequestHandler = async ({ locals, params }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
const db = await getDb();
const row = ownRow(db, params.id, user.id);
if (!row) throw error(404, 'not found');
db.prepare('DELETE FROM characters WHERE id = ?').run(params.id);
return json({ ok: true });
};
// GET /api/characters/:id — return character for editing
export const GET: RequestHandler = async ({ locals, params }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
const db = await getDb();
const row = ownRow(db, params.id, user.id);
if (!row) throw error(404, 'not found');
return json({ character: row });
};

View File

@ -0,0 +1,39 @@
// /var/www/sandbox/src/routes/api/characters/[id]/json/+server.ts
import { json, error, type RequestHandler } from '@sveltejs/kit';
function getDb() {
return import('$lib/server/db').then((m) => m.db);
}
export const GET: RequestHandler = async ({ locals, params }) => {
const user = (locals as any).user;
if (!user) throw error(401, 'auth required');
const db = await getDb();
const row = db.prepare('SELECT * FROM characters WHERE id = ?').get(params.id) as any;
if (!row || row.user_id !== user.id) throw error(404, 'not found');
const card = {
name: row.name,
description: row.description || '',
personality: row.personality || '',
scenario: row.scenario || '',
first_mes: row.first_mes || '',
mes_example: row.mes_example || '',
creator_notes: 'Exported from TheHowlingWhispers Character Workbench',
system_prompt: '',
post_history_instructions: '',
tags: [],
creator: user.display_name || user.username,
character_version: '1.0',
extras: {
sandbox_exported_at: new Date().toISOString()
}
};
return json(card, {
headers: {
'Content-Disposition': `attachment; filename="${row.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json"`
}
});
};

View File

@ -0,0 +1,6 @@
// /var/www/sandbox/src/routes/api/health/+server.ts
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
return json({ status: 'ok' });
};

View File

@ -0,0 +1,10 @@
// /var/www/sandbox/src/routes/auth/discord/+server.ts
// Initiates the Discord OAuth flow.
import { redirect, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ url }) => {
const next = url.searchParams.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
const { buildAuthorizeUrl } = await import('$lib/server/auth');
throw redirect(302, buildAuthorizeUrl(safeNext));
};

View File

@ -0,0 +1,53 @@
// /var/www/sandbox/src/routes/auth/discord/callback/+server.ts
// Handles the Discord OAuth callback.
import { redirect, type RequestHandler } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const GET: RequestHandler = async ({ url, cookies }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
throw redirect(302, `/login?error=${encodeURIComponent(error)}`);
}
if (!code || !state) {
throw redirect(302, '/login?error=missing_code_or_state');
}
const { exchangeCodeForUser, isUserAllowed, createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
let user, redirectTo;
try {
const result = await exchangeCodeForUser(code, state);
user = result.user;
redirectTo = result.redirect;
} catch (e) {
const msg = e instanceof Error ? e.message : 'unknown error';
throw redirect(302, `/login?error=${encodeURIComponent(msg.slice(0, 200))}`);
}
if (!isUserAllowed(user.id)) {
throw redirect(302, `/login?error=not_on_allowlist`);
}
upsertUser({
id: user.id,
username: user.username,
global_name: user.global_name,
avatar: user.avatar,
display_name: null
});
const session = createSession(user.id);
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
cookies.set(SESSION_COOKIE, session.cookie, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isHttps,
maxAge: SESSION_TTL_DURATION
});
throw redirect(302, redirectTo);
};

View File

@ -0,0 +1,43 @@
// /var/www/sandbox/src/routes/auth/local/+server.ts
// Local password login.
import { redirect, type RequestHandler } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const POST: RequestHandler = async ({ request, cookies, url }) => {
const { isLocalLoginEnabled, verifyLocalPassword } = await import('$lib/server/auth/local');
const { createSession, upsertUser, SESSION_COOKIE, SESSION_TTL_DURATION } = await import('$lib/server/auth');
if (!isLocalLoginEnabled()) {
throw redirect(302, '/login?error=local_login_disabled');
}
const data = await request.formData();
const password = (data.get('password') ?? '').toString();
const user = verifyLocalPassword(password);
if (!user) {
throw redirect(302, '/login?error=local_login_invalid');
}
upsertUser({
id: user.id,
username: user.username,
global_name: user.global_name,
avatar: user.avatar,
display_name: user.display_name
});
const session = createSession(user.id);
const isHttps = env.ORIGIN?.startsWith('https://') ?? false;
cookies.set(SESSION_COOKIE, session.cookie, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isHttps,
maxAge: SESSION_TTL_DURATION
});
const next = url.searchParams.get('next');
const safeNext = next && next.startsWith('/') && !next.startsWith('//') ? next : '/';
throw redirect(302, safeNext);
};

View File

@ -0,0 +1,150 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
$: errorParam = $page.url.searchParams.get('error');
$: errorMessage = (() => {
if (!errorParam) return null;
if (errorParam === 'not_on_allowlist') return 'Your Discord account is not on the allowlist for this site.';
if (errorParam === 'local_login_invalid') return 'Wrong password.';
if (errorParam === 'local_login_disabled') return 'Local login is not enabled.';
return `Sign-in error: ${errorParam}`;
})();
let localLoginEnabled = false;
onMount(async () => {
try {
const res = await fetch('/api/auth/local-enabled', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
localLoginEnabled = data.enabled === true;
}
} catch {}
});
</script>
<svelte:head>
<title>Sign in — sandbox</title>
</svelte:head>
<main class="login">
<article class="card">
<p class="kicker">sandbox / sign in</p>
<h1>Access required</h1>
{#if errorMessage}
<p class="error">! {errorMessage}</p>
{/if}
<a class="btn discord" href="/auth/discord">
<span>Continue with Discord</span>
</a>
{#if localLoginEnabled}
<div class="or">— or —</div>
<form method="POST" class="local-form" action="/auth/local">
<label for="local-password">Local password</label>
<input id="local-password" name="password" type="password" required autocomplete="current-password" />
<button type="submit" class="local-submit">Sign in</button>
</form>
{/if}
</article>
</main>
<style>
/* Monospace, blueprint feel. NOT Editorial. */
:global(body) {
background: #0a0a0a;
color: #c8c8c8;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace;
}
.login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.card {
width: 100%;
max-width: 400px;
background: #0e0e0e;
border: 1px solid #1f1f1f;
padding: 28px 24px;
}
.kicker {
color: #5a5a5a;
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
margin: 0 0 6px;
}
h1 {
font-size: 16px;
font-weight: 600;
color: #d8d8d8;
margin: 0 0 20px;
letter-spacing: 0.5px;
}
.error {
color: #d96a7a;
font-size: 12px;
padding: 8px 10px;
background: rgba(217, 106, 122, 0.08);
border: 1px solid rgba(217, 106, 122, 0.25);
margin: 0 0 16px;
}
.btn {
display: block;
width: 100%;
padding: 9px 12px;
text-align: center;
text-decoration: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: #d8d8d8;
background: #1a1a1a;
border: 1px solid #2a2a2a;
transition: background 0.15s;
}
.btn.discord:hover {
background: #232323;
border-color: #3a3a3a;
}
.or {
text-align: center;
color: #4a4a4a;
font-size: 11px;
margin: 16px 0;
}
.local-form { display: flex; flex-direction: column; gap: 8px; }
.local-form label {
font-size: 11px;
color: #6a6a6a;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
}
.local-form input {
background: #0a0a0a;
border: 1px solid #2a2a2a;
color: #d8d8d8;
padding: 8px 10px;
font-family: inherit;
font-size: 13px;
}
.local-form input:focus { outline: none; border-color: #4a4a4a; }
.local-submit {
background: #1a1a1a;
border: 1px solid #2a2a2a;
color: #d8d8d8;
padding: 8px 12px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.local-submit:hover { background: #232323; }
</style>

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 B

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

4
static/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#050312"/>
<circle cx="16" cy="16" r="12" fill="#e0709a"/>
</svg>

After

Width:  |  Height:  |  Size: 173 B

16
svelte.config.js Normal file
View File

@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false
}),
csrf: { checkOrigin: false }
}
};
export default config;

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 2027,
host: '127.0.0.1',
strictPort: true
}
});