Add web frontend

This commit is contained in:
2026-02-13 23:15:12 -08:00
parent 5faa57a741
commit 16a668b6ee
37 changed files with 4149 additions and 69 deletions

9
AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
# AGENTS.md
## Server Change Verification
- Whenever you modify files under `/Users/buzzert/src/sybil-2/server`, you must verify startup locally before finalizing.
- Verification means running `npm run dev` in `/Users/buzzert/src/sybil-2/server` and confirming:
- migrations run (or report already up-to-date), and
- the server reaches a listening state without crashing.
- Include the verification result in your final response.

2
server/.gitignore vendored
View File

@@ -3,7 +3,7 @@ node_modules
.env.* .env.*
dev.db dev.db
*.db *.db
*.db-journal
prisma/migrations prisma/migrations
.DS_Store .DS_Store
dist dist

View File

@@ -13,12 +13,20 @@ Backend API for:
```bash ```bash
cp .env.example .env cp .env.example .env
npm run db:migrate
npm run dev npm run dev
``` ```
Migrations are applied automatically on server startup (`prisma migrate deploy`).
Open docs: `http://localhost:8787/docs` Open docs: `http://localhost:8787/docs`
## Run Modes
- `npm run dev`: runs `src/index.ts` with `tsx` in watch mode (auto-restart on file changes). Use for local development.
- `npm run start`: runs compiled `dist/index.js` with Node.js (no watch mode). Use for production-like runs.
Both modes run startup checks (`predev` / `prestart`) and apply migrations at app boot.
## Auth ## Auth
Set `ADMIN_TOKEN` and send: Set `ADMIN_TOKEN` and send:
@@ -34,6 +42,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
## API ## API
- `GET /health` - `GET /health`
- `GET /v1/auth/session`
- `GET /v1/chats` - `GET /v1/chats`
- `POST /v1/chats` - `POST /v1/chats`
- `GET /v1/chats/:chatId` - `GET /v1/chats/:chatId`
@@ -41,6 +50,8 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `POST /v1/chat-completions` - `POST /v1/chat-completions`
- `POST /v1/chat-completions/stream` (SSE) - `POST /v1/chat-completions/stream` (SSE)
When `chatId` is provided to completion endpoints, you can send full conversation context. The server now stores only new non-assistant messages to avoid duplicate history rows.
`POST /v1/chat-completions` body example: `POST /v1/chat-completions` body example:
```json ```json

View File

