MEGAINFRA-WEBAPP/src/app/api/chat/route.ts
DEls 58b625701e feat(ask): serve a real PDF from a stored assistant message
GET /api/ask/messages/[id]/pdf builds a PDF for one stored assistant
row: its structured make_pdf_report document when `parsed.pdf` is set,
otherwise its own markdown (headings/paragraphs/lists/GFM tables, with
[[chart:id]]/[[image:id]] tokens resolved against that message's own
stored charts and any referenced generated-image rows). 404s for a
missing message, a non-assistant row, or a thread owned by someone
else — same "not found, not forbidden" reasoning as the existing
thread routes, since there are no logins yet. Returns
application/pdf with Content-Disposition: attachment; filename="ask-<slug>-<date>.pdf".

The chat stream's "done" event now carries the persisted assistant
row's own id (messageId), which the browser needs to ask this route
for a PDF — see the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:16:38 +02:00

341 lines
12 KiB
TypeScript

/**
* 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 beyond `x-ask-user` identification — anyone with the PIN
* can pick any name until logins land (see src/lib/agent/context.ts).
*
* Two request shapes:
* - New: `{ threadId?, message, display?, attachmentIds?, images? }`
* with an `x-ask-user` header. No threadId creates a thread owned by
* the actor; a thread owned by someone else 404s. The conversation is
* persisted to `ask_messages` so it survives leaving the page.
* - Legacy: `{ history }` with no `threadId` — kept working for one
* release so a tab open across a deploy doesn't break. Not persisted.
*/
import { tools, type AgentTool } from "@/lib/agent/tools";
import { actionTools } from "@/lib/agent/tools-actions";
import { listMemories, formatMemoriesForPrompt } from "@/lib/agent/memory";
import { extractCharts } from "@/lib/reports/chart-spec";
import type { ChartSpec } from "@/lib/reports/chart-spec";
import { resolveActor } from "@/lib/agent/context";
import type { AgentContext } from "@/lib/agent/context";
import {
autoTitle,
createThread,
getThreadOwner,
loadThreadEntries,
appendMessage,
getRecentActionIds,
} from "@/lib/chat/threads";
import { extractActionCard, describeActionOutcome, type ActionCard } from "@/lib/agent/gateway";
import { extractGeneratedImages } from "@/lib/agent/images";
import { extractPdfMeta, type PdfReportMeta } from "@/lib/agent/pdf-report";
import {
buildSystemPrompt,
chatEnabled,
getProvider,
runChatLoop,
toChatToolDefs,
type ChatEntry,
type ChatImage,
type ChatToolCall,
type ChatToolResult,
} from "@/lib/chat";
export const dynamic = "force-dynamic";
/**
* Coerce whatever the browser posted into well-formed ChatEntry values.
*
* This is a trust boundary: the request body is JSON, so the ChatEntry
* type guarantees nothing at runtime. The client legitimately sends
* assistant turns as {role, text} with no toolCalls, which previously
* crashed both adapters on the second message of a conversation.
*/
function normalizeHistory(raw: unknown): ChatEntry[] {
if (!Array.isArray(raw)) return [];
const out: ChatEntry[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const e = item as Record<string, unknown>;
if (e.role === "user") {
out.push({
role: "user",
text: String(e.text ?? ""),
...(Array.isArray(e.images) ? { images: e.images as ChatImage[] } : {}),
});
} else if (e.role === "assistant") {
out.push({
role: "assistant",
text: String(e.text ?? ""),
toolCalls: Array.isArray(e.toolCalls)
? (e.toolCalls as ChatToolCall[])
: [],
});
} else if (e.role === "tool") {
out.push({
role: "tool",
results: Array.isArray(e.results) ? (e.results as ChatToolResult[]) : [],
});
}
}
return out;
}
interface ChatRequestBody {
history?: unknown;
threadId?: string;
message?: string;
display?: string;
attachmentIds?: string[];
images?: ChatImage[];
}
/**
* Runs the agent loop and streams the result. When `threadId`/`ownerId`
* are given, the finished (or failed) assistant turn is persisted as its
* own `ask_messages` row — see the plan's append-only rule.
*/
function streamAnswer(args: {
history: ChatEntry[];
threadId?: string;
ownerId?: string;
threadTitle?: string;
system?: string;
extraTools?: AgentTool[];
}) {
const { history, threadId, ownerId, threadTitle } = args;
const provider = getProvider();
const allTools = [...tools, ...(args.extraTools ?? [])];
const toolByName = new Map(allTools.map((t) => [t.name, t]));
const toolDefs = toChatToolDefs(allTools);
const system = args.system ?? buildSystemPrompt(new Date());
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`),
);
};
const collectedCharts: ChartSpec[] = [];
const collectedActionIds: string[] = [];
const toolsUsed: string[] = [];
let lastText = "";
let pdfMeta: PdfReportMeta | null = null;
try {
send("meta", { provider: provider.name, model: provider.model });
if (threadId && threadTitle) send("thread", { id: threadId, title: threadTitle });
const result = await runChatLoop({
provider,
history,
tools: toolDefs,
system,
onTextDelta: (delta) => {
lastText += delta;
send("delta", { text: delta });
},
executeTool: async (name, input) => {
send("tool", { name });
toolsUsed.push(name);
const tool = toolByName.get(name);
if (!tool) throw new Error(`Unknown tool: ${name}`);
const raw = JSON.stringify(await tool.handler(input));
// An action card (propose_changes) goes to the browser in
// full; the model keeps only actionId/status/problems — the
// same split extractCharts makes for chart numbers.
const { forModel: withoutCard, card } = extractActionCard(raw);
if (card) {
collectedActionIds.push(card.actionId);
send("action", card as ActionCard);
}
// Generated images go to the browser in full; the model
// keeps only their ids, so it can place [[image:<id>]]
// without ever seeing pixels or a URL.
const { forModel: withoutImages, images } = extractGeneratedImages(withoutCard);
for (const img of images) send("image", img);
// Chart specs go to the browser; the model gets { id, title }
// only, so it can place a chart but never re-derive its numbers.
const { forModel: withoutCharts, charts } = extractCharts(withoutImages);
for (const c of charts) {
collectedCharts.push(c);
send("chart", c);
}
// make_pdf_report — the model's own "this answer is a
// finished report" signal, called last. The browser gets
// the full title/subtitle/filename; the model just an ack.
const { forModel, pdf } = extractPdfMeta(withoutCharts);
if (pdf) {
pdfMeta = pdf;
send("pdf", pdf);
}
return forModel;
},
});
let messageId: string | undefined;
if (threadId && ownerId) {
messageId = await appendMessage(threadId, ownerId, {
kind: "assistant",
text: result.text,
charts: collectedCharts,
tools: toolsUsed,
actionIds: collectedActionIds,
...(pdfMeta ? { parsed: { pdf: pdfMeta } } : {}),
});
}
send("done", {
text: result.text,
iterations: result.iterations,
stoppedOnLimit: result.stoppedOnLimit,
...(messageId ? { messageId } : {}),
});
} catch (err) {
// Surface the reason. A silent close would leave the UI showing a
// half-finished answer with no indication it failed.
const message = err instanceof Error ? err.message : String(err);
if (threadId && ownerId) {
try {
// Persist what happened so far — a crash must not silently
// drop the partial answer. "error" in tools marks the turn
// as having failed mid-stream.
await appendMessage(threadId, ownerId, {
kind: "assistant",
text: lastText,
charts: collectedCharts,
tools: [...toolsUsed, "error"],
});
} catch {
// Best-effort — the error event below still reaches the client.
}
}
send("error", { message });
} 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",
},
});
}
export async function POST(request: Request) {
if (!chatEnabled()) {
return new Response("Not found", { status: 404 });
}
let body: ChatRequestBody;
try {
body = (await request.json()) as ChatRequestBody;
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Legacy path: an open tab from before threads shipped sends { history }
// with no threadId. Keep it working for one release; it is never
// persisted, matching its pre-threads behaviour exactly.
if (body.history !== undefined && body.threadId === undefined) {
const history = normalizeHistory(body.history);
if (history.length === 0) {
return Response.json({ error: "history is empty" }, { status: 400 });
}
return streamAnswer({ history });
}
const actor = await resolveActor(request);
if (actor instanceof Response) return actor;
const message = typeof body.message === "string" ? body.message : "";
if (!message.trim()) {
return Response.json({ error: "message is empty" }, { status: 400 });
}
let threadId = body.threadId;
let threadTitle: string | undefined;
if (threadId) {
const owner = await getThreadOwner(threadId);
if (owner === null || owner !== actor.id) {
return Response.json({ error: "Thread not found" }, { status: 404 });
}
} else {
const created = await createThread(actor.id, autoTitle(message));
threadId = created.id;
threadTitle = created.title;
}
const priorEntries = await loadThreadEntries(threadId);
// "The next user turn gets a system note": ChatEntry has no system
// role, so an outcome for the last turn's action(s) — confirmed,
// rejected, undone or failed — is prepended to what the model reads
// as this user turn, once, since the next assistant reply will carry
// its own (possibly empty) action_ids and this lookup moves forward.
let systemNote = "";
try {
const recentActionIds = await getRecentActionIds(threadId);
const notes = (
await Promise.all(recentActionIds.map((id) => describeActionOutcome(id)))
).filter((n): n is string => !!n);
if (notes.length > 0) systemNote = `${notes.join("\n")}\n\n`;
} catch {
// ask_log may not exist yet — proceed without the note.
}
const images = Array.isArray(body.images) ? body.images : undefined;
const userEntry: ChatEntry = {
role: "user",
text: systemNote + message,
...(images && images.length > 0 ? { images } : {}),
};
const history = [...priorEntries, userEntry];
// Created before the model is ever called — a crash mid-turn must never
// lose what was actually asked.
await appendMessage(threadId, actor.id, {
kind: "user",
text: message,
...(body.display ? { display: body.display } : {}),
...(body.attachmentIds ? { attachmentIds: body.attachmentIds } : {}),
});
const ctx: AgentContext = { actor, threadId };
// Memories and action tools both need the actor — the legacy path above
// has none, so it gets neither, matching its pre-threads behaviour.
let memoriesBlock = "";
try {
memoriesBlock = formatMemoriesForPrompt(await listMemories(ctx));
} catch {
// ask_log may not exist yet (schema not applied) — answer without
// memories rather than failing the whole turn.
}
return streamAnswer({
history,
threadId,
ownerId: actor.id,
threadTitle,
system: buildSystemPrompt(new Date(), memoriesBlock),
extraTools: actionTools(ctx),
});
}