- pnpm monorepo: apps/api (Fastify + SQLite + SSE), apps/web (React+Vite), packages/shared, packages/pi-adapter - Local auth (admin/webhook-runner roles) + Keycloak JWT ready - Multi-session chat with reliable history (user persisted before LLM, assistant persisted after stream) - Markdown knowledge base with /api/docs/search + /api/docs/:id - YAML webhook catalog with backend-only execution, retry/backoff, audit (webhook_runs), and per-user rate limit - Skills config (sre-on-call, blameless-postmortem, security-incident) injected into LLM system prompt - LLM provider failover chain (config/models.yml fallback + LLM_FALLBACK_CHAIN override) - Context-aware webhooks panel + backend id-mention safety net - Per-message stats (time/duration/tokens/model), Markdown+GFM render, code & table copy/download buttons - Vitest suite, end-to-end smoke test (scripts/smoke.mjs), per-session system prompt override - /metrics Prometheus endpoint + /api/metrics JSON, request-id correlation - dotenv with explicit repo-root path; envString/envNumber helpers (handles empty-string env) - Runbooks + SOPs under knowledge/ in English; README, docs, and INDEX.md in English
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { z } from "zod";
|
|
import { getAuthUser } from "../auth/index.js";
|
|
import type { AppDatabase } from "../db/database.js";
|
|
import { createMessageRepository, createSessionRepository } from "./repository.js";
|
|
|
|
const createSessionBody = z.object({
|
|
title: z.string().min(1).max(120).optional(),
|
|
});
|
|
|
|
const updateSessionBody = z.object({
|
|
title: z.string().trim().min(1).max(120),
|
|
});
|
|
|
|
const updateSystemPromptBody = z.object({
|
|
// Empty / whitespace-only strings clear the override; null is a no-op.
|
|
system_prompt: z.string().max(8_000).nullable().optional(),
|
|
});
|
|
|
|
export async function registerSessionRoutes(app: FastifyInstance, db: AppDatabase) {
|
|
const sessions = createSessionRepository(db);
|
|
const messages = createMessageRepository(db);
|
|
|
|
app.get("/api/sessions", async (request) => {
|
|
const user = await getAuthUser(request);
|
|
return { items: sessions.list(user.id) };
|
|
});
|
|
|
|
app.post("/api/sessions", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const body = createSessionBody.parse(request.body ?? {});
|
|
const session = sessions.create(user.id, body.title ?? null);
|
|
return reply.code(201).send(session);
|
|
});
|
|
|
|
app.get<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const session = sessions.get(user.id, request.params.id);
|
|
|
|
if (!session) {
|
|
return reply.code(404).send({ error: "session_not_found" });
|
|
}
|
|
|
|
return {
|
|
...session,
|
|
messages: messages.listForSession(user.id, session.id),
|
|
};
|
|
});
|
|
|
|
app.patch<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const body = updateSessionBody.parse(request.body);
|
|
const session = sessions.get(user.id, request.params.id);
|
|
|
|
if (!session) {
|
|
return reply.code(404).send({ error: "session_not_found" });
|
|
}
|
|
|
|
sessions.updateTitle(user.id, session.id, body.title);
|
|
return sessions.get(user.id, session.id);
|
|
});
|
|
|
|
// Per-session system prompt override. Inserted into the chat stream
|
|
// immediately after the base identity prompt, before the docs/actions
|
|
// context. Use to attach incident-specific context (runbook link, on-call
|
|
// names, severity matrix) without polluting the global prompt.
|
|
app.patch<{ Params: { id: string } }>(
|
|
"/api/sessions/:id/system-prompt",
|
|
async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const body = updateSystemPromptBody.parse(request.body ?? {});
|
|
const session = sessions.get(user.id, request.params.id);
|
|
|
|
if (!session) {
|
|
return reply.code(404).send({ error: "session_not_found" });
|
|
}
|
|
|
|
sessions.updateSystemPrompt(user.id, session.id, body.system_prompt ?? null);
|
|
return sessions.get(user.id, session.id);
|
|
},
|
|
);
|
|
|
|
app.delete<{ Params: { id: string } }>("/api/sessions/:id", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const deleted = sessions.delete(user.id, request.params.id);
|
|
|
|
if (!deleted) {
|
|
return reply.code(404).send({ error: "session_not_found" });
|
|
}
|
|
|
|
return reply.code(204).send();
|
|
});
|
|
|
|
// Bulk delete: wipes every session owned by the current user. Cascade
|
|
// removes the messages and webhook_runs that point at them. The frontend
|
|
// requires the user to type the literal word "delete" before this fires.
|
|
app.delete("/api/sessions", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const removed = sessions.deleteAllForUser(user.id);
|
|
return reply.code(200).send({ deleted: removed });
|
|
});
|
|
|
|
// Export: returns a JSON document with the session metadata and all its
|
|
// messages. The shape is a stable contract so a `POST /api/sessions/import`
|
|
// can read it back. webhook_runs are intentionally excluded from the
|
|
// export — those are operational audit data, not conversation content.
|
|
app.get<{ Params: { id: string } }>("/api/sessions/:id/export", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const session = sessions.get(user.id, request.params.id);
|
|
|
|
if (!session) {
|
|
return reply.code(404).send({ error: "session_not_found" });
|
|
}
|
|
|
|
return {
|
|
version: 1,
|
|
exported_at: new Date().toISOString(),
|
|
session: {
|
|
id: session.id,
|
|
title: session.title,
|
|
created_at: session.created_at,
|
|
updated_at: session.updated_at,
|
|
},
|
|
messages: messages.listForSession(user.id, session.id),
|
|
};
|
|
});
|
|
|
|
// Import: accepts the export document above, creates a new session owned
|
|
// by the caller, and writes the messages with fresh ids. Returns the new
|
|
// session id and a count of imported messages.
|
|
const importSessionBody = z.object({
|
|
session: z.object({
|
|
title: z.string().max(120).nullable().optional(),
|
|
created_at: z.string().optional(),
|
|
updated_at: z.string().optional(),
|
|
}),
|
|
messages: z.array(
|
|
z.object({
|
|
role: z.enum(["user", "assistant", "system"]),
|
|
content: z.string().min(1).max(50_000),
|
|
metadata: z.record(z.unknown()).optional(),
|
|
// Original created_at is preserved if present; otherwise "now" is
|
|
// used. Used only to restore the timeline.
|
|
created_at: z.string().optional(),
|
|
}),
|
|
),
|
|
});
|
|
|
|
app.post("/api/sessions/import", async (request, reply) => {
|
|
const user = await getAuthUser(request);
|
|
const body = importSessionBody.parse(request.body);
|
|
|
|
const newSession = sessions.create(user.id, body.session.title ?? null);
|
|
let imported = 0;
|
|
for (const message of body.messages) {
|
|
messages.create({
|
|
sessionId: newSession.id,
|
|
userId: user.id,
|
|
role: message.role,
|
|
content: message.content,
|
|
metadata: message.metadata,
|
|
});
|
|
imported += 1;
|
|
}
|
|
sessions.touch(user.id, newSession.id);
|
|
|
|
return reply.code(201).send({
|
|
session: newSession,
|
|
imported_messages: imported,
|
|
});
|
|
});
|
|
}
|