@@ -19,11 +19,11 @@
"fastify": "^5.7.2", "fastify": "^5.7.2",
"openai": "^6.16.0", "openai": "^6.16.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"prisma": "^6.6.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
@@ -64,7 +64,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -81,7 +80,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -98,7 +96,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -115,7 +112,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -132,7 +128,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -149,7 +144,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -166,7 +160,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -183,7 +176,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -200,7 +192,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -217,7 +208,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -234,7 +224,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -251,7 +240,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -268,7 +256,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -285,7 +272,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -302,7 +288,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -319,7 +304,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -336,7 +320,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -353,7 +336,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -370,7 +352,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -387,7 +368,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -404,7 +384,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -421,7 +400,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -438,7 +416,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -455,7 +432,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -472,7 +448,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -489,7 +464,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -826,7 +800,6 @@
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.6.0.tgz",
"integrity": "sha512-d8FlXRHsx72RbN8nA2QCRORNv5AcUnPXgtPvwhXmYkQSMF/j9cKaJg+9VcUzBRXGy9QBckNzEQDEJZdEOZ+ubA==", "integrity": "sha512-d8FlXRHsx72RbN8nA2QCRORNv5AcUnPXgtPvwhXmYkQSMF/j9cKaJg+9VcUzBRXGy9QBckNzEQDEJZdEOZ+ubA==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"esbuild": ">=0.12 <1", "esbuild": ">=0.12 <1",
@@ -837,14 +810,12 @@
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.6.0.tgz",
"integrity": "sha512-DL6n4IKlW5k2LEXzpN60SQ1kP/F6fqaCgU/McgaYsxSf43GZ8lwtmXLke9efS+L1uGmrhtBUP4npV/QKF8s2ZQ==", "integrity": "sha512-DL6n4IKlW5k2LEXzpN60SQ1kP/F6fqaCgU/McgaYsxSf43GZ8lwtmXLke9efS+L1uGmrhtBUP4npV/QKF8s2ZQ==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.6.0.tgz",
"integrity": "sha512-nC0IV4NHh7500cozD1fBoTwTD1ydJERndreIjpZr/S3mno3P6tm8qnXmIND5SwUkibNeSJMpgl4gAnlqJ/gVlg==", "integrity": "sha512-nC0IV4NHh7500cozD1fBoTwTD1ydJERndreIjpZr/S3mno3P6tm8qnXmIND5SwUkibNeSJMpgl4gAnlqJ/gVlg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@@ -858,14 +829,12 @@
"version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a", "version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a.tgz",
"integrity": "sha512-JzRaQ5Em1fuEcbR3nUsMNYaIYrOT1iMheenjCvzZblJcjv/3JIuxXN7RCNT5i6lRkLodW5ojCGhR7n5yvnNKrw==", "integrity": "sha512-JzRaQ5Em1fuEcbR3nUsMNYaIYrOT1iMheenjCvzZblJcjv/3JIuxXN7RCNT5i6lRkLodW5ojCGhR7n5yvnNKrw==",
"devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.6.0.tgz",
"integrity": "sha512-Ohfo8gKp05LFLZaBlPUApM0M7k43a0jmo86YY35u1/4t+vuQH9mRGU7jGwVzGFY3v+9edeb/cowb1oG4buM1yw==", "integrity": "sha512-Ohfo8gKp05LFLZaBlPUApM0M7k43a0jmo86YY35u1/4t+vuQH9mRGU7jGwVzGFY3v+9edeb/cowb1oG4buM1yw==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.6.0", "@prisma/debug": "6.6.0",
@@ -877,7 +846,6 @@
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.6.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.6.0.tgz",
"integrity": "sha512-3qCwmnT4Jh5WCGUrkWcc6VZaw0JY7eWN175/pcb5Z6FiLZZ3ygY93UX0WuV41bG51a6JN/oBH0uywJ90Y+V5eA==", "integrity": "sha512-3qCwmnT4Jh5WCGUrkWcc6VZaw0JY7eWN175/pcb5Z6FiLZZ3ygY93UX0WuV41bG51a6JN/oBH0uywJ90Y+V5eA==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.6.0" "@prisma/debug": "6.6.0"
@@ -1061,7 +1029,6 @@
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -1103,7 +1070,6 @@
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.4" "debug": "^4.3.4"
@@ -1276,7 +1242,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1679,7 +1644,6 @@
"version": "6.6.0", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.6.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.6.0.tgz",
"integrity": "sha512-SYCUykz+1cnl6Ugd8VUvtTQq5+j1Q7C0CtzKPjQ8JyA2ALh0EEJkMCS+KgdnvKW1lrxjtjCyJSHOOT236mENYg==", "integrity": "sha512-SYCUykz+1cnl6Ugd8VUvtTQq5+j1Q7C0CtzKPjQ8JyA2ALh0EEJkMCS+KgdnvKW1lrxjtjCyJSHOOT236mENYg==",
"devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {

View File

