Add web frontend
This commit is contained in:
9
AGENTS.md
Normal file
9
AGENTS.md
Normal 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
2
server/.gitignore
vendored
@@ -3,7 +3,7 @@ node_modules
|
||||
.env.*
|
||||
dev.db
|
||||
*.db
|
||||
*.db-journal
|
||||
prisma/migrations
|
||||
.DS_Store
|
||||
dist
|
||||
|
||||
|
||||
@@ -13,12 +13,20 @@ Backend API for:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Migrations are applied automatically on server startup (`prisma migrate deploy`).
|
||||
|
||||
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
|
||||
|
||||
Set `ADMIN_TOKEN` and send:
|
||||
@@ -34,6 +42,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
|
||||
|
||||
## API
|
||||
- `GET /health`
|
||||
- `GET /v1/auth/session`
|
||||
- `GET /v1/chats`
|
||||
- `POST /v1/chats`
|
||||
- `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/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:
|
||||
|
||||
```json
|
||||
|
||||
38
server/package-lock.json
generated
38
server/package-lock.json
generated
@@ -19,11 +19,11 @@
|
||||
"fastify": "^5.7.2",
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"prisma": "^6.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
@@ -64,7 +64,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -81,7 +80,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -98,7 +96,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -115,7 +112,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -132,7 +128,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -149,7 +144,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -166,7 +160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -183,7 +176,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -200,7 +192,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -217,7 +208,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -234,7 +224,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -251,7 +240,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -268,7 +256,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -285,7 +272,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -302,7 +288,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -319,7 +304,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -336,7 +320,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -353,7 +336,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -370,7 +352,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -387,7 +368,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -404,7 +384,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -421,7 +400,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -438,7 +416,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -455,7 +432,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -472,7 +448,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -489,7 +464,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -826,7 +800,6 @@
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.6.0.tgz",
|
||||
"integrity": "sha512-d8FlXRHsx72RbN8nA2QCRORNv5AcUnPXgtPvwhXmYkQSMF/j9cKaJg+9VcUzBRXGy9QBckNzEQDEJZdEOZ+ubA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esbuild": ">=0.12 <1",
|
||||
@@ -837,14 +810,12 @@
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.6.0.tgz",
|
||||
"integrity": "sha512-DL6n4IKlW5k2LEXzpN60SQ1kP/F6fqaCgU/McgaYsxSf43GZ8lwtmXLke9efS+L1uGmrhtBUP4npV/QKF8s2ZQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.6.0.tgz",
|
||||
"integrity": "sha512-nC0IV4NHh7500cozD1fBoTwTD1ydJERndreIjpZr/S3mno3P6tm8qnXmIND5SwUkibNeSJMpgl4gAnlqJ/gVlg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -858,14 +829,12 @@
|
||||
"version": "6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.6.0-53.f676762280b54cd07c770017ed3711ddde35f37a.tgz",
|
||||
"integrity": "sha512-JzRaQ5Em1fuEcbR3nUsMNYaIYrOT1iMheenjCvzZblJcjv/3JIuxXN7RCNT5i6lRkLodW5ojCGhR7n5yvnNKrw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.6.0.tgz",
|
||||
"integrity": "sha512-Ohfo8gKp05LFLZaBlPUApM0M7k43a0jmo86YY35u1/4t+vuQH9mRGU7jGwVzGFY3v+9edeb/cowb1oG4buM1yw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.6.0",
|
||||
@@ -877,7 +846,6 @@
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.6.0.tgz",
|
||||
"integrity": "sha512-3qCwmnT4Jh5WCGUrkWcc6VZaw0JY7eWN175/pcb5Z6FiLZZ3ygY93UX0WuV41bG51a6JN/oBH0uywJ90Y+V5eA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.6.0"
|
||||
@@ -1061,7 +1029,6 @@
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -1103,7 +1070,6 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
@@ -1276,7 +1242,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1679,7 +1644,6 @@
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.6.0.tgz",
|
||||
"integrity": "sha512-SYCUykz+1cnl6Ugd8VUvtTQq5+j1Q7C0CtzKPjQ8JyA2ALh0EEJkMCS+KgdnvKW1lrxjtjCyJSHOOT236mENYg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
"predev": "node scripts/ensure-prisma-client.mjs",
|
||||
"prestart": "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",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio"
|
||||
"build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||
"prisma:generate": "node ./node_modules/prisma/build/index.js generate",
|
||||
"db:migrate": "node ./node_modules/prisma/build/index.js migrate dev",
|
||||
"db:studio": "node ./node_modules/prisma/build/index.js studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.71.2",
|
||||
@@ -26,12 +26,12 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.7.2",
|
||||
"openai": "^6.16.0",
|
||||
"prisma": "^6.6.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"prisma": "^6.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(scriptDir, "..");
|
||||
const clientDir = join(rootDir, "node_modules", ".prisma", "client");
|
||||
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");
|
||||
|
||||
function parseExpectedEngineFiles() {
|
||||
@@ -22,15 +22,18 @@ function missingEngineFiles(expectedFiles) {
|
||||
}
|
||||
|
||||
function runPrismaGenerate() {
|
||||
if (!existsSync(prismaBin)) {
|
||||
throw new Error(
|
||||
"Prisma CLI not found. Install dev dependencies and run `npm run prisma:generate`."
|
||||
);
|
||||
if (existsSync(prismaJs)) {
|
||||
execFileSync(process.execPath, [prismaJs, "generate"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
execFileSync(prismaBin, ["generate"], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -54,4 +57,3 @@ if (missingFiles.length) {
|
||||
)}. Ensure deployment copies node_modules/.prisma (including dot-directories).`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
27
server/src/db-init.ts
Normal file
27
server/src/db-init.ts
Normal 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");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import swagger from "@fastify/swagger";
|
||||
import swaggerUI from "@fastify/swagger-ui";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { env } from "./env.js";
|
||||
import { ensureDatabaseReady } from "./db-init.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
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(swagger, {
|
||||
|
||||
@@ -2,12 +2,59 @@ import { z } from "zod";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { prisma } from "./db.js";
|
||||
import { requireAdmin } from "./auth.js";
|
||||
import { env } from "./env.js";
|
||||
import { runMultiplex } from "./llm/multiplexer.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) {
|
||||
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) => {
|
||||
requireAdmin(req);
|
||||
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");
|
||||
}
|
||||
|
||||
// store user messages (anything not assistant) for DB fidelity
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
if (body.chatId) {
|
||||
const toInsert = body.messages.filter((m) => m.role !== "assistant");
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
const result = await runMultiplex(body);
|
||||
@@ -137,14 +179,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
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) {
|
||||
const toInsert = body.messages.filter((m) => m.role !== "assistant");
|
||||
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 })),
|
||||
});
|
||||
}
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, {
|
||||
|
||||
3
web/.env.example
Normal file
3
web/.env.example
Normal 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
3
web/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
38
web/README.md
Normal file
38
web/README.md
Normal 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
12
web/index.html
Normal 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
3035
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/package.json
Normal file
28
web/package.json
Normal 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
6
web/postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
521
web/src/App.tsx
Normal file
521
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
web/src/components/ui/button.tsx
Normal file
33
web/src/components/ui/button.tsx
Normal 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} />;
|
||||
}
|
||||
14
web/src/components/ui/input.tsx
Normal file
14
web/src/components/ui/input.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
web/src/components/ui/scroll-area.tsx
Normal file
11
web/src/components/ui/scroll-area.tsx
Normal 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>;
|
||||
}
|
||||
9
web/src/components/ui/separator.tsx
Normal file
9
web/src/components/ui/separator.tsx
Normal 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)} />;
|
||||
}
|
||||
14
web/src/components/ui/textarea.tsx
Normal file
14
web/src/components/ui/textarea.tsx
Normal 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
35
web/src/index.css
Normal 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
109
web/src/lib/api.ts
Normal 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
6
web/src/lib/utils.ts
Normal 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
5
web/src/main.tsx
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
3
web/tailwind.config.d.ts
vendored
Normal file
3
web/tailwind.config.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
declare const config: Config;
|
||||
export default config;
|
||||
38
web/tailwind.config.js
Normal file
38
web/tailwind.config.js
Normal 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
41
web/tailwind.config.ts
Normal 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
24
web/tsconfig.json
Normal 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
11
web/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
1
web/tsconfig.node.tsbuildinfo
Normal file
1
web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
web/tsconfig.tsbuildinfo
Normal file
1
web/tsconfig.tsbuildinfo
Normal 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
2
web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
11
web/vite.config.js
Normal file
11
web/vite.config.js
Normal 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
12
web/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user