diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6529695 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +**/node_modules +dist +**/dist +.env +.git +.gitignore +.DS_Store +*.log +.vite +coverage +/tmp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a792e87 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +NODE_ENV=production +PORT=3000 +DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode +LLAMA_SWAP_URL=http://100.101.41.16:8401 +PROJECT_ROOT_WHITELIST=/opt +DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4 +POSTGRES_PASSWORD=CHANGE_ME diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..847767e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +*.log +.DS_Store +.vite +coverage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3279183 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-alpine AS builder +RUN corepack enable +WORKDIR /build + +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ +COPY apps/server/package.json ./apps/server/ +COPY apps/web/package.json ./apps/web/ + +RUN pnpm install --frozen-lockfile + +COPY apps/server ./apps/server +COPY apps/web ./apps/web + +RUN pnpm build + +RUN pnpm deploy --filter=@boocode/server --prod --legacy /out/server + + +FROM node:20-alpine AS runtime +RUN apk add --no-cache ripgrep +WORKDIR /app + +COPY --from=builder /out/server ./ +COPY --from=builder /build/apps/web/dist ./web + +ENV NODE_ENV=production +ENV WEB_DIST_PATH=/app/web +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index a185743..5f3881c 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ # boocode + +Self-hosted single-user developer chat app. v1: chat only. + +## Stack + +- Node 20, Fastify, postgres (porsager/postgres), ws, zod +- React 18, Vite, TypeScript, Tailwind v4, shadcn/ui +- Postgres 16 +- pnpm workspaces + +## Layout + +- `apps/server` — Fastify API + WebSocket + inference loop + file-read tools +- `apps/web` — React frontend; served by Fastify in production, Vite in dev + +## Local dev + +Requires Node 20, pnpm, Docker (for Postgres), and ripgrep. + +```bash +# install +pnpm install + +# bring up postgres only +cp .env.example .env +# edit POSTGRES_PASSWORD if you like; default DATABASE_URL points at the container +docker compose up -d boocode_db + +# run server (port 3000) and web (port 5173) in two shells +DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boocode \ +LLAMA_SWAP_URL=http://100.101.41.16:8401 \ +pnpm dev:server + +pnpm dev:web +``` + +The Vite dev server proxies `/api` and `/api/ws/*` to the Fastify backend with a +synthetic `Remote-User: sam` header so the Authelia auth layer can be skipped +during development. + +## Production + +```bash +cd /opt/boocode +docker compose up --build -d +``` + +Binds to `100.114.205.53:9500` (Tailscale). Authelia is expected to gate the +upstream and inject `Remote-User`. Postgres binds loopback only. + +## What v1 has + +Project sidebar, sessions per project, chat with streaming responses over +WebSocket, four file-read tools scoped to the project root (`view_file`, +`list_dir`, `grep`, `find_files`), and a model picker driven by llama-swap's +`/v1/models`. + +What v1 does not have lives in v2 (terminal pane) and v3 (Coder pane). diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..caeb3d4 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@boocode/server", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/static": "^7.0.4", + "@fastify/websocket": "^10.0.1", + "fastify": "^4.28.1", + "postgres": "^3.4.4", + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "@types/ws": "^8.5.10", + "tsx": "^4.16.2", + "typescript": "^5.5.0" + } +} diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..de286de --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; + +declare module 'fastify' { + interface FastifyRequest { + user?: string; + } +} + +const PUBLIC_PATHS = new Set(['/api/health']); + +export function registerAuth(app: FastifyInstance): void { + app.addHook('onRequest', async (req, reply) => { + if (!req.url.startsWith('/api')) return; + if (PUBLIC_PATHS.has(req.routeOptions.url ?? req.url.split('?')[0]!)) return; + + const header = req.headers['remote-user']; + const user = Array.isArray(header) ? header[0] : header; + if (!user || user.trim() === '') { + reply.code(401).send({ error: 'unauthenticated' }); + return reply; + } + req.user = user.trim(); + }); +} + +export function requireUser(req: FastifyRequest): string { + if (!req.user) { + throw new Error('user not set on request — auth hook must run first'); + } + return req.user; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..74d01da --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const ConfigSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().default(3000), + HOST: z.string().default('0.0.0.0'), + DATABASE_URL: z.string().url(), + LLAMA_SWAP_URL: z.string().url(), + PROJECT_ROOT_WHITELIST: z.string().default('/opt'), + DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), + LOG_LEVEL: z.string().default('info'), +}); + +export type Config = z.infer; + +let cached: Config | null = null; + +export function loadConfig(): Config { + if (cached) return cached; + const parsed = ConfigSchema.safeParse(process.env); + if (!parsed.success) { + console.error('Invalid environment configuration:'); + console.error(parsed.error.flatten().fieldErrors); + process.exit(1); + } + cached = parsed.data; + return cached; +} diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts new file mode 100644 index 0000000..0a1947b --- /dev/null +++ b/apps/server/src/db.ts @@ -0,0 +1,45 @@ +import postgres from 'postgres'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import type { Config } from './config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export type Sql = ReturnType; + +let sqlInstance: Sql | null = null; + +export function getSql(config: Config): Sql { + if (sqlInstance) return sqlInstance; + sqlInstance = postgres(config.DATABASE_URL, { + max: 10, + idle_timeout: 30, + connect_timeout: 10, + onnotice: () => {}, + }); + return sqlInstance; +} + +export async function applySchema(sql: Sql): Promise { + const schemaPath = resolve(__dirname, 'schema.sql'); + const ddl = await readFile(schemaPath, 'utf8'); + await sql.unsafe(ddl); +} + +export async function pingDb(sql: Sql): Promise { + try { + await sql`SELECT 1`; + return true; + } catch { + return false; + } +} + +export async function closeDb(): Promise { + if (sqlInstance) { + await sqlInstance.end({ timeout: 5 }); + sqlInstance = null; + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..62455df --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,114 @@ +import Fastify from 'fastify'; +import fastifyStatic from '@fastify/static'; +import fastifyWebsocket from '@fastify/websocket'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { loadConfig } from './config.js'; +import { getSql, applySchema, pingDb, closeDb } from './db.js'; +import { registerAuth } from './auth.js'; +import { registerProjectRoutes } from './routes/projects.js'; +import { registerSessionRoutes } from './routes/sessions.js'; +import { registerSettingsRoutes } from './routes/settings.js'; +import { registerMessageRoutes } from './routes/messages.js'; +import { registerWebSocket } from './routes/ws.js'; +import { registerModelRoutes } from './routes/models.js'; +import { createInferenceRunner } from './services/inference.js'; +import { createBroker } from './services/broker.js'; + +async function main() { + const config = loadConfig(); + + const app = Fastify({ + logger: { level: config.LOG_LEVEL }, + }); + + const sql = getSql(config); + await applySchema(sql); + app.log.info('database schema applied'); + + await app.register(fastifyWebsocket); + + registerAuth(app); + + app.get('/api/health', async () => { + const dbOk = await pingDb(sql); + return { status: dbOk ? 'ok' : 'degraded', db: dbOk }; + }); + + registerProjectRoutes(app, sql, config); + registerSessionRoutes(app, sql, config); + registerSettingsRoutes(app, sql); + registerModelRoutes(app, config); + + const broker = createBroker(); + const inference = createInferenceRunner({ + sql, + config, + log: app.log, + publish: (sessionId, frame) => { + broker.publish(sessionId, frame as unknown as Record & { type: string }); + }, + }); + registerMessageRoutes(app, sql, { + onSend: (sessionId, _userId, assistantId) => { + inference.enqueue(sessionId, assistantId); + }, + publishUserMessage: (sessionId, userMessageId, content) => { + broker.publish(sessionId, { + type: 'message_started', + message_id: userMessageId, + role: 'user', + }); + broker.publish(sessionId, { + type: 'delta', + message_id: userMessageId, + content, + }); + broker.publish(sessionId, { + type: 'message_complete', + message_id: userMessageId, + }); + }, + }); + registerWebSocket(app, sql, broker); + + const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist'); + if (existsSync(webDist)) { + await app.register(fastifyStatic, { + root: webDist, + prefix: '/', + wildcard: false, + }); + app.setNotFoundHandler((req, reply) => { + if (req.url.startsWith('/api')) { + reply.code(404).send({ error: 'not found' }); + return; + } + reply.sendFile('index.html'); + }); + app.log.info(`serving static frontend from ${webDist}`); + } + + const shutdown = async (signal: string) => { + app.log.info(`received ${signal}, shutting down`); + try { + await app.close(); + await closeDb(); + process.exit(0); + } catch (err) { + app.log.error(err); + process.exit(1); + } + }; + + process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM')); + + await app.listen({ port: config.PORT, host: config.HOST }); + app.log.info(`boocode server listening on http://${config.HOST}:${config.PORT}`); +} + +main().catch((err) => { + console.error('Fatal startup error:', err); + process.exit(1); +}); diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts new file mode 100644 index 0000000..97c43eb --- /dev/null +++ b/apps/server/src/routes/messages.ts @@ -0,0 +1,83 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; +import type { Message, Session } from '../types/api.js'; + +const SendBody = z.object({ + content: z.string().min(1).max(64_000), +}); + +interface MessageHandlers { + onSend: (sessionId: string, userMessageId: string, assistantMessageId: string) => void; + publishUserMessage: ( + sessionId: string, + userMessageId: string, + content: string + ) => void; +} + +export function registerMessageRoutes( + app: FastifyInstance, + sql: Sql, + handlers: MessageHandlers +): void { + app.get<{ Params: { id: string } }>( + '/api/sessions/:id/messages', + async (req, reply) => { + const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; + if (session.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + const rows = await sql` + SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at + FROM messages + WHERE session_id = ${req.params.id} + ORDER BY created_at ASC, id ASC + `; + return rows; + } + ); + + app.post<{ Params: { id: string } }>( + '/api/sessions/:id/messages', + async (req, reply) => { + const parsed = SendBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + + const session = await sql`SELECT id FROM sessions WHERE id = ${req.params.id}`; + if (session.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + + const result = await sql.begin(async (tx) => { + const [userMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, role, content, status, created_at) + VALUES (${req.params.id}, 'user', ${parsed.data.content}, 'complete', clock_timestamp()) + RETURNING id + `; + const [assistantMsg] = await tx<{ id: string }[]>` + INSERT INTO messages (session_id, role, content, status, created_at) + VALUES (${req.params.id}, 'assistant', '', 'streaming', clock_timestamp()) + RETURNING id + `; + await tx`UPDATE sessions SET updated_at = NOW() WHERE id = ${req.params.id}`; + return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id }; + }); + + handlers.publishUserMessage( + req.params.id, + result.user_message_id, + parsed.data.content + ); + handlers.onSend(req.params.id, result.user_message_id, result.assistant_message_id); + + reply.code(202); + return result; + } + ); +} diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts new file mode 100644 index 0000000..b3266d0 --- /dev/null +++ b/apps/server/src/routes/models.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance } from 'fastify'; +import type { Config } from '../config.js'; +import type { ModelInfo } from '../types/api.js'; + +interface LlamaSwapModelsResponse { + data?: ModelInfo[]; +} + +export function registerModelRoutes(app: FastifyInstance, config: Config): void { + app.get('/api/models', async (_req, reply) => { + try { + const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`); + if (!res.ok) { + reply.code(502); + return { error: `llama-swap returned ${res.status}` }; + } + const parsed = (await res.json()) as LlamaSwapModelsResponse; + return parsed.data ?? []; + } catch (err) { + reply.code(502); + return { + error: 'failed to reach llama-swap', + details: err instanceof Error ? err.message : String(err), + }; + } + }); +} diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts new file mode 100644 index 0000000..41c0844 --- /dev/null +++ b/apps/server/src/routes/projects.ts @@ -0,0 +1,130 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { realpath, stat, readdir, access } from 'node:fs/promises'; +import { basename, resolve, sep } from 'node:path'; +import type { Sql } from '../db.js'; +import type { Config } from '../config.js'; +import type { Project, AvailableProject } from '../types/api.js'; + +const AddProjectBody = z.object({ + path: z.string().min(1), + name: z.string().min(1).optional(), +}); + +async function isDir(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch { + return false; + } +} + +async function resolveProjectPath( + raw: string, + whitelist: string +): Promise<{ real: string; name: string } | { error: string }> { + if (!raw.startsWith('/')) return { error: 'path must be absolute' }; + let real: string; + try { + real = await realpath(raw); + } catch { + return { error: 'path does not exist' }; + } + const whitelistReal = await realpath(whitelist); + if (real !== whitelistReal && !real.startsWith(whitelistReal + sep)) { + return { error: `path must be under ${whitelist}` }; + } + if (!(await isDir(real))) return { error: 'path is not a directory' }; + return { real, name: basename(real) }; +} + +export function registerProjectRoutes( + app: FastifyInstance, + sql: Sql, + config: Config +): void { + app.get('/api/projects', async () => { + const rows = await sql` + SELECT id, name, path, added_at, last_session_id + FROM projects + ORDER BY added_at DESC + `; + return rows; + }); + + app.post('/api/projects', async (req, reply) => { + const parsed = AddProjectBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const resolved = await resolveProjectPath(parsed.data.path, config.PROJECT_ROOT_WHITELIST); + if ('error' in resolved) { + reply.code(400); + return { error: resolved.error }; + } + const name = parsed.data.name?.trim() || resolved.name; + try { + const [row] = await sql` + INSERT INTO projects (name, path) + VALUES (${name}, ${resolved.real}) + RETURNING id, name, path, added_at, last_session_id + `; + reply.code(201); + return row; + } catch (err) { + if (err instanceof Error && err.message.includes('duplicate key')) { + reply.code(409); + return { error: 'project already exists' }; + } + throw err; + } + }); + + app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { + const id = req.params.id; + const result = await sql`DELETE FROM projects WHERE id = ${id}`; + if (result.count === 0) { + reply.code(404); + return { error: 'not found' }; + } + reply.code(204); + return null; + }); + + app.get('/api/projects/available', async () => { + const whitelist = await realpath(config.PROJECT_ROOT_WHITELIST); + let entries: string[]; + try { + entries = await readdir(whitelist); + } catch { + return [] as AvailableProject[]; + } + + const existing = await sql<{ path: string }[]>`SELECT path FROM projects`; + const existingSet = new Set(existing.map((r) => r.path)); + + const out: AvailableProject[] = []; + for (const entry of entries) { + const full = resolve(whitelist, entry); + let real: string; + try { + real = await realpath(full); + } catch { + continue; + } + if (real !== whitelist && !real.startsWith(whitelist + sep)) continue; + if (existingSet.has(real)) continue; + if (!(await isDir(real))) continue; + try { + await access(resolve(real, '.git')); + } catch { + continue; + } + out.push({ path: real, name: basename(real) }); + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; + }); +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts new file mode 100644 index 0000000..2d272d2 --- /dev/null +++ b/apps/server/src/routes/sessions.ts @@ -0,0 +1,138 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; +import type { Config } from '../config.js'; +import type { Session } from '../types/api.js'; +import { getSetting } from './settings.js'; + +const CreateBody = z.object({ + name: z.string().min(1).max(200).optional(), + model: z.string().min(1).max(200).optional(), + system_prompt: z.string().max(8000).optional(), +}); + +const PatchBody = z.object({ + name: z.string().min(1).max(200).optional(), + model: z.string().min(1).max(200).optional(), + system_prompt: z.string().max(8000).optional(), +}); + +async function resolveDefaultModel(sql: Sql, config: Config): Promise { + const fromDb = await getSetting(sql, 'default_model'); + if (typeof fromDb === 'string' && fromDb.length > 0) return fromDb; + return config.DEFAULT_MODEL; +} + +export function registerSessionRoutes( + app: FastifyInstance, + sql: Sql, + config: Config +): void { + app.get<{ Params: { id: string } }>( + '/api/projects/:id/sessions', + async (req, reply) => { + const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; + if (project.length === 0) { + reply.code(404); + return { error: 'project not found' }; + } + const rows = await sql` + SELECT id, project_id, name, model, system_prompt, created_at, updated_at + FROM sessions + WHERE project_id = ${req.params.id} + ORDER BY updated_at DESC + `; + return rows; + } + ); + + app.post<{ Params: { id: string } }>( + '/api/projects/:id/sessions', + async (req, reply) => { + const parsed = CreateBody.safeParse(req.body ?? {}); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const project = await sql`SELECT id FROM projects WHERE id = ${req.params.id}`; + if (project.length === 0) { + reply.code(404); + return { error: 'project not found' }; + } + + let model = parsed.data.model; + if (!model) { + const lastUsed = await sql<{ model: string }[]>` + SELECT model FROM sessions + WHERE project_id = ${req.params.id} + ORDER BY created_at DESC + LIMIT 1 + `; + model = lastUsed[0]?.model ?? (await resolveDefaultModel(sql, config)); + } + + const name = parsed.data.name ?? 'New session'; + const systemPrompt = parsed.data.system_prompt ?? ''; + + const [row] = await sql` + INSERT INTO sessions (project_id, name, model, system_prompt) + VALUES (${req.params.id}, ${name}, ${model}, ${systemPrompt}) + RETURNING id, project_id, name, model, system_prompt, created_at, updated_at + `; + reply.code(201); + return row; + } + ); + + app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { + const rows = await sql` + SELECT id, project_id, name, model, system_prompt, created_at, updated_at + FROM sessions WHERE id = ${req.params.id} + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + return rows[0]; + }); + + app.patch<{ Params: { id: string } }>( + '/api/sessions/:id', + async (req, reply) => { + const parsed = PatchBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + const { name, model, system_prompt } = parsed.data; + const rows = await sql` + UPDATE sessions + SET + name = COALESCE(${name ?? null}, name), + model = COALESCE(${model ?? null}, model), + system_prompt = COALESCE(${system_prompt ?? null}, system_prompt), + updated_at = NOW() + WHERE id = ${req.params.id} + RETURNING id, project_id, name, model, system_prompt, created_at, updated_at + `; + if (rows.length === 0) { + reply.code(404); + return { error: 'session not found' }; + } + return rows[0]; + } + ); + + app.delete<{ Params: { id: string } }>( + '/api/sessions/:id', + async (req, reply) => { + const result = await sql`DELETE FROM sessions WHERE id = ${req.params.id}`; + if (result.count === 0) { + reply.code(404); + return { error: 'not found' }; + } + reply.code(204); + return null; + } + ); +} diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts new file mode 100644 index 0000000..5f762de --- /dev/null +++ b/apps/server/src/routes/settings.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import type { Sql } from '../db.js'; + +export async function getSetting( + sql: Sql, + key: string +): Promise { + const rows = await sql<{ value: T }[]>`SELECT value FROM settings WHERE key = ${key}`; + return rows[0]?.value ?? null; +} + +export async function setSetting( + sql: Sql, + key: string, + value: unknown +): Promise { + await sql` + INSERT INTO settings (key, value) + VALUES (${key}, ${sql.json(value as never)}) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `; +} + +const PatchBody = z.record(z.string(), z.unknown()); + +export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void { + app.get('/api/settings', async () => { + const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`; + const out: Record = {}; + for (const r of rows) out[r.key] = r.value; + return out; + }); + + app.patch('/api/settings', async (req, reply) => { + const parsed = PatchBody.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'invalid body', details: parsed.error.flatten() }; + } + for (const [k, v] of Object.entries(parsed.data)) { + await setSetting(sql, k, v); + } + const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`; + const out: Record = {}; + for (const r of rows) out[r.key] = r.value; + return out; + }); +} diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts new file mode 100644 index 0000000..b1f8d33 --- /dev/null +++ b/apps/server/src/routes/ws.ts @@ -0,0 +1,45 @@ +import type { FastifyInstance } from 'fastify'; +import type { Sql } from '../db.js'; +import type { Broker } from '../services/broker.js'; +import type { Message } from '../types/api.js'; + +export function registerWebSocket( + app: FastifyInstance, + sql: Sql, + broker: Broker +): void { + app.get<{ Params: { id: string } }>( + '/api/ws/sessions/:id', + { websocket: true }, + async (socket, req) => { + const sessionId = req.params.id; + + const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`; + if (session.length === 0) { + socket.send(JSON.stringify({ type: 'error', error: 'session not found' })); + socket.close(1008, 'session not found'); + return; + } + + const messages = await sql` + SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at + FROM messages + WHERE session_id = ${sessionId} + ORDER BY created_at ASC, id ASC + `; + socket.send(JSON.stringify({ type: 'snapshot', messages })); + + const unsubscribe = broker.subscribe(sessionId, (frame) => { + if (socket.readyState !== socket.OPEN) return; + try { + socket.send(JSON.stringify(frame)); + } catch (err) { + app.log.warn({ err, sessionId }, 'ws send failed'); + } + }); + + socket.on('close', () => unsubscribe()); + socket.on('error', () => unsubscribe()); + } + ); +} diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql new file mode 100644 index 0000000..aabc4ed --- /dev/null +++ b/apps/server/src/schema.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_session_id UUID +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + model TEXT NOT NULL, + system_prompt TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')), + content TEXT NOT NULL DEFAULT '', + tool_calls JSONB, + tool_results JSONB, + status TEXT NOT NULL DEFAULT 'complete' CHECK (status IN ('streaming', 'complete', 'failed')), + last_seq INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL +); + +INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING; diff --git a/apps/server/src/services/broker.ts b/apps/server/src/services/broker.ts new file mode 100644 index 0000000..e9c0005 --- /dev/null +++ b/apps/server/src/services/broker.ts @@ -0,0 +1,38 @@ +export type Frame = Record & { type: string }; +export type Listener = (frame: Frame) => void; + +export interface Broker { + publish(sessionId: string, frame: Frame): void; + subscribe(sessionId: string, listener: Listener): () => void; +} + +export function createBroker(): Broker { + const topics = new Map>(); + return { + publish(sessionId, frame) { + const set = topics.get(sessionId); + if (!set) return; + for (const listener of set) { + try { + listener(frame); + } catch { + // ignore listener errors so one bad subscriber doesn't break the rest + } + } + }, + subscribe(sessionId, listener) { + let set = topics.get(sessionId); + if (!set) { + set = new Set(); + topics.set(sessionId, set); + } + set.add(listener); + return () => { + const s = topics.get(sessionId); + if (!s) return; + s.delete(listener); + if (s.size === 0) topics.delete(sessionId); + }; + }, + }; +} diff --git a/apps/server/src/services/inference.ts b/apps/server/src/services/inference.ts new file mode 100644 index 0000000..d026b32 --- /dev/null +++ b/apps/server/src/services/inference.ts @@ -0,0 +1,471 @@ +import type { FastifyBaseLogger } from 'fastify'; +import type { Sql } from '../db.js'; +import type { Config } from '../config.js'; +import type { Message, Project, Session, ToolCall } from '../types/api.js'; +import { ALL_TOOLS, TOOLS_BY_NAME, toolJsonSchemas } from './tools.js'; +import { PathScopeError, resolveProjectRoot } from './path_guard.js'; + +const BASE_SYSTEM_PROMPT = (projectPath: string) => + `You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`; + +const DB_FLUSH_INTERVAL_MS = 500; +const MAX_TOOL_LOOP_DEPTH = 5; + +export interface InferenceFrame { + type: 'message_started' | 'delta' | 'tool_call' | 'tool_result' | 'message_complete' | 'error'; + message_id?: string; + tool_message_id?: string; + tool_call_id?: string; + role?: 'assistant' | 'tool' | 'user'; + content?: string; + tool_call?: ToolCall; + output?: unknown; + truncated?: boolean; + error?: string; +} + +export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void; + +interface OpenAiMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { name: string; arguments: string }; + }>; + tool_call_id?: string; +} + +interface ChatCompletionDelta { + role?: string; + content?: string | null; + tool_calls?: Array<{ + index: number; + id?: string; + type?: 'function'; + function?: { name?: string; arguments?: string }; + }>; +} + +interface ChatCompletionChunk { + choices: Array<{ + delta: ChatCompletionDelta; + finish_reason: string | null; + }>; +} + +interface InferenceContext { + sql: Sql; + config: Config; + log: FastifyBaseLogger; + publish: FramePublisher; +} + +export function buildMessagesPayload( + session: Session, + project: Project, + history: Message[] +): OpenAiMessage[] { + const out: OpenAiMessage[] = []; + let systemPrompt = BASE_SYSTEM_PROMPT(project.path); + if (session.system_prompt && session.system_prompt.trim().length > 0) { + systemPrompt += '\n\n' + session.system_prompt.trim(); + } + out.push({ role: 'system', content: systemPrompt }); + + for (const m of history) { + if (m.role === 'assistant' && m.status === 'streaming') continue; + if (m.role === 'tool') { + const tr = m.tool_results; + if (!tr) continue; + const outputText = tr.error + ? `error: ${tr.error}` + : typeof tr.output === 'string' + ? tr.output + : JSON.stringify(tr.output); + out.push({ + role: 'tool', + content: outputText, + tool_call_id: tr.tool_call_id, + }); + continue; + } + if (m.role === 'assistant') { + const msg: OpenAiMessage = { + role: 'assistant', + content: m.content && m.content.length > 0 ? m.content : null, + }; + if (m.tool_calls && m.tool_calls.length > 0) { + msg.tool_calls = m.tool_calls.map((tc) => ({ + id: tc.id, + type: 'function' as const, + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })); + } + out.push(msg); + continue; + } + out.push({ role: 'user', content: m.content }); + } + return out; +} + +async function loadContext( + sql: Sql, + sessionId: string +): Promise<{ session: Session; project: Project; history: Message[] } | null> { + const sessionRows = await sql` + SELECT id, project_id, name, model, system_prompt, created_at, updated_at + FROM sessions WHERE id = ${sessionId} + `; + if (sessionRows.length === 0) return null; + const session = sessionRows[0]!; + + const projectRows = await sql` + SELECT id, name, path, added_at, last_session_id + FROM projects WHERE id = ${session.project_id} + `; + if (projectRows.length === 0) return null; + const project = projectRows[0]!; + + const history = await sql` + SELECT id, session_id, role, content, tool_calls, tool_results, status, last_seq, created_at + FROM messages + WHERE session_id = ${sessionId} + ORDER BY created_at ASC, id ASC + `; + + return { session, project, history }; +} + +async function* sseLines(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx).replace(/\r$/, ''); + buffer = buffer.slice(idx + 1); + if (line.length === 0) continue; + yield line; + } + } + if (buffer.length > 0) yield buffer; + } finally { + reader.releaseLock(); + } +} + +async function streamCompletion( + ctx: InferenceContext, + model: string, + messages: OpenAiMessage[], + includeTools: boolean, + onDelta: (content: string) => void +): Promise<{ finishReason: string | null; content: string; toolCalls: ToolCall[] }> { + const body: Record = { model, messages, stream: true }; + if (includeTools) { + body['tools'] = toolJsonSchemas(); + body['tool_choice'] = 'auto'; + } + + const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ''); + throw new Error(`llama-swap returned ${res.status}: ${text.slice(0, 200)}`); + } + + let content = ''; + let finishReason: string | null = null; + const toolCallsBuffer = new Map(); + + for await (const line of sseLines(res.body)) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (payload === '[DONE]') break; + let parsed: ChatCompletionChunk; + try { + parsed = JSON.parse(payload); + } catch { + continue; + } + const choice = parsed.choices?.[0]; + if (!choice) continue; + const delta = choice.delta ?? {}; + if (typeof delta.content === 'string' && delta.content.length > 0) { + content += delta.content; + onDelta(delta.content); + } + if (Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const idx = tc.index; + const existing = toolCallsBuffer.get(idx) ?? { id: '', name: '', argsText: '' }; + if (tc.id) existing.id = tc.id; + if (tc.function?.name) existing.name = tc.function.name; + if (typeof tc.function?.arguments === 'string') existing.argsText += tc.function.arguments; + toolCallsBuffer.set(idx, existing); + } + } + if (choice.finish_reason) finishReason = choice.finish_reason; + } + + const toolCalls: ToolCall[] = []; + for (const [, t] of [...toolCallsBuffer.entries()].sort(([a], [b]) => a - b)) { + let args: Record = {}; + if (t.argsText.length > 0) { + try { + args = JSON.parse(t.argsText); + } catch { + args = { _raw: t.argsText }; + } + } + toolCalls.push({ id: t.id || `call_${toolCalls.length}`, name: t.name, args }); + } + + return { finishReason, content, toolCalls }; +} + +async function executeToolCall( + projectRoot: string, + toolCall: ToolCall +): Promise<{ output: unknown; truncated: boolean; error?: string }> { + const tool = TOOLS_BY_NAME[toolCall.name]; + if (!tool) { + return { output: null, truncated: false, error: `unknown tool: ${toolCall.name}` }; + } + const parsed = tool.inputSchema.safeParse(toolCall.args); + if (!parsed.success) { + return { + output: null, + truncated: false, + error: `invalid input: ${JSON.stringify(parsed.error.flatten())}`, + }; + } + try { + const output = await tool.execute(parsed.data, projectRoot); + const truncated = + typeof output === 'object' && output !== null && 'truncated' in output + ? Boolean((output as { truncated: unknown }).truncated) + : false; + return { output, truncated }; + } catch (err) { + if (err instanceof PathScopeError) { + return { output: null, truncated: false, error: err.message }; + } + return { + output: null, + truncated: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +async function runAssistantTurn( + ctx: InferenceContext, + sessionId: string, + assistantMessageId: string, + depth: number +): Promise { + if (depth > MAX_TOOL_LOOP_DEPTH) { + await ctx.sql` + UPDATE messages + SET status = 'failed', content = ${'tool loop depth exceeded'} + WHERE id = ${assistantMessageId} + `; + ctx.publish(sessionId, { + type: 'error', + message_id: assistantMessageId, + error: 'tool loop depth exceeded', + }); + return; + } + + const loaded = await loadContext(ctx.sql, sessionId); + if (!loaded) { + ctx.log.warn({ sessionId }, 'inference: session or project missing'); + return; + } + const { session, project, history } = loaded; + const projectRoot = await resolveProjectRoot(project.path); + const messages = buildMessagesPayload(session, project, history); + + ctx.publish(sessionId, { + type: 'message_started', + message_id: assistantMessageId, + role: 'assistant', + }); + + let accumulated = ''; + let pendingFlushTimer: NodeJS.Timeout | null = null; + let flushPromise: Promise = Promise.resolve(); + + const flushNow = () => { + if (pendingFlushTimer) { + clearTimeout(pendingFlushTimer); + pendingFlushTimer = null; + } + const snapshot = accumulated; + flushPromise = flushPromise.then(() => + ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` + ); + }; + + const scheduleFlush = () => { + if (pendingFlushTimer) return; + pendingFlushTimer = setTimeout(() => { + pendingFlushTimer = null; + flushNow(); + }, DB_FLUSH_INTERVAL_MS); + }; + + let content = ''; + let finishReason: string | null = null; + let toolCalls: ToolCall[] = []; + + try { + const result = await streamCompletion( + ctx, + session.model, + messages, + true, + (delta) => { + accumulated += delta; + ctx.publish(sessionId, { + type: 'delta', + message_id: assistantMessageId, + content: delta, + }); + ctx.log.debug({ sessionId, delta }, 'inference delta'); + scheduleFlush(); + } + ); + content = result.content; + finishReason = result.finishReason; + toolCalls = result.toolCalls; + } catch (err) { + if (pendingFlushTimer) { + clearTimeout(pendingFlushTimer); + pendingFlushTimer = null; + } + const errMsg = err instanceof Error ? err.message : String(err); + await ctx.sql` + UPDATE messages + SET status = 'failed', content = ${accumulated} + WHERE id = ${assistantMessageId} + `; + ctx.publish(sessionId, { + type: 'error', + message_id: assistantMessageId, + error: errMsg, + }); + ctx.log.error({ err, sessionId, assistantMessageId }, 'inference failed'); + return; + } + + if (pendingFlushTimer) { + clearTimeout(pendingFlushTimer); + pendingFlushTimer = null; + } + await flushPromise; + + if (toolCalls.length > 0) { + await ctx.sql` + UPDATE messages + SET content = ${content}, status = 'complete', + tool_calls = ${ctx.sql.json(toolCalls as never)} + WHERE id = ${assistantMessageId} + `; + for (const tc of toolCalls) { + ctx.publish(sessionId, { + type: 'tool_call', + message_id: assistantMessageId, + tool_call: tc, + }); + } + ctx.publish(sessionId, { + type: 'message_complete', + message_id: assistantMessageId, + }); + + await Promise.all( + toolCalls.map(async (tc) => { + const [toolRow] = await ctx.sql<{ id: string }[]>` + INSERT INTO messages (session_id, role, content, status, created_at) + VALUES (${sessionId}, 'tool', '', 'complete', clock_timestamp()) + RETURNING id + `; + const toolMessageId = toolRow!.id; + const result = await executeToolCall(projectRoot, tc); + const stored = { + tool_call_id: tc.id, + output: result.output, + truncated: result.truncated, + ...(result.error ? { error: result.error } : {}), + }; + await ctx.sql` + UPDATE messages + SET tool_results = ${ctx.sql.json(stored as never)} + WHERE id = ${toolMessageId} + `; + ctx.publish(sessionId, { + type: 'tool_result', + tool_message_id: toolMessageId, + tool_call_id: tc.id, + output: result.output, + truncated: result.truncated, + ...(result.error ? { error: result.error } : {}), + }); + }) + ); + + const [nextAssistant] = await ctx.sql<{ id: string }[]>` + INSERT INTO messages (session_id, role, content, status, created_at) + VALUES (${sessionId}, 'assistant', '', 'streaming', clock_timestamp()) + RETURNING id + `; + await runAssistantTurn(ctx, sessionId, nextAssistant!.id, depth + 1); + return; + } + + await ctx.sql` + UPDATE messages + SET content = ${content}, status = 'complete' + WHERE id = ${assistantMessageId} + `; + ctx.publish(sessionId, { + type: 'message_complete', + message_id: assistantMessageId, + }); + ctx.log.info({ sessionId, assistantMessageId, finishReason, chars: content.length }, 'inference complete'); +} + +export async function runInference( + ctx: InferenceContext, + sessionId: string, + assistantMessageId: string +): Promise { + return runAssistantTurn(ctx, sessionId, assistantMessageId, 0); +} + +export function createInferenceRunner(ctx: InferenceContext) { + return { + enqueue(sessionId: string, assistantMessageId: string) { + void runInference(ctx, sessionId, assistantMessageId).catch((err) => { + ctx.log.error({ err }, 'unhandled inference error'); + }); + }, + }; +} + +// Reference to keep ALL_TOOLS imported for type checks if needed +export const _toolNames = ALL_TOOLS.map((t) => t.name); diff --git a/apps/server/src/services/path_guard.ts b/apps/server/src/services/path_guard.ts new file mode 100644 index 0000000..c2e4c8f --- /dev/null +++ b/apps/server/src/services/path_guard.ts @@ -0,0 +1,39 @@ +import { realpath } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; + +export class PathScopeError extends Error { + constructor(message: string) { + super(message); + this.name = 'PathScopeError'; + } +} + +export async function resolveProjectRoot(projectPath: string): Promise { + try { + return await realpath(projectPath); + } catch { + throw new PathScopeError(`project path does not exist: ${projectPath}`); + } +} + +export async function pathGuard( + projectRoot: string, + requested: string +): Promise { + if (typeof requested !== 'string' || requested.length === 0) { + throw new PathScopeError('path is required'); + } + const candidate = isAbsolute(requested) ? requested : resolve(projectRoot, requested); + let real: string; + try { + real = await realpath(candidate); + } catch { + throw new PathScopeError(`path does not exist: ${requested}`); + } + if (real !== projectRoot && !real.startsWith(projectRoot + sep)) { + throw new PathScopeError( + `path escapes project root: ${requested} -> ${real}` + ); + } + return real; +} diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts new file mode 100644 index 0000000..aaf5e2b --- /dev/null +++ b/apps/server/src/services/tools.ts @@ -0,0 +1,371 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { resolve, basename, relative } from 'node:path'; +import { spawn } from 'node:child_process'; +import { z } from 'zod'; +import { pathGuard, PathScopeError } from './path_guard.js'; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const DEFAULT_VIEW_LINES = 200; +const MAX_GREP_RESULTS = 200; +const DEFAULT_GREP_RESULTS = 100; +const MAX_FIND_RESULTS = 200; +const DEFAULT_FIND_RESULTS = 100; +const MAX_DIR_ENTRIES = 500; + +export interface ToolJsonSchema { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +export interface ToolDef { + name: string; + description: string; + inputSchema: z.ZodType; + jsonSchema: ToolJsonSchema; + execute(input: TInput, projectRoot: string): Promise; +} + +const ViewFileInput = z.object({ + path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), +}); +type ViewFileInputT = z.infer; + +export const viewFile: ToolDef = { + name: 'view_file', + description: + "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.", + inputSchema: ViewFileInput, + jsonSchema: { + type: 'function', + function: { + name: 'view_file', + description: + "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.", + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'absolute or project-relative path' }, + start_line: { type: 'integer', description: 'first line (1-indexed)' }, + end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + const real = await pathGuard(projectRoot, input.path); + const s = await stat(real); + if (!s.isFile()) { + throw new PathScopeError(`not a file: ${input.path}`); + } + if (s.size > MAX_FILE_BYTES) { + throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`); + } + const raw = await readFile(real, 'utf8'); + const lines = raw.split('\n'); + const total = lines.length; + let start = input.start_line ?? 1; + let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1); + if (input.start_line == null && input.end_line == null) { + end = Math.min(total, DEFAULT_VIEW_LINES); + } + if (start < 1) start = 1; + if (end > total) end = total; + if (end < start) end = start; + const slice = lines.slice(start - 1, end); + const content = slice.join('\n'); + const truncated = total > end || start > 1; + return { + path: relative(projectRoot, real) || basename(real), + content, + total_lines: total, + returned_lines: [start, end], + truncated, + }; + }, +}; + +const ListDirInput = z.object({ + path: z.string().min(1), + show_hidden: z.boolean().optional(), +}); +type ListDirInputT = z.infer; + +export const listDir: ToolDef = { + name: 'list_dir', + description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.', + inputSchema: ListDirInput, + jsonSchema: { + type: 'function', + function: { + name: 'list_dir', + description: + 'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + show_hidden: { type: 'boolean' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + const real = await pathGuard(projectRoot, input.path); + const s = await stat(real); + if (!s.isDirectory()) { + throw new PathScopeError(`not a directory: ${input.path}`); + } + const entries = await readdir(real, { withFileTypes: true }); + const filtered = input.show_hidden + ? entries + : entries.filter((e) => !e.name.startsWith('.')); + const total = filtered.length; + const slice = filtered.slice(0, MAX_DIR_ENTRIES); + const out = await Promise.all( + slice.map(async (e) => { + const child = resolve(real, e.name); + let size: number | undefined; + if (e.isFile()) { + try { + const cs = await stat(child); + size = cs.size; + } catch { + /* ignore */ + } + } + return { + name: e.name, + type: e.isDirectory() ? ('dir' as const) : ('file' as const), + ...(size != null ? { size } : {}), + }; + }) + ); + return { + path: relative(projectRoot, real) || '.', + entries: out, + total, + truncated: total > MAX_DIR_ENTRIES, + }; + }, +}; + +const GrepInput = z.object({ + pattern: z.string().min(1), + path: z.string().optional(), + case_sensitive: z.boolean().optional(), + max_results: z.number().int().positive().optional(), + hidden: z.boolean().optional(), +}); +type GrepInputT = z.infer; + +interface RipgrepMatch { + type: string; + data?: { + path?: { text?: string }; + line_number?: number; + lines?: { text?: string }; + }; +} + +export const grep: ToolDef = { + name: 'grep', + description: + 'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).', + inputSchema: GrepInput, + jsonSchema: { + type: 'function', + function: { + name: 'grep', + description: + 'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string' }, + case_sensitive: { type: 'boolean' }, + max_results: { type: 'integer' }, + hidden: { type: 'boolean' }, + }, + required: ['pattern'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + const target = await pathGuard(projectRoot, input.path ?? projectRoot); + const limit = Math.min( + Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1), + MAX_GREP_RESULTS + ); + const args = [ + '--json', + '--max-count', + String(limit), + '--max-columns', + '300', + ]; + if (!input.case_sensitive) args.push('--ignore-case'); + if (input.hidden) args.push('--hidden'); + args.push('--', input.pattern, target); + + return await new Promise((resolveP, rejectP) => { + const child = spawn('rg', args, { cwd: projectRoot }); + const matches: Array<{ path: string; line: number; content: string }> = []; + let buf = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buf += chunk; + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (!line) continue; + if (matches.length >= limit) continue; + try { + const parsed = JSON.parse(line) as RipgrepMatch; + if (parsed.type !== 'match' || !parsed.data) continue; + const path = parsed.data.path?.text ?? ''; + const lineNumber = parsed.data.line_number ?? 0; + const content = parsed.data.lines?.text ?? ''; + matches.push({ + path: relative(projectRoot, path) || path, + line: lineNumber, + content: content.replace(/\n$/, ''), + }); + } catch { + /* ignore non-json */ + } + } + if (matches.length >= limit) { + child.kill(); + } + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (err) => rejectP(err)); + child.on('close', (code) => { + // rg exits 1 when no matches, 2 on real error + if (code === 2 && matches.length === 0) { + rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`)); + return; + } + resolveP({ + matches, + total: matches.length, + truncated: matches.length >= limit, + }); + }); + }); + }, +}; + +const FindFilesInput = z.object({ + pattern: z.string().min(1), + path: z.string().optional(), + max_results: z.number().int().positive().optional(), +}); +type FindFilesInputT = z.infer; + +export const findFiles: ToolDef = { + name: 'find_files', + description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).', + inputSchema: FindFilesInput, + jsonSchema: { + type: 'function', + function: { + name: 'find_files', + description: + 'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string' }, + max_results: { type: 'integer' }, + }, + required: ['pattern'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot) { + const target = await pathGuard(projectRoot, input.path ?? projectRoot); + const limit = Math.min( + Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1), + MAX_FIND_RESULTS + ); + return await new Promise((resolveP, rejectP) => { + const args = ['--files', '--glob', input.pattern, target]; + const child = spawn('rg', args, { cwd: projectRoot }); + const paths: string[] = []; + let total = 0; + let buf = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buf += chunk; + let idx; + while ((idx = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, idx); + buf = buf.slice(idx + 1); + if (!line) continue; + total++; + if (paths.length < limit) { + paths.push(relative(projectRoot, line) || line); + } + } + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (err) => rejectP(err)); + child.on('close', (code) => { + if (code === 2) { + rejectP(new Error(`ripgrep failed: ${stderr.slice(0, 300)}`)); + return; + } + if (buf.length > 0) { + total++; + if (paths.length < limit) { + paths.push(relative(projectRoot, buf) || buf); + } + } + resolveP({ + paths, + total, + truncated: total > paths.length, + }); + }); + }); + }, +}; + +export const ALL_TOOLS: ReadonlyArray> = [ + viewFile as ToolDef, + listDir as ToolDef, + grep as ToolDef, + findFiles as ToolDef, +]; + +export const TOOLS_BY_NAME: Record> = Object.fromEntries( + ALL_TOOLS.map((t) => [t.name, t]) +); + +export function toolJsonSchemas(): ToolJsonSchema[] { + return ALL_TOOLS.map((t) => t.jsonSchema); +} diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts new file mode 100644 index 0000000..1cd2b5c --- /dev/null +++ b/apps/server/src/types/api.ts @@ -0,0 +1,55 @@ +export interface Project { + id: string; + name: string; + path: string; + added_at: string; + last_session_id: string | null; +} + +export interface AvailableProject { + path: string; + name: string; +} + +export interface Session { + id: string; + project_id: string; + name: string; + model: string; + system_prompt: string; + created_at: string; + updated_at: string; +} + +export type MessageRole = 'user' | 'assistant' | 'tool'; +export type MessageStatus = 'streaming' | 'complete' | 'failed'; + +export interface ToolCall { + id: string; + name: string; + args: Record; +} + +export interface ToolResult { + tool_call_id: string; + output: unknown; + truncated: boolean; + error?: string; +} + +export interface Message { + id: string; + session_id: string; + role: MessageRole; + content: string; + tool_calls: ToolCall[] | null; + tool_results: ToolResult | null; + status: MessageStatus; + last_seq: number; + created_at: string; +} + +export interface ModelInfo { + id: string; + [key: string]: unknown; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..eb9098f --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2022"], + "types": ["node"], + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..5eb55ce --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..b29ff30 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + BooCode + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..8e7c2f7 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "@boocode/web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.16.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.0", + "shadcn": "^4.7.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "tailwindcss": "^4.3.0", + "typescript": "^5.5.0", + "vite": "^5.3.4" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..e629e36 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,24 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ProjectSidebar } from '@/components/ProjectSidebar'; +import { Home } from '@/pages/Home'; +import { Project } from '@/pages/Project'; +import { Session } from '@/pages/Session'; +import { Toaster } from '@/components/ui/sonner'; + +export default function App() { + return ( + +
+ +
+ + } /> + } /> + } /> + +
+ +
+
+ ); +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..da174d1 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,98 @@ +import type { + Project, + AvailableProject, + Session, + Message, + ModelInfo, +} from './types'; + +export class ApiError extends Error { + constructor( + public status: number, + public body: unknown + ) { + super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`); + } +} + +async function request( + path: string, + init: RequestInit = {} +): Promise { + const res = await fetch(path, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + if (res.status === 204) return undefined as T; + const text = await res.text(); + const data = text ? JSON.parse(text) : undefined; + if (!res.ok) throw new ApiError(res.status, data); + return data as T; +} + +export const api = { + health: () => request<{ status: string; db: boolean }>('/api/health'), + + projects: { + list: () => request('/api/projects'), + available: () => request('/api/projects/available'), + add: (body: { path: string; name?: string }) => + request('/api/projects', { + method: 'POST', + body: JSON.stringify(body), + }), + remove: (id: string) => + request(`/api/projects/${id}`, { method: 'DELETE' }), + }, + + sessions: { + listForProject: (projectId: string) => + request(`/api/projects/${projectId}/sessions`), + create: ( + projectId: string, + body: { name?: string; model?: string; system_prompt?: string } + ) => + request(`/api/projects/${projectId}/sessions`, { + method: 'POST', + body: JSON.stringify(body), + }), + get: (id: string) => request(`/api/sessions/${id}`), + update: ( + id: string, + body: Partial> + ) => + request(`/api/sessions/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + remove: (id: string) => + request(`/api/sessions/${id}`, { method: 'DELETE' }), + }, + + messages: { + list: (sessionId: string) => + request(`/api/sessions/${sessionId}/messages`), + send: (sessionId: string, content: string) => + request<{ user_message_id: string; assistant_message_id: string }>( + `/api/sessions/${sessionId}/messages`, + { + method: 'POST', + body: JSON.stringify({ content }), + } + ), + }, + + models: () => request('/api/models'), + + settings: { + get: () => request>('/api/settings'), + patch: (body: Record) => + request>('/api/settings', { + method: 'PATCH', + body: JSON.stringify(body), + }), + }, +}; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts new file mode 100644 index 0000000..eaa7578 --- /dev/null +++ b/apps/web/src/api/types.ts @@ -0,0 +1,71 @@ +export interface Project { + id: string; + name: string; + path: string; + added_at: string; + last_session_id: string | null; +} + +export interface AvailableProject { + path: string; + name: string; +} + +export interface Session { + id: string; + project_id: string; + name: string; + model: string; + system_prompt: string; + created_at: string; + updated_at: string; +} + +export type MessageRole = 'user' | 'assistant' | 'tool'; +export type MessageStatus = 'streaming' | 'complete' | 'failed'; + +export interface ToolCall { + id: string; + name: string; + args: Record; +} + +export interface ToolResult { + tool_call_id: string; + output: unknown; + truncated: boolean; + error?: string; +} + +export interface Message { + id: string; + session_id: string; + role: MessageRole; + content: string; + tool_calls: ToolCall[] | null; + tool_results: ToolResult | null; + status: MessageStatus; + last_seq: number; + created_at: string; +} + +export interface ModelInfo { + id: string; + [key: string]: unknown; +} + +export type WsFrame = + | { type: 'snapshot'; messages: Message[] } + | { type: 'message_started'; message_id: string; role: MessageRole } + | { type: 'delta'; message_id: string; content: string } + | { type: 'tool_call'; message_id: string; tool_call: ToolCall } + | { + type: 'tool_result'; + tool_message_id: string; + tool_call_id: string; + output: unknown; + truncated: boolean; + error?: string; + } + | { type: 'message_complete'; message_id: string } + | { type: 'error'; message_id?: string; error: string }; diff --git a/apps/web/src/components/AddProjectModal.tsx b/apps/web/src/components/AddProjectModal.tsx new file mode 100644 index 0000000..b7a4daf --- /dev/null +++ b/apps/web/src/components/AddProjectModal.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/api/client'; +import type { AvailableProject } from '@/api/types'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded: () => void; +} + +export function AddProjectModal({ open, onOpenChange, onAdded }: Props) { + const [available, setAvailable] = useState(null); + const [customPath, setCustomPath] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!open) return; + setError(null); + setCustomPath(''); + setAvailable(null); + api.projects + .available() + .then(setAvailable) + .catch((err) => + setError(err instanceof Error ? err.message : 'failed to list available projects') + ); + }, [open]); + + async function add(path: string) { + setBusy(true); + setError(null); + try { + await api.projects.add({ path }); + onAdded(); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'failed to add'); + } finally { + setBusy(false); + } + } + + return ( + + + + Add project + + Pick from detected repos in /opt or type a path. + + + +
+
+ {available === null && ( +
Loading…
+ )} + {available && available.length === 0 && ( +
+ No undiscovered repos in /opt. +
+ )} + {available?.map((p) => ( + + ))} +
+ +
+ +
+ setCustomPath(e.target.value)} + disabled={busy} + /> + +
+
+ + {error && ( +
{error}
+ )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/ChatInput.tsx b/apps/web/src/components/ChatInput.tsx new file mode 100644 index 0000000..9d711e7 --- /dev/null +++ b/apps/web/src/components/ChatInput.tsx @@ -0,0 +1,58 @@ +import { useState, type KeyboardEvent } from 'react'; +import { Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; + +interface Props { + disabled?: boolean; + onSend: (content: string) => void | Promise; +} + +export function ChatInput({ disabled, onSend }: Props) { + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + + async function submit() { + const text = value.trim(); + if (!text || disabled || busy) return; + setBusy(true); + try { + await onSend(text); + setValue(''); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'failed to send'); + } finally { + setBusy(false); + } + } + + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + void submit(); + } + } + + return ( +
+