From d7b5d01333b00a3f79f0b548689c649a434d6249 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 29 Aug 2026 20:41:31 +0200 Subject: [PATCH] feat(chat): add env-gated SSE chat route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns 404 unless ENABLE_CHAT=true. No authentication yet — must stay disabled in production until phase 4. Co-Authored-By: Claude Opus 5 --- src/app/api/chat/route.ts | 93 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/app/api/chat/route.ts diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..e03414e --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,93 @@ +/** + * POST /api/chat — streams an answer as Server-Sent Events. + * + * Route Handlers use the Web Request/Response API, so streaming is a + * plain ReadableStream in a Response. POST is never cached. + * + * GATED: returns 404 unless ENABLE_CHAT=true. There is no authentication + * on this route — it reads every site's financials and spends LLM + * credits. Do not enable it in production until phase 4 adds auth. + */ +import { tools } from "@/lib/agent/tools"; +import { + chatEnabled, + getProvider, + runChatLoop, + toChatToolDefs, + type ChatEntry, +} from "@/lib/chat"; + +export const dynamic = "force-dynamic"; + +const toolByName = new Map(tools.map((t) => [t.name, t])); + +export async function POST(request: Request) { + if (!chatEnabled()) { + return new Response("Not found", { status: 404 }); + } + + let history: ChatEntry[]; + try { + const body = (await request.json()) as { history?: ChatEntry[] }; + history = body.history ?? []; + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (history.length === 0) { + return Response.json({ error: "history is empty" }, { status: 400 }); + } + + const provider = getProvider(); + const toolDefs = toChatToolDefs(tools); + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ); + }; + + try { + send("meta", { provider: provider.name, model: provider.model }); + + const result = await runChatLoop({ + provider, + history, + tools: toolDefs, + onTextDelta: (delta) => send("delta", { text: delta }), + executeTool: async (name, input) => { + send("tool", { name }); + const tool = toolByName.get(name); + if (!tool) throw new Error(`Unknown tool: ${name}`); + return JSON.stringify(await tool.handler(input)); + }, + }); + + send("done", { + text: result.text, + iterations: result.iterations, + stoppedOnLimit: result.stoppedOnLimit, + }); + } catch (err) { + // Surface the reason. A silent close would leave the UI showing a + // half-finished answer with no indication it failed. + send("error", { + message: err instanceof Error ? err.message : String(err), + }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} \ No newline at end of file