@@ -9,12 +9,12 @@
"predev": "node scripts/ensure-prisma-client.mjs", "predev": "node scripts/ensure-prisma-client.mjs",
"prestart": "node scripts/ensure-prisma-client.mjs", "prestart": "node scripts/ensure-prisma-client.mjs",
"prebuild": "node scripts/ensure-prisma-client.mjs", "prebuild": "node scripts/ensure-prisma-client.mjs",
"dev": "tsx watch src/index.ts", "dev": "node ./node_modules/tsx/dist/cli.mjs watch src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"build": "tsc -p tsconfig.json", "build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
"prisma:generate": "prisma generate", "prisma:generate": "node ./node_modules/prisma/build/index.js generate",
"db:migrate": "prisma migrate dev", "db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",
"db:studio": "prisma studio" "db:studio": "node ./node_modules/prisma/build/index.js studio"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.71.2", "@anthropic-ai/sdk": "^0.71.2",
@@ -26,12 +26,12 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"fastify": "^5.7.2", "fastify": "^5.7.2",
"openai": "^6.16.0", "openai": "^6.16.0",
"prisma": "^6.6.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"prisma": "^6.6.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -7,7 +7,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
const rootDir = resolve(scriptDir, ".."); const rootDir = resolve(scriptDir, "..");
const clientDir = join(rootDir, "node_modules", ".prisma", "client"); const clientDir = join(rootDir, "node_modules", ".prisma", "client");
const clientIndex = join(clientDir, "index.js"); const clientIndex = join(clientDir, "index.js");
const prismaBin = join(rootDir, "node_modules", ".bin", "prisma"); const prismaJs = join(rootDir, "node_modules", "prisma", "build", "index.js");
const genericEngine = join(clientDir, "libquery_engine.node"); const genericEngine = join(clientDir, "libquery_engine.node");
function parseExpectedEngineFiles() { function parseExpectedEngineFiles() {
@@ -22,15 +22,18 @@ function missingEngineFiles(expectedFiles) {
} }
function runPrismaGenerate() { function runPrismaGenerate() {
if (!existsSync(prismaBin)) { if (existsSync(prismaJs)) {
throw new Error( execFileSync(process.execPath, [prismaJs, "generate"], {
"Prisma CLI not found. Install dev dependencies and run `npm run prisma:generate`."
);
}
execFileSync(prismaBin, ["generate"], {
cwd: rootDir, cwd: rootDir,
stdio: "inherit", stdio: "inherit",
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
}); });
return;
}
throw new Error(
"Prisma CLI not found at node_modules/prisma/build/index.js. Install dependencies and run `npm run prisma:generate`."
);
} }
let expectedFiles = parseExpectedEngineFiles(); let expectedFiles = parseExpectedEngineFiles();
@@ -54,4 +57,3 @@ if (missingFiles.length) {
)}. Ensure deployment copies node_modules/.prisma (including dot-directories).` )}. Ensure deployment copies node_modules/.prisma (including dot-directories).`
); );
} }

27
server/src/db-init.ts Normal file
View File

@@ -0,0 +1,27 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { FastifyBaseLogger } from "fastify";
function runPrismaCli(rootDir: string, args: string[]) {
const prismaJs = join(rootDir, "node_modules", "prisma", "build", "index.js");
if (existsSync(prismaJs)) {
execFileSync(process.execPath, [prismaJs, ...args], {
cwd: rootDir,
stdio: "inherit",
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
});
return;
}
throw new Error("Prisma CLI not found at node_modules/prisma/build/index.js. Run `npm install` in /server.");
}
export async function ensureDatabaseReady(logger: FastifyBaseLogger) {
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const startedAt = Date.now();
logger.info("Applying Prisma migrations...");
runPrismaCli(rootDir, ["migrate", "deploy"]);
logger.info({ durationMs: Date.now() - startedAt }, "Prisma migrations applied");
}

View File

