initial
This commit is contained in:
83
apps/server/src/routes/messages.ts
Normal file
83
apps/server/src/routes/messages.ts
Normal file
@@ -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<Session[]>`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<Message[]>`
|
||||
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<Session[]>`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;
|
||||
}
|
||||
);
|
||||
}
|
||||
27
apps/server/src/routes/models.ts
Normal file
27
apps/server/src/routes/models.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
130
apps/server/src/routes/projects.ts
Normal file
130
apps/server/src/routes/projects.ts
Normal file
@@ -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<boolean> {
|
||||
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<Project[]>`
|
||||
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<Project[]>`
|
||||
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;
|
||||
});
|
||||
}
|
||||
138
apps/server/src/routes/sessions.ts
Normal file
138
apps/server/src/routes/sessions.ts
Normal file
@@ -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<string> {
|
||||
const fromDb = await getSetting<string>(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<Session[]>`
|
||||
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<Session[]>`
|
||||
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<Session[]>`
|
||||
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<Session[]>`
|
||||
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;
|
||||
}
|
||||
);
|
||||
}
|
||||
49
apps/server/src/routes/settings.ts
Normal file
49
apps/server/src/routes/settings.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export async function getSetting<T = unknown>(
|
||||
sql: Sql,
|
||||
key: string
|
||||
): Promise<T | null> {
|
||||
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<void> {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
for (const r of rows) out[r.key] = r.value;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
45
apps/server/src/routes/ws.ts
Normal file
45
apps/server/src/routes/ws.ts
Normal file
@@ -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<Message[]>`
|
||||
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());
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user