Initial commit: Character sandbox with React+Express frontend/backend, SvelteKit foundation
This commit is contained in:
commit
85454f9737
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.vite/
|
||||||
|
dist/
|
||||||
|
.tmp/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
1
client
Submodule
1
client
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit b9e538e1914933ac4eedbdf75328737e7df70caf
|
||||||
45
package.json
Normal file
45
package.json
Normal 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
17
run.sh
Executable 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
268
scripts/netcup-dns.sh
Executable 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
196
server/ai/trainer.js
Normal 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
103
server/db.js
Normal 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
27
server/index.js
Normal 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
26
server/middleware/auth.js
Normal 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
2102
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
server/package.json
Normal file
20
server/package.json
Normal 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
55
server/routes/ai.js
Normal 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
49
server/routes/auth.js
Normal 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
238
server/routes/characters.js
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import db from '../db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', authenticateToken, (req, res) => {
|
||||||
|
const characters = db.prepare('SELECT * FROM characters WHERE user_id = ? ORDER BY updated_at DESC').all(req.userId);
|
||||||
|
res.json({ characters });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticateToken, (req, res) => {
|
||||||
|
const { name, description, personality_traits, backstory } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
const traits = JSON.stringify(personality_traits || []);
|
||||||
|
db.prepare('INSERT INTO characters (id, user_id, name, description, personality_traits, backstory) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, req.userId, name, description || '', traits, backstory || '');
|
||||||
|
|
||||||
|
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(id);
|
||||||
|
res.status(201).json({ character });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authenticateToken, (req, res) => {
|
||||||
|
const character = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
|
||||||
|
if (!character) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
|
||||||
|
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ?').all(req.params.id);
|
||||||
|
const uiElements = db.prepare('SELECT * FROM character_ui_elements WHERE character_id = ?').all(req.params.id);
|
||||||
|
const brainRules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ?').all(req.params.id);
|
||||||
|
|
||||||
|
res.json({ character, needs, ui_elements: uiElements, brain_rules: brainRules });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticateToken, (req, res) => {
|
||||||
|
const { name, description, personality_traits, backstory, avatar_url } = req.body;
|
||||||
|
const existing = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE characters SET name = ?, description = ?, personality_traits = ?, backstory = ?, avatar_url = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||||
|
.run(
|
||||||
|
name || existing.name,
|
||||||
|
description !== undefined ? description : existing.description,
|
||||||
|
personality_traits ? JSON.stringify(personality_traits) : existing.personality_traits,
|
||||||
|
backstory !== undefined ? backstory : existing.backstory,
|
||||||
|
avatar_url !== undefined ? avatar_url : existing.avatar_url,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const character = db.prepare('SELECT * FROM characters WHERE id = ?').get(req.params.id);
|
||||||
|
res.json({ character });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticateToken, (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM characters WHERE id = ? AND user_id = ?').get(req.params.id, req.userId);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Character not found' });
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM characters WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ message: 'Character deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Needs ---
|
||||||
|
|
||||||
|
router.get('/:id/needs', authenticateToken, (req, res) => {
|
||||||
|
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ?').all(req.params.id);
|
||||||
|
res.json({ needs });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/needs', authenticateToken, (req, res) => {
|
||||||
|
const { name, enabled, initial_value, min_value, max_value, decay_rate, priority } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Need name is required' });
|
||||||
|
|
||||||
|
const needId = uuidv4();
|
||||||
|
db.prepare('INSERT INTO character_needs (id, character_id, name, enabled, initial_value, min_value, max_value, decay_rate, priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(needId, req.params.id, name, enabled !== false ? 1 : 0, initial_value || 50, min_value || 0, max_value || 100, decay_rate || 1, priority || 0);
|
||||||
|
|
||||||
|
const need = db.prepare('SELECT * FROM character_needs WHERE id = ?').get(needId);
|
||||||
|
res.status(201).json({ need });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/needs/:needId', authenticateToken, (req, res) => {
|
||||||
|
const { name, enabled, initial_value, min_value, max_value, decay_rate, priority } = req.body;
|
||||||
|
const existing = db.prepare('SELECT * FROM character_needs WHERE id = ? AND character_id = ?').get(req.params.needId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Need not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE character_needs SET name = ?, enabled = ?, initial_value = ?, min_value = ?, max_value = ?, decay_rate = ?, priority = ? WHERE id = ?')
|
||||||
|
.run(name || existing.name, enabled !== undefined ? (enabled ? 1 : 0) : existing.enabled, initial_value ?? existing.initial_value, min_value ?? existing.min_value, max_value ?? existing.max_value, decay_rate ?? existing.decay_rate, priority ?? existing.priority, req.params.needId);
|
||||||
|
|
||||||
|
const need = db.prepare('SELECT * FROM character_needs WHERE id = ?').get(req.params.needId);
|
||||||
|
res.json({ need });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/needs/:needId', authenticateToken, (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM character_needs WHERE id = ? AND character_id = ?').get(req.params.needId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Need not found' });
|
||||||
|
db.prepare('DELETE FROM character_needs WHERE id = ?').run(req.params.needId);
|
||||||
|
res.json({ message: 'Need deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UI Elements ---
|
||||||
|
|
||||||
|
router.get('/:id/ui-elements', authenticateToken, (req, res) => {
|
||||||
|
const elements = db.prepare('SELECT * FROM character_ui_elements WHERE character_id = ?').all(req.params.id);
|
||||||
|
res.json({ ui_elements: elements });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/ui-elements', authenticateToken, (req, res) => {
|
||||||
|
const { need_id, element_type, config } = req.body;
|
||||||
|
if (!element_type) return res.status(400).json({ error: 'Element type is required' });
|
||||||
|
|
||||||
|
const elementId = uuidv4();
|
||||||
|
db.prepare('INSERT INTO character_ui_elements (id, character_id, need_id, element_type, config) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(elementId, req.params.id, need_id || null, element_type, JSON.stringify(config || {}));
|
||||||
|
|
||||||
|
const element = db.prepare('SELECT * FROM character_ui_elements WHERE id = ?').get(elementId);
|
||||||
|
res.status(201).json({ ui_element: element });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/ui-elements/:elementId', authenticateToken, (req, res) => {
|
||||||
|
const { need_id, element_type, config } = req.body;
|
||||||
|
const existing = db.prepare('SELECT * FROM character_ui_elements WHERE id = ? AND character_id = ?').get(req.params.elementId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'UI element not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE character_ui_elements SET need_id = ?, element_type = ?, config = ? WHERE id = ?')
|
||||||
|
.run(need_id !== undefined ? need_id : existing.need_id, element_type || existing.element_type, config ? JSON.stringify(config) : existing.config, req.params.elementId);
|
||||||
|
|
||||||
|
const element = db.prepare('SELECT * FROM character_ui_elements WHERE id = ?').get(req.params.elementId);
|
||||||
|
res.json({ ui_element: element });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/ui-elements/:elementId', authenticateToken, (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM character_ui_elements WHERE id = ? AND character_id = ?').get(req.params.elementId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'UI element not found' });
|
||||||
|
db.prepare('DELETE FROM character_ui_elements WHERE id = ?').run(req.params.elementId);
|
||||||
|
res.json({ message: 'UI element deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Brain Rules ---
|
||||||
|
|
||||||
|
router.get('/:id/brain-rules', authenticateToken, (req, res) => {
|
||||||
|
const rules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ?').all(req.params.id);
|
||||||
|
res.json({ brain_rules: rules });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/brain-rules', authenticateToken, (req, res) => {
|
||||||
|
const { condition, action, priority, enabled } = req.body;
|
||||||
|
if (!condition || !action) return res.status(400).json({ error: 'Condition and action are required' });
|
||||||
|
|
||||||
|
const ruleId = uuidv4();
|
||||||
|
db.prepare('INSERT INTO character_brain_rules (id, character_id, condition, action, priority, enabled) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(ruleId, req.params.id, JSON.stringify(condition), JSON.stringify(action), priority || 0, enabled !== false ? 1 : 0);
|
||||||
|
|
||||||
|
const rule = db.prepare('SELECT * FROM character_brain_rules WHERE id = ?').get(ruleId);
|
||||||
|
res.status(201).json({ brain_rule: rule });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/brain-rules/:ruleId', authenticateToken, (req, res) => {
|
||||||
|
const { condition, action, priority, enabled } = req.body;
|
||||||
|
const existing = db.prepare('SELECT * FROM character_brain_rules WHERE id = ? AND character_id = ?').get(req.params.ruleId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Brain rule not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE character_brain_rules SET condition = ?, action = ?, priority = ?, enabled = ? WHERE id = ?')
|
||||||
|
.run(condition ? JSON.stringify(condition) : existing.condition, action ? JSON.stringify(action) : existing.action, priority ?? existing.priority, enabled !== undefined ? (enabled ? 1 : 0) : existing.enabled, req.params.ruleId);
|
||||||
|
|
||||||
|
const rule = db.prepare('SELECT * FROM character_brain_rules WHERE id = ?').get(req.params.ruleId);
|
||||||
|
res.json({ brain_rule: rule });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/brain-rules/:ruleId', authenticateToken, (req, res) => {
|
||||||
|
const existing = db.prepare('SELECT * FROM character_brain_rules WHERE id = ? AND character_id = ?').get(req.params.ruleId, req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Brain rule not found' });
|
||||||
|
db.prepare('DELETE FROM character_brain_rules WHERE id = ?').run(req.params.ruleId);
|
||||||
|
res.json({ message: 'Brain rule deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Simulation ---
|
||||||
|
|
||||||
|
router.post('/:id/simulate', authenticateToken, (req, res) => {
|
||||||
|
const { steps = 10, events = [] } = req.body;
|
||||||
|
const needs = db.prepare('SELECT * FROM character_needs WHERE character_id = ? AND enabled = 1').all(req.params.id);
|
||||||
|
const rules = db.prepare('SELECT * FROM character_brain_rules WHERE character_id = ? AND enabled = 1 ORDER BY priority DESC').all(req.params.id);
|
||||||
|
|
||||||
|
const simulation = [];
|
||||||
|
let currentValues = {};
|
||||||
|
needs.forEach(n => { currentValues[n.name] = { ...n }; });
|
||||||
|
|
||||||
|
for (let step = 0; step < steps; step++) {
|
||||||
|
for (const need of needs) {
|
||||||
|
if (currentValues[need.name]) {
|
||||||
|
let newVal = currentValues[need.name].current_value || need.initial_value;
|
||||||
|
newVal -= need.decay_rate;
|
||||||
|
newVal = Math.max(need.min_value, Math.min(need.max_value, newVal));
|
||||||
|
currentValues[need.name].current_value = newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
for (const need of needs) {
|
||||||
|
if (event[need.name]) {
|
||||||
|
let val = currentValues[need.name].current_value || need.initial_value;
|
||||||
|
val += event[need.name];
|
||||||
|
val = Math.max(need.min_value, Math.min(need.max_value, val));
|
||||||
|
currentValues[need.name].current_value = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggeredRules = [];
|
||||||
|
for (const rule of rules) {
|
||||||
|
const cond = JSON.parse(rule.condition);
|
||||||
|
const needVal = currentValues[cond.need]?.current_value ?? need.initial_value;
|
||||||
|
if (cond.operator === 'lt' && needVal < cond.value) {
|
||||||
|
triggeredRules.push(rule);
|
||||||
|
} else if (cond.operator === 'gt' && needVal > cond.value) {
|
||||||
|
triggeredRules.push(rule);
|
||||||
|
} else if (cond.operator === 'eq' && needVal === cond.value) {
|
||||||
|
triggeredRules.push(rule);
|
||||||
|
} else if (cond.operator === 'lte' && needVal <= cond.value) {
|
||||||
|
triggeredRules.push(rule);
|
||||||
|
} else if (cond.operator === 'gte' && needVal >= cond.value) {
|
||||||
|
triggeredRules.push(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = {};
|
||||||
|
for (const need of needs) {
|
||||||
|
snapshot[need.name] = Math.round((currentValues[need.name].current_value || need.initial_value) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
simulation.push({ step, values: snapshot, triggered_rules: triggeredRules.map(r => ({ id: r.id, action: JSON.parse(r.action) })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ simulation, final_values: simulation[simulation.length - 1]?.values });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
84
server/routes/fragments.js
Normal file
84
server/routes/fragments.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import db from '../db.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/search', authenticateToken, (req, res) => {
|
||||||
|
const { q, tag } = req.query;
|
||||||
|
let query = `SELECT f.*, l.name as lorebook_name FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id WHERE l.user_id = ?`;
|
||||||
|
const params = [req.userId];
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
query += ` AND (f.title LIKE ? OR f.content LIKE ?)`;
|
||||||
|
params.push(`%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
if (tag) {
|
||||||
|
query += ` AND f.tags LIKE ?`;
|
||||||
|
params.push(`%"${tag}"%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY f.updated_at DESC LIMIT 50`;
|
||||||
|
const fragments = db.prepare(query).all(...params);
|
||||||
|
res.json({ fragments });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authenticateToken, (req, res) => {
|
||||||
|
const fragment = db.prepare(`
|
||||||
|
SELECT f.*, l.name as lorebook_name, l.user_id
|
||||||
|
FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
|
||||||
|
WHERE f.id = ? AND l.user_id = ?
|
||||||
|
`).get(req.params.id, req.userId);
|
||||||
|
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
|
||||||
|
res.json({ fragment });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/lorebook/:lorebookId', authenticateToken, (req, res) => {
|
||||||
|
const { title, content, tags, linked_characters } = req.body;
|
||||||
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
|
const lorebook = db.prepare('SELECT * FROM lorebooks WHERE id = ? AND user_id = ?').get(req.params.lorebookId, req.userId);
|
||||||
|
if (!lorebook) return res.status(404).json({ error: 'Lorebook not found' });
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
db.prepare('INSERT INTO fragments (id, lorebook_id, title, content, tags, linked_characters) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(id, req.params.lorebookId, title, content || '', JSON.stringify(tags || []), JSON.stringify(linked_characters || []));
|
||||||
|
|
||||||
|
const fragment = db.prepare('SELECT * FROM fragments WHERE id = ?').get(id);
|
||||||
|
res.status(201).json({ fragment });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticateToken, (req, res) => {
|
||||||
|
const { title, content, tags, linked_characters } = req.body;
|
||||||
|
const fragment = db.prepare(`
|
||||||
|
SELECT f.* FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
|
||||||
|
WHERE f.id = ? AND l.user_id = ?
|
||||||
|
`).get(req.params.id, req.userId);
|
||||||
|
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE fragments SET title = ?, content = ?, tags = ?, linked_characters = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||||
|
.run(
|
||||||
|
title || fragment.title,
|
||||||
|
content !== undefined ? content : fragment.content,
|
||||||
|
tags ? JSON.stringify(tags) : fragment.tags,
|
||||||
|
linked_characters ? JSON.stringify(linked_characters) : fragment.linked_characters,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db.prepare('SELECT * FROM fragments WHERE id = ?').get(req.params.id);
|
||||||
|
res.json({ fragment: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticateToken, (req, res) => {
|
||||||
|
const fragment = db.prepare(`
|
||||||
|
SELECT f.* FROM fragments f JOIN lorebooks l ON f.lorebook_id = l.id
|
||||||
|
WHERE f.id = ? AND l.user_id = ?
|
||||||
|
`).get(req.params.id, req.userId);
|
||||||
|
if (!fragment) return res.status(404).json({ error: 'Fragment not found' });
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM fragments WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ message: 'Fragment deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
51
server/routes/lorebooks.js
Normal file
51
server/routes/lorebooks.js
Normal 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
BIN
server/sandbox.db-shm
Normal file
Binary file not shown.
BIN
server/sandbox.db-wal
Normal file
BIN
server/sandbox.db-wal
Normal file
Binary file not shown.
17
server/serve-frontend.js
Normal file
17
server/serve-frontend.js
Normal 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
13
src/app.d.ts
vendored
Normal 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
15
src/app.html
Normal 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
43
src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
8
src/lib/server/allowlist.ts
Normal file
8
src/lib/server/allowlist.ts
Normal 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;
|
||||||
|
}
|
||||||
74
src/lib/server/auth/discord.ts
Normal file
74
src/lib/server/auth/discord.ts
Normal 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 };
|
||||||
|
}
|
||||||
24
src/lib/server/auth/index.ts
Normal file
24
src/lib/server/auth/index.ts
Normal 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';
|
||||||
38
src/lib/server/auth/local.ts
Normal file
38
src/lib/server/auth/local.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
96
src/lib/server/auth/session.ts
Normal file
96
src/lib/server/auth/session.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/lib/server/db/index.ts
Normal file
50
src/lib/server/db/index.ts
Normal 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);
|
||||||
|
`);
|
||||||
8
src/routes/+layout.server.ts
Normal file
8
src/routes/+layout.server.ts
Normal 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
85
src/routes/+layout.svelte
Normal 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
391
src/routes/+page.svelte
Normal 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>
|
||||||
6
src/routes/api/auth/local-enabled/+server.ts
Normal file
6
src/routes/api/auth/local-enabled/+server.ts
Normal 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() });
|
||||||
|
};
|
||||||
14
src/routes/api/auth/logout/+server.ts
Normal file
14
src/routes/api/auth/logout/+server.ts
Normal 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');
|
||||||
|
};
|
||||||
10
src/routes/api/auth/me/+server.ts
Normal file
10
src/routes/api/auth/me/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
53
src/routes/api/characters/+server.ts
Normal file
53
src/routes/api/characters/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
77
src/routes/api/characters/[id]/+server.ts
Normal file
77
src/routes/api/characters/[id]/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
39
src/routes/api/characters/[id]/json/+server.ts
Normal file
39
src/routes/api/characters/[id]/json/+server.ts
Normal 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"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
6
src/routes/api/health/+server.ts
Normal file
6
src/routes/api/health/+server.ts
Normal 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' });
|
||||||
|
};
|
||||||
10
src/routes/auth/discord/+server.ts
Normal file
10
src/routes/auth/discord/+server.ts
Normal 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));
|
||||||
|
};
|
||||||
53
src/routes/auth/discord/callback/+server.ts
Normal file
53
src/routes/auth/discord/callback/+server.ts
Normal 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);
|
||||||
|
};
|
||||||
43
src/routes/auth/local/+server.ts
Normal file
43
src/routes/auth/local/+server.ts
Normal 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);
|
||||||
|
};
|
||||||
150
src/routes/login/+page.svelte
Normal file
150
src/routes/login/+page.svelte
Normal 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
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 B |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
4
static/favicon.svg
Normal file
4
static/favicon.svg
Normal 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
16
svelte.config.js
Normal 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
14
tsconfig.json
Normal 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
11
vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user