1
0
Fork 0

Integrate backend into storywriter

This commit is contained in:
Pabloader 2026-04-16 15:36:29 +00:00
parent 64268b42c9
commit d2d3ad42d8
21 changed files with 264 additions and 74 deletions

View File

@ -2,6 +2,16 @@ import { db } from "./db.ts";
export const TOKEN_TTL_DAYS = parseInt(process.env.TOKEN_TTL_DAYS ?? "30");
export const issueToken = async (userId: number): Promise<string> => {
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000)
.toISOString()
.replace("T", " ")
.slice(0, 19);
await db`INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (${userId}, ${token}, ${expiresAt})`;
return token;
};
export const validateToken = async (token: string): Promise<number | null> => {
const [row] = await db`
SELECT user_id FROM auth_tokens

18
backend/src/login.ts Normal file
View File

@ -0,0 +1,18 @@
import { db } from "./db.ts";
import { issueToken } from "./auth.ts";
const [username, password] = process.argv.slice(2);
if (!username || !password) {
console.error("Usage: bun run login <username> <password>");
process.exit(1);
}
const [user] = await db`SELECT id, password_hash FROM users WHERE username = ${username}`;
if (!user || !await Bun.password.verify(password, user.password_hash as string)) {
console.error("Invalid credentials");
process.exit(1);
}
console.log(await issueToken(user.id as number));

View File

@ -1,5 +1,5 @@
import { db } from "../db.ts";
import { TOKEN_TTL_DAYS } from "../auth.ts";
import { issueToken } from "../auth.ts";
export const handleLogin = async (req: Request): Promise<Response> => {
if (req.method !== "POST") {
@ -30,16 +30,6 @@ export const handleLogin = async (req: Request): Promise<Response> => {
return Response.json({ error: "Invalid credentials" }, { status: 401 });
}
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + TOKEN_TTL_DAYS * 86_400_000)
.toISOString()
.replace("T", " ")
.slice(0, 19);
await db`
INSERT INTO auth_tokens (user_id, token, expires_at)
VALUES (${user.id}, ${token}, ${expiresAt})
`;
const token = await issueToken(user.id as number);
return Response.json({ token });
};

View File

@ -7,6 +7,8 @@
--border: #3e3d32;
--accent: #f92672;
--accent-alt: #a6e22e;
--danger: #c4265e;
--danger-alt: #f92672;
--text: #f8f8f2;
--text-muted: #75715e;
--text-dim: #cfcfc2;

View File

@ -10,6 +10,7 @@
"backend": "bun backend/src/index.ts",
"backend:dev": "bun --hot backend/src/index.ts",
"register": "bun backend/src/register.ts",
"login": "bun backend/src/login.ts",
"docker:build": "docker build -t git.pabloader.ru/pabloid/tsgames:latest backend/",
"docker:push": "docker push git.pabloader.ru/pabloid/tsgames:latest",
"docker:update": "docker service update --force --image git.pabloader.ru/pabloid/tsgames:latest tsgames_backend",

View File

@ -0,0 +1,4 @@
.error {
color: var(--danger);
font-size: 13px;
}

View File

@ -58,6 +58,7 @@ button {
background: transparent;
transition: all var(--transition);
color: var(--text);
width: fit-content;
}
.buttonPrimary {

View File

@ -0,0 +1,65 @@
import { useState } from "preact/hooks";
import clsx from "clsx";
import { login } from "@common/storage";
import { useInputState } from "@common/hooks/useInputState";
import ui from "../assets/ui.module.css";
import styles from "../assets/login.module.css";
interface IProps {
onLogin?: () => void;
class?: string;
className?: string;
}
export const LoginForm = ({ onLogin, ['class']: cls, className }: IProps) => {
const [username, handleUsername] = useInputState();
const [password, handlePassword] = useInputState();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
await login(username, password);
onLogin?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<form class={clsx(ui.form, cls, className)} onSubmit={handleSubmit}>
<div class={ui.formGroup}>
<label class={ui.label}>Username</label>
<input
class={ui.input}
type="text"
value={username}
onInput={handleUsername}
autocomplete="username"
required
/>
</div>
<div class={ui.formGroup}>
<label class={ui.label}>Password</label>
<input
class={ui.input}
type="password"
value={password}
onInput={handlePassword}
autocomplete="current-password"
required
/>
</div>
{error && <span class={styles.error}>{error}</span>}
<button class={ui.buttonPrimary} type="submit" disabled={loading}>
{loading ? 'Logging in…' : 'Log in'}
</button>
</form>
);
};

View File

@ -1,10 +1,12 @@
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
import { loadObject, saveObject } from "@common/storage";
import { storedStateKey, storedReducerKey } from "@common/hooks/useStored";
declare const GAME: string;
export const remoteStateKey = (key: string) => `useRemoteState.${GAME}.${key}`;
export const remoteReducerKey = (key: string) => `useRemoteReducer.${GAME}.${key}`;
export const useRemoteState = <T>(key: string, initialValue: T) => {
const storedKey = `useRemoteState.${GAME}.${key}`;
const storedKey = remoteStateKey(key);
const [value, setValue] = useState<T>(initialValue);
@ -34,10 +36,35 @@ const wrapReducer = <T, A>(reducer: Reducer<T, A>): Reducer<T, A | HydrateAction
return reducer(state, action);
};
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
const storedKey = `useRemoteReducer.${GAME}.${key}`;
/** Copies a useStoredState value into the useRemoteState slot and syncs it remotely. */
export const importStoredState = <T>(key: string): boolean => {
const raw = localStorage.getItem(storedStateKey(key));
if (!raw) return false;
saveObject(remoteStateKey(key), JSON.parse(raw) as T);
return true;
};
const [state, dispatch] = useReducer(wrapReducer(reducer), initialValue);
/** Copies a useStoredReducer value into the useRemoteReducer slot and syncs it remotely. */
export const importStoredReducer = <T>(key: string): boolean => {
const raw = localStorage.getItem(storedReducerKey(key));
if (!raw) return false;
saveObject(remoteReducerKey(key), JSON.parse(raw) as T);
return true;
};
export const useRemoteReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
const storedKey = remoteReducerKey(key);
const getInitialValue = (): T => {
try {
const storedValue = localStorage.getItem(storedKey);
return storedValue ? JSON.parse(storedValue) : initialValue;
} catch {
return initialValue;
}
};
const [state, dispatch] = useReducer(wrapReducer(reducer), undefined, getInitialValue);
useEffect(() => {
loadObject<T>(storedKey, initialValue).then((loaded) => {

View File

@ -1,9 +1,10 @@
import { useEffect, useReducer, useState, type Dispatch, type Reducer, type StateUpdater } from "preact/hooks";
declare const GAME: string;
export const storedStateKey = (key: string) => `useStoredState.${GAME}.${key}`;
export const storedReducerKey = (key: string) => `useStoredReducer.${GAME}.${key}`;
export const useStoredState = <T>(key: string, initialValue: T) => {
const storedKey = `useStoredState.${GAME}.${key}`;
const storedKey = storedStateKey(key);
const [value, setValue] = useState<T>(() => {
try {
@ -22,7 +23,7 @@ export const useStoredState = <T>(key: string, initialValue: T) => {
};
export const useStoredReducer = <T, A>(key: string, reducer: Reducer<T, A>, initialValue: T) => {
const storedKey = `useStoredReducer.${GAME}.${key}`;
const storedKey = storedReducerKey(key);
const getInitialValue = (): T => {
try {

View File

@ -1,62 +1,93 @@
const saveThrottleDelay = 2000;
const SAVE_THROTTLE_MS = 2000;
const TOKEN_KEY = '__storage_token__';
const BASE_URL = '/storage/api';
const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>();
// ─── Auth ────────────────────────────────────────────────────
let token: string | null = localStorage.getItem(TOKEN_KEY);
export const isLoggedIn = () => token !== null;
export const login = async (username: string, password: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? 'Login failed');
token = json.token;
localStorage.setItem(TOKEN_KEY, json.token);
};
export const logout = () => {
token = null;
localStorage.removeItem(TOKEN_KEY);
};
// ─── Storage ─────────────────────────────────────────────────
export const loadObject = async <T>(key: string, defaultObject: T): Promise<T> => {
let localObject: Partial<T> = {};
try {
const json = localStorage.getItem(key);
if (json) {
localObject = JSON.parse(json);
}
if (json) localObject = JSON.parse(json);
} catch { }
let remoteObject: Partial<T> = {};
try {
// TODO loading from a remote source
const compressedData = new Blob([]);
const decompressedData = await decompressBlob(compressedData);
remoteObject = JSON.parse(await decompressedData.text());
} catch { }
if (token) {
try {
const res = await fetch(`${BASE_URL}/storage/${key}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) remoteObject = await res.json();
} catch { }
}
return { ...defaultObject, ...localObject, ...remoteObject };
}
export const saveObject = <T>(key: string, obj: T) => {
const existing = pendingSaves.get(key);
if (existing) clearTimeout(existing);
pendingSaves.set(key, setTimeout(() => {
pendingSaves.delete(key);
doSaveObject(key, obj);
}, saveThrottleDelay));
};
const doSaveObject = async <T>(key: string, obj: T) => {
const saveData = JSON.stringify(obj);
try {
localStorage.setItem(key, saveData);
} catch { }
try {
const compressedData = await compressBlob(saveData);
// TODO saving to remote storage
void compressedData;
} catch {
if (token) {
const existing = pendingSaves.get(key);
if (existing) clearTimeout(existing);
pendingSaves.set(key, setTimeout(() => {
pendingSaves.delete(key);
saveRemote(key, saveData);
}, SAVE_THROTTLE_MS));
}
}
};
const saveRemote = async (key: string, saveData: string) => {
try {
await fetch(`${BASE_URL}/storage/${key}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: saveData,
});
} catch { }
};
// ─── Compression utils ───────────────────────────────────────
export const compressBlob = async (blob: Blob | string): Promise<Blob> => {
if (typeof blob === 'string') {
blob = new Blob([blob]);
}
if (typeof blob === 'string') blob = new Blob([blob]);
const cs = new CompressionStream("gzip");
const compressedStream = blob.stream().pipeThrough(cs);
return await new Response(compressedStream).blob();
return await new Response(blob.stream().pipeThrough(cs)).blob();
}
export const decompressBlob = async (blob: Blob): Promise<Blob> => {
const ds = new DecompressionStream("gzip");
const decompressedStream = blob.stream().pipeThrough(ds);
return await new Response(decompressedStream).blob();
return await new Response(blob.stream().pipeThrough(ds)).blob();
}

View File

@ -2,8 +2,6 @@ import { render } from "preact";
import styles from "./assets/game.module.css";
import type { Game } from "../../../build/isGame";
declare const GAMES: Game[];
function GameList({ games }: { games: Game[] }) {
return (
<div class={styles.games}>

View File

@ -71,10 +71,6 @@
min-height: unset;
}
.button {
composes: button from '@common/assets/ui.module.css';
}
.buttonSecondary {
composes: buttonSecondary from '@common/assets/ui.module.css';
}
@ -84,6 +80,10 @@
color: var(--accent-text);
}
.buttonDanger {
composes: buttonDanger from '@common/assets/ui.module.css';
}
.inputRow {
composes: inputRow from '@common/assets/ui.module.css';
}

View File

@ -10,13 +10,14 @@ import { SamplingSettings } from "./settings/sampling";
import { SystemInstructionSettings } from "./settings/system-instruction";
import { ImageSettings } from "./settings/image";
import { UserSettings } from "./settings/user";
import { LoginSettings } from "./settings/login";
interface Props {
open: boolean;
onClose: () => void;
}
type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image";
type Tab = "banned-tokens" | "system-instruction" | "chat-system-instruction" | "continue-prompt" | "connection" | "user" | "sampling" | "image" | "login";
const TABS: { id: Tab; label: string }[] = [
{ id: "connection", label: "Connection" },
@ -27,6 +28,7 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "system-instruction", label: "System Instruction" },
{ id: "continue-prompt", label: "Continue Prompt" },
{ id: "chat-system-instruction", label: "Chat System Instruction" },
{ id: "login", label: "Login" },
];
export const SettingsModal = ({ open, onClose }: Props) => {
@ -60,6 +62,7 @@ export const SettingsModal = ({ open, onClose }: Props) => {
{activeTab === "connection" && <ConnectionSettings />}
{activeTab === "sampling" && <SamplingSettings />}
{activeTab === "image" && <ImageSettings />}
{activeTab === "login" && <LoginSettings />}
</Modal>
);
};

View File

@ -4,7 +4,7 @@ import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate";
import { fuzzyMatch } from "@common/utils";
import clsx from "clsx";
import { useMemo, useRef } from "preact/hooks";
import { useEffect, useMemo, useRef } from "preact/hooks";
import styles from "../../assets/settings-modal.module.css";
import { useAppState } from "../../contexts/state";
import LLM from "../../utils/llm";

View File

@ -114,7 +114,7 @@ export const ImageSettings = () => {
/>
</div>
<div>
<button class={styles.button} onClick={handleReset}>
<button class={styles.buttonSecondary} onClick={handleReset}>
Reset to defaults
</button>
</div>

View File

@ -0,0 +1,36 @@
import { useState } from "preact/hooks";
import { LoginForm } from "@common/components/LoginForm";
import { isLoggedIn, logout } from "@common/storage";
import { importStoredReducer } from "@common/hooks/useRemote";
import { STATE_KEY } from "../../contexts/state";
import styles from "../../assets/settings-modal.module.css";
export const LoginSettings = () => {
const [loggedIn, setLoggedIn] = useState(isLoggedIn);
const [imported, setImported] = useState(false);
const handleLogin = () => setLoggedIn(true);
const handleLogout = () => {
logout();
setLoggedIn(false);
};
const handleImport = () => {
if (importStoredReducer(STATE_KEY)) setImported(true);
};
if (loggedIn) {
return (
<div class={styles.form}>
<span class={styles.label}>Logged in. Save data is synced to the server.</span>
<button class={styles.buttonSecondary} onClick={handleImport} disabled={imported}>
{imported ? 'Imported' : 'Import settings from local storage'}
</button>
<button class={styles.buttonDanger} onClick={handleLogout}>Log out</button>
</div>
);
}
return <LoginForm onLogin={handleLogin} class={styles.form} />;
};

View File

@ -14,12 +14,12 @@ interface ParamConfig {
}
const PARAMS: ParamConfig[] = [
{ key: "temperature", label: "Temperature", min: 0, max: 4, step: 0.01 },
{ key: "top_p", label: "Top P", min: 0, max: 1, step: 0.01 },
{ key: "top_k", label: "Top K", min: 0, max: 200, step: 1 },
{ key: "min_p", label: "Min P", min: 0, max: 1, step: 0.01 },
{ key: "repetition_penalty", label: "Repetition Penalty", min: 1, max: 3, step: 0.01 },
{ key: "frequency_penalty", label: "Frequency Penalty", min: -2, max: 2, step: 0.01 },
{ key: "temperature", label: "Temperature", min: 0, max: 4, step: 0.01 },
{ key: "top_p", label: "Top P", min: 0, max: 1, step: 0.01 },
{ key: "top_k", label: "Top K", min: 0, max: 200, step: 1 },
{ key: "min_p", label: "Min P", min: 0, max: 1, step: 0.01 },
{ key: "repetition_penalty", label: "Repetition Penalty", min: 1, max: 3, step: 0.01 },
{ key: "frequency_penalty", label: "Frequency Penalty", min: -2, max: 2, step: 0.01 },
];
function ParamRow({ config, initialValue, onChange }: {
@ -83,7 +83,7 @@ export const SamplingSettings = () => {
/>
))}
<div>
<button class={styles.button} onClick={handleReset}>
<button class={styles.buttonSecondary} onClick={handleReset}>
Reset to defaults
</button>
</div>

View File

@ -1,4 +1,4 @@
import { useStoredReducer } from "@common/hooks/useStored";
import { useRemoteReducer } from "@common/hooks/useRemote";
import { createContext } from "preact";
import { useContext, useMemo } from "preact/hooks";
import Chapters from "../utils/chapters";
@ -695,8 +695,10 @@ export const useAppState = () => useContext(StateContext);
// ─── Provider ────────────────────────────────────────────────────────────────
export const STATE_KEY = 'state';
export const StateContextProvider = ({ children }: { children?: any }) => {
const [state, dispatch] = useStoredReducer('state', reducer, DEFAULT_STATE);
const [state, dispatch] = useRemoteReducer(STATE_KEY, reducer, DEFAULT_STATE);
const value = useMemo<AppState>(() => {
const currentWorld = state.worlds.find(w => w.id === state.currentWorldId) ?? null;

View File

@ -1,5 +1,3 @@
declare const GAME: string;
async function main() {
const { default: runGame }: { default: RunGame } = await import(`./games/${GAME}`);
await runGame();

3
src/types.d.ts vendored
View File

@ -3,6 +3,9 @@ type Rect = [number, number, number, number];
type RunGame = () => Promise<void>;
declare const GAME: string;
declare const GAMES: Game[];
declare namespace WebAssembly {
type tc = 'i32' | 'i64' | 'f32' | 'f64';
export class Function {