@@ -4,6 +4,7 @@ import swagger from "@fastify/swagger";
import swaggerUI from "@fastify/swagger-ui"; import swaggerUI from "@fastify/swagger-ui";
import sensible from "@fastify/sensible"; import sensible from "@fastify/sensible";
import { env } from "./env.js"; import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js";
import { registerRoutes } from "./routes.js"; import { registerRoutes } from "./routes.js";
const app = Fastify({ const app = Fastify({
@@ -15,6 +16,8 @@ const app = Fastify({
}, },
}); });
await ensureDatabaseReady(app.log);
await app.register(cors, { origin: true, credentials: true }); await app.register(cors, { origin: true, credentials: true });
await app.register(swagger, { await app.register(swagger, {

View File

@@ -2,12 +2,59 @@ import { z } from "zod";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { requireAdmin } from "./auth.js"; import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { runMultiplex } from "./llm/multiplexer.js"; import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js"; import { runMultiplexStream } from "./llm/streaming.js";
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
};
function sameMessage(a: IncomingChatMessage, b: IncomingChatMessage) {
return a.role === b.role && a.content === b.content && (a.name ?? null) === (b.name ?? null);
}
async function storeNonAssistantMessages(chatId: string, messages: IncomingChatMessage[]) {
const incoming = messages.filter((m) => m.role !== "assistant");
if (!incoming.length) return;
const existing = await prisma.message.findMany({
where: { chatId },
orderBy: { createdAt: "asc" },
select: { role: true, content: true, name: true },
});
const existingNonAssistant = existing.filter((m) => m.role !== "assistant");
let sharedPrefix = 0;
const max = Math.min(existingNonAssistant.length, incoming.length);
while (sharedPrefix < max && sameMessage(existingNonAssistant[sharedPrefix] as IncomingChatMessage, incoming[sharedPrefix])) {
sharedPrefix += 1;
}
if (sharedPrefix === incoming.length) return;
const toInsert = sharedPrefix === existingNonAssistant.length ? incoming.slice(existingNonAssistant.length) : incoming;
if (!toInsert.length) return;
await prisma.message.createMany({
data: toInsert.map((m) => ({
chatId,
role: m.role as any,
content: m.content,
name: m.name,
})),
});
}
export async function registerRoutes(app: FastifyInstance) { export async function registerRoutes(app: FastifyInstance) {
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
app.get("/v1/auth/session", async (req) => {
requireAdmin(req);
return { authenticated: true, mode: env.ADMIN_TOKEN ? "token" : "open" };
});
app.get("/v1/chats", async (req) => { app.get("/v1/chats", async (req) => {
requireAdmin(req); requireAdmin(req);
const chats = await prisma.chat.findMany({ const chats = await prisma.chat.findMany({
@@ -92,14 +139,9 @@ export async function registerRoutes(app: FastifyInstance) {
if (!exists) return app.httpErrors.notFound("chat not found"); if (!exists) return app.httpErrors.notFound("chat not found");
} }
// store user messages (anything not assistant) for DB fidelity // Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) { if (body.chatId) {
const toInsert = body.messages.filter((m) => m.role !== "assistant"); await storeNonAssistantMessages(body.chatId, body.messages);
if (toInsert.length) {
await prisma.message.createMany({
data: toInsert.map((m) => ({ chatId: body.chatId!, role: m.role as any, content: m.content, name: m.name })),
});
}
} }
const result = await runMultiplex(body); const result = await runMultiplex(body);
@@ -137,14 +179,9 @@ export async function registerRoutes(app: FastifyInstance) {
if (!exists) return app.httpErrors.notFound("chat not found"); if (!exists) return app.httpErrors.notFound("chat not found");
} }
// store user messages (anything not assistant) for DB fidelity // Store only new non-assistant messages to avoid duplicate history entries.
if (body.chatId) { if (body.chatId) {
const toInsert = body.messages.filter((m) => m.role !== "assistant"); await storeNonAssistantMessages(body.chatId, body.messages);
if (toInsert.length) {
await prisma.message.createMany({
data: toInsert.map((m) => ({ chatId: body.chatId!, role: m.role as any, content: m.content, name: m.name })),
});
}
} }
reply.raw.writeHead(200, { reply.raw.writeHead(200, {

3
web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://localhost:8787
# Optional: pre-fill token in the login screen.
# VITE_ADMIN_TOKEN=replace-with-admin-token

3
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.DS_Store
dist

38
web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# Sybil Web
Preact + Vite frontend for the Sybil backend.
## Setup
1. Copy env values:
```bash
cp .env.example .env
```
2. Install and run:
```bash
npm install
npm run dev
```
Default dev URL: `http://localhost:5173`
## Environment variables
- `VITE_API_BASE_URL`: backend API base URL. Defaults to `http://localhost:8787`.
- `VITE_ADMIN_TOKEN`: optional. Pre-fills a token in the login form.
## Authentication
- On startup, the app checks `GET /v1/auth/session`.
- If backend runs with `ADMIN_TOKEN`, sign in using that token.
- If backend runs in open mode (`ADMIN_TOKEN` unset), you can continue without a token.
- The entered token is stored in browser `localStorage` and can be cleared via Logout.
## UI
- Left panel: conversation list + new chat.
- Right panel: selected transcript + model controls + composer.
- Sending a message uses `POST /v1/chat-completions` and then refreshes chat history from the backend.

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sybil Chat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3035
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "sybil-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-preact": "^0.542.0",
"preact": "^10.27.2",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.2",
"@types/node": "^24.5.2",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.3",
"vite": "^7.1.7"
}
}

6
web/postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

521
web/src/App.tsx Normal file
View File

@@ -0,0 +1,521 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { LogOut, MessageSquare, Plus, SendHorizontal, ShieldCheck } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
createChat,
getChat,
getConfiguredToken,
listChats,
runCompletion,
setAuthToken,
verifySession,
type ChatDetail,
type ChatSummary,
type CompletionRequestMessage,
} from "@/lib/api";
import { cn } from "@/lib/utils";
type Provider = "openai" | "anthropic" | "xai";
type AuthMode = "open" | "token";
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
openai: "gpt-4.1-mini",
anthropic: "claude-3-5-sonnet-latest",
xai: "grok-3-mini",
};
const TOKEN_STORAGE_KEY = "sybil_admin_token";
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
if (chat.title?.trim()) return chat.title.trim();
const firstUserMessage = messages?.find((m) => m.role === "user")?.content.trim();
if (firstUserMessage) return firstUserMessage.slice(0, 48);
return "New chat";
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(value));
}
function readStoredToken() {
return localStorage.getItem(TOKEN_STORAGE_KEY)?.trim() || null;
}
function persistToken(token: string | null) {
if (token) {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
return;
}
localStorage.removeItem(TOKEN_STORAGE_KEY);
}
function normalizeAuthError(message: string) {
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
}
return message;
}
export default function App() {
const initialToken = readStoredToken() ?? getConfiguredToken() ?? "";
const [authTokenInput, setAuthTokenInput] = useState(initialToken);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [isSigningIn, setIsSigningIn] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [selectedChatId, setSelectedChatId] = useState<string | null>(null);
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
const [isLoadingChats, setIsLoadingChats] = useState(false);
const [isLoadingChat, setIsLoadingChat] = useState(false);
const [isSending, setIsSending] = useState(false);
const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai");
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai);
const [error, setError] = useState<string | null>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const completeSessionCheck = async (tokenCandidate: string | null) => {
setAuthToken(tokenCandidate);
const session = await verifySession();
setIsAuthenticated(true);
setAuthMode(session.mode);
setAuthError(null);
persistToken(tokenCandidate);
};
const handleAuthFailure = (message: string) => {
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(normalizeAuthError(message));
setAuthToken(null);
persistToken(null);
setChats([]);
setSelectedChatId(null);
setSelectedChat(null);
};
const refreshChats = async (preferredChatId?: string) => {
setIsLoadingChats(true);
try {
const nextChats = await listChats();
setChats(nextChats);
setSelectedChatId((current) => {
if (preferredChatId && nextChats.some((chat) => chat.id === preferredChatId)) {
return preferredChatId;
}
if (current && nextChats.some((chat) => chat.id === current)) {
return current;
}
return nextChats[0]?.id ?? null;
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
setIsLoadingChats(false);
}
};
const refreshChat = async (chatId: string) => {
setIsLoadingChat(true);
try {
const chat = await getChat(chatId);
setSelectedChat(chat);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
setIsLoadingChat(false);
}
};
useEffect(() => {
const token = readStoredToken() ?? getConfiguredToken();
void (async () => {
try {
await completeSessionCheck(token);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
handleAuthFailure(message);
} finally {
setIsCheckingSession(false);
}
})();
}, []);
useEffect(() => {
if (!isAuthenticated) return;
void refreshChats();
}, [isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
setSelectedChat(null);
return;
}
if (!selectedChatId) {
setSelectedChat(null);
return;
}
void refreshChat(selectedChatId);
}, [isAuthenticated, selectedChatId]);
useEffect(() => {
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [selectedChat?.messages.length, isSending]);
const messages = selectedChat?.messages ?? [];
const selectedChatTitle = useMemo(() => {
if (!selectedChat) return "Sybil";
return getChatTitle(selectedChat, selectedChat.messages);
}, [selectedChat]);
const handleCreateChat = async () => {
setError(null);
try {
const chat = await createChat();
setSelectedChatId(chat.id);
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
messages: [],
});
await refreshChats(chat.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const handleSend = async () => {
const content = composer.trim();
if (!content || isSending) return;
setComposer("");
setError(null);
setIsSending(true);
let chatId = selectedChatId;
try {
if (!chatId) {
const chat = await createChat();
chatId = chat.id;
setSelectedChatId(chatId);
}
if (!chatId) {
throw new Error("Unable to initialize chat");
}
let baseChat = selectedChat;
if (!baseChat || baseChat.id !== chatId) {
baseChat = await getChat(chatId);
}
const optimisticUserMessage = {
id: `temp-user-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "user" as const,
content,
name: null,
};
const optimisticAssistantMessage = {
id: `temp-assistant-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "assistant" as const,
content: "",
name: null,
};
setSelectedChat((current) => {
if (!current || current.id !== chatId) return current;
return {
...current,
messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage],
};
});
const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages.map((message) => ({
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
})),
{
role: "user",
content,
},
];
await runCompletion({
chatId,
provider,
model: model.trim(),
messages: requestMessages,
});
await Promise.all([refreshChats(chatId), refreshChat(chatId)]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
if (chatId) {
await refreshChat(chatId);
}
} finally {
setIsSending(false);
}
};
const handleSignIn = async (tokenCandidate: string | null) => {
setIsSigningIn(true);
setAuthError(null);
try {
await completeSessionCheck(tokenCandidate);
await refreshChats();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setAuthError(normalizeAuthError(message));
setIsAuthenticated(false);
setAuthMode(null);
} finally {
setIsSigningIn(false);
}
};
const handleLogout = () => {
setAuthToken(null);
persistToken(null);
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(null);
setChats([]);
setSelectedChatId(null);
setSelectedChat(null);
setComposer("");
setError(null);
};
if (isCheckingSession) {
return (
<div className="flex h-full items-center justify-center bg-muted/70">
<p className="text-sm text-muted-foreground">Checking session...</p>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,#f8fafc_0%,#eef2f7_45%,#e2e8f0_100%)] p-4">
<div className="w-full max-w-md rounded-2xl border bg-background p-6 shadow-xl">
<div className="mb-5 flex items-start gap-3">
<div className="rounded-lg bg-slate-900 p-2 text-slate-50">
<ShieldCheck className="h-4 w-4" />
</div>
<div>
<h1 className="text-lg font-semibold">Sign in to Sybil</h1>
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
</div>
</div>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void handleSignIn(authTokenInput.trim() || null);
}}
>
<Input
type="password"
autoComplete="off"
placeholder="ADMIN_TOKEN"
value={authTokenInput}
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
disabled={isSigningIn}
/>
<Button className="w-full" type="submit" disabled={isSigningIn}>
{isSigningIn ? "Signing in..." : "Sign in"}
</Button>
<Button
className="w-full"
type="button"
variant="secondary"
disabled={isSigningIn}
onClick={() => void handleSignIn(null)}
>
Continue without token
</Button>
</form>
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
</div>
</div>
);
}
return (
<div className="h-full p-3 md:p-5">
<div className="mx-auto flex h-full w-full max-w-[1560px] overflow-hidden rounded-2xl border bg-background shadow-xl">
<aside className="flex w-80 shrink-0 flex-col border-r bg-slate-50">
<div className="p-3">
<Button className="w-full justify-start gap-2" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
</Button>
</div>
<Separator />
<div className="flex-1 overflow-y-auto p-2">
{isLoadingChats && chats.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading chats...</p> : null}
{!isLoadingChats && chats.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
<MessageSquare className="h-5 w-5" />
Start your first conversation.
</div>
) : null}
{chats.map((chat) => {
const active = chat.id === selectedChatId;
return (
<button
key={chat.id}
className={cn(
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
active ? "bg-slate-900 text-slate-50" : "hover:bg-slate-200"
)}
onClick={() => setSelectedChatId(chat.id)}
type="button"
>
<p className="truncate text-sm font-medium">{getChatTitle(chat)}</p>
<p className={cn("mt-1 text-xs", active ? "text-slate-300" : "text-slate-500")}>{formatDate(chat.updatedAt)}</p>
</button>
);
})}
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div>
<h1 className="text-sm font-semibold md:text-base">{selectedChatTitle}</h1>
<p className="text-xs text-muted-foreground">
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
</div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={provider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider);
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
}}
disabled={isSending}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<Input
value={model}
onInput={(event) => setModel(event.currentTarget.value)}
placeholder="Model"
disabled={isSending}
/>
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOut className="mr-1 h-4 w-4" />
Logout
</Button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
{isLoadingChat && messages.length === 0 ? <p className="text-sm text-muted-foreground">Loading messages...</p> : null}
{!isLoadingChat && messages.length === 0 ? (
<div className="mx-auto flex max-w-3xl flex-col items-center gap-3 rounded-xl border border-dashed p-8 text-center">
<h2 className="text-lg font-semibold">How can I help today?</h2>
<p className="text-sm text-muted-foreground">Ask a question to begin this conversation.</p>
</div>
) : null}
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => {
const isUser = message.role === "user";
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending;
return (
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-6",
isUser ? "bg-slate-900 text-slate-50" : "bg-slate-100 text-slate-900"
)}
>
{isPendingAssistant ? "Thinking..." : message.content}
</div>
</div>
);
})}
</div>
<div ref={transcriptEndRef} />
</div>
<footer className="border-t p-3 md:p-4">
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
<Textarea
rows={3}
value={composer}
onInput={(event) => setComposer(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
placeholder="Message Sybil"
className="resize-none border-0 shadow-none focus-visible:ring-0"
disabled={isSending}
/>
<div className="flex items-center justify-between px-2 pb-1">
{error ? <p className="text-xs text-red-600">{error}</p> : <span className="text-xs text-muted-foreground">Enter to send</span>}
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</footer>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { JSX } from "preact";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

View File

@@ -0,0 +1,14 @@
import type { JSX } from "preact";
import { cn } from "@/lib/utils";
export function Input({ className, ...props }: JSX.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,11 @@
import type { ComponentChildren } from "preact";
import { cn } from "@/lib/utils";
type ScrollAreaProps = {
className?: string;
children: ComponentChildren;
};
export function ScrollArea({ className, children }: ScrollAreaProps) {
return <div className={cn("overflow-y-auto", className)}>{children}</div>;
}

View File

@@ -0,0 +1,9 @@
import { cn } from "@/lib/utils";
type SeparatorProps = {
className?: string;
};
export function Separator({ className }: SeparatorProps) {
return <div className={cn("h-px w-full bg-border", className)} />;
}

View File

@@ -0,0 +1,14 @@
import type { JSX } from "preact";
import { cn } from "@/lib/utils";
export function Textarea({ className, ...props }: JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
{...props}
/>
);
}

35
web/src/index.css Normal file
View File

@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--muted: 210 40% 96.1%;
--muted-foreground: 215 16% 47%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
--primary: 222 84% 5%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222 47% 11%;
--accent: 210 40% 96.1%;
--accent-foreground: 222 47% 11%;
--radius: 0.65rem;
}
* {
@apply border-border;
}
html,
body,
#app {
height: 100%;
}
body {
@apply bg-muted/70 text-foreground antialiased;
font-family: "Soehne", "Avenir Next", "Segoe UI", sans-serif;
}

109
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,109 @@
export type ChatSummary = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
createdAt: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
name: string | null;
};
export type ChatDetail = {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
messages: Message[];
};
export type CompletionRequestMessage = {
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
};
type CompletionResponse = {
chatId: string | null;
message: {
role: "assistant";
content: string;
};
};
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8787";
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
let authToken: string | null = ENV_ADMIN_TOKEN;
export function getConfiguredToken() {
return ENV_ADMIN_TOKEN;
}
export function setAuthToken(token: string | null) {
authToken = token?.trim() || null;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? {});
headers.set("Content-Type", "application/json");
if (authToken) {
headers.set("Authorization", `Bearer ${authToken}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers,
});
if (!response.ok) {
const fallback = `${response.status} ${response.statusText}`;
let message = fallback;
try {
const body = (await response.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// keep fallback message
}
throw new Error(message);
}
return (await response.json()) as T;
}
export async function listChats() {
const data = await api<{ chats: ChatSummary[] }>("/v1/chats");
return data.chats;
}
export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
}
export async function createChat(title?: string) {
const data = await api<{ chat: ChatSummary }>("/v1/chats", {
method: "POST",
body: JSON.stringify({ title }),
});
return data.chat;
}
export async function getChat(chatId: string) {
const data = await api<{ chat: ChatDetail }>(`/v1/chats/${chatId}`);
return data.chat;
}
export async function runCompletion(body: {
chatId: string;
provider: "openai" | "anthropic" | "xai";
model: string;
messages: CompletionRequestMessage[];
}) {
return api<CompletionResponse>("/v1/chat-completions", {
method: "POST",
body: JSON.stringify(body),
});
}

6
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

5
web/src/main.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { render } from "preact";
import App from "./App";
import "./index.css";
render(<App />, document.getElementById("app")!);

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

3
web/tailwind.config.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import type { Config } from "tailwindcss";
declare const config: Config;
export default config;

38
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,38 @@
var config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

41
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [],
};
export default config;

24
web/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}

File diff suppressed because one or more lines are too long

1
web/tsconfig.tsbuildinfo Normal file
View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/lib/api.ts","./src/lib/utils.ts"],"version":"5.9.3"}

2
web/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

11
web/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
import path from "node:path";
export default defineConfig({
plugins: [preact()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

12
web/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
import path from "node:path";
export default defineConfig({
plugins: [preact()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});