import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { SessionStore } from "./session.js";
import { encodeSseFrame, retrieve, tokenizeAnswer } from "./stream.js";
const QuerySchema = z.object({
sessionId: z.string().min(1).optional(),
role: z.string().min(1).optional(),
jurisdiction: z.string().min(1).optional(),
q: z.string().min(1),
});
export type AppOptions = {
sessionStore?: SessionStore;
tokenDelayMs?: number;
};
function renderClient(): string {
return `
Production RAG chatbot
Capstone 08 chat (skeleton)
Role: analyst, jurisdiction: GDPR. Streams SSE token-by-token.
`;
}
export function buildApp(options: AppOptions = {}): {
app: Hono;
sessions: SessionStore;
} {
const sessions = options.sessionStore ?? new SessionStore();
const tokenDelayMs = options.tokenDelayMs ?? 0;
const app = new Hono();
app.get("/", (c) => c.html(renderClient()));
app.get("/health", (c) => c.json({ ok: true, sessions: sessions.size() }));
app.get("/sessions", (c) => {
const list = sessions.list().map((s) => ({
id: s.id,
role: s.role,
jurisdiction: s.jurisdiction,
turnCount: s.turns.length,
}));
return c.json({ sessions: list });
});
app.get("/chat/stream", (c) => {
const parsed = QuerySchema.safeParse({
sessionId: c.req.query("sessionId"),
role: c.req.query("role"),
jurisdiction: c.req.query("jurisdiction"),
q: c.req.query("q"),
});
if (!parsed.success) {
return c.json({ error: "missing q" }, 400);
}
const sessionId = parsed.data.sessionId ?? randomUUID();
const role = parsed.data.role ?? "analyst";
const jurisdiction = parsed.data.jurisdiction ?? "GDPR";
const q = parsed.data.q;
const session = sessions.getOrCreate(sessionId, role, jurisdiction);
sessions.appendTurn(sessionId, { role: "user", content: q, ts: Date.now() });
return streamSSE(c, async (stream) => {
const writeFrame = async (event: string, data: unknown): Promise => {
await stream.write(encodeSseFrame(event, data));
};
await writeFrame("session", {
sessionId,
role,
jurisdiction,
turn: session.turns.length,
});
const citations = retrieve(q, jurisdiction, 3);
await writeFrame("citations", { items: citations });
const tokens = tokenizeAnswer(q, citations);
let assembled = "";
for (const tok of tokens) {
if (stream.aborted) return;
assembled += tok;
await writeFrame("token", { text: tok });
if (tokenDelayMs > 0) await stream.sleep(tokenDelayMs);
}
sessions.appendTurn(sessionId, {
role: "assistant",
content: assembled,
ts: Date.now(),
});
await writeFrame("done", { totalTokens: tokens.length });
});
});
app.notFound((c) => c.json({ error: "not found" }, 404));
return { app, sessions };
}