feat(chat): add env-gated SSE chat route

Returns 404 unless ENABLE_CHAT=true. No authentication yet — must stay
disabled in production until phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-29 20:41:31 +02:00
parent 5cc0d91f2d
commit d7b5d01333

93
src/app/api/chat/route.ts Normal file
View file

@ -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",
},
});
}