// Newline-delimited JSON protocol shared by the AI project-mapping stream (both // the local on-device route and the hub cloud route emit it; the desktop client // consumes it). One JSON object per line: // // {"type":"meta","prompt":""} // {"type":"reasoning","text":""} // {"type":"mapping","id":"","group":""} // {"type":"done"} // {"type":"error","message":""} // // Keeping this pure (no `ai` import) lets the React client import it without // pulling the server SDK into the browser bundle. export type AiMapStreamEvent = | { type: "meta"; prompt: string } | { type: "reasoning"; text: string } | { type: "mapping"; id: string; group: string } | { type: "done" } | { type: "error"; message: string }; export function serializeAiMapEvent(event: AiMapStreamEvent): string { return `${JSON.stringify(event)}\n`; } // Validate a parsed line into a typed event, or null if it isn't a well-formed // event (unknown type, missing/blank fields). The client skips nulls. export function parseAiMapEvent(value: unknown): AiMapStreamEvent | null { if (!value || typeof value !== "object") return null; const record = value as Record; switch (record.type) { case "meta": return typeof record.prompt === "string" ? { type: "meta", prompt: record.prompt } : null; case "reasoning": return typeof record.text === "string" && record.text.length > 0 ? { type: "reasoning", text: record.text } : null; case "mapping": return typeof record.id === "string" && record.id.length > 0 && typeof record.group === "string" && record.group.trim().length > 0 ? { type: "mapping", id: record.id, group: record.group.trim() } : null; case "done": return { type: "done" }; case "error": return { type: "error", message: typeof record.message === "string" && record.message ? record.message : "AI mapping failed", }; default: return null; } } // Stateful splitter: feed it raw stream chunks, get back the complete events in // each chunk. Holds a partial trailing line between calls; call flush() at the // end to emit any final line that arrived without a trailing newline. export function createAiMapEventBuffer(): { push: (chunk: string) => AiMapStreamEvent[]; flush: () => AiMapStreamEvent[]; } { let buffer = ""; function drain(final: boolean): AiMapStreamEvent[] { const events: AiMapStreamEvent[] = []; let newlineIndex = buffer.indexOf("\n"); while (newlineIndex !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line) { try { const event = parseAiMapEvent(JSON.parse(line)); if (event) events.push(event); } catch { // Skip a malformed line rather than aborting the whole stream. } } newlineIndex = buffer.indexOf("\n"); } if (final && buffer.trim()) { try { const event = parseAiMapEvent(JSON.parse(buffer.trim())); if (event) events.push(event); } catch { // Ignore a trailing partial line. } buffer = ""; } return events; } return { push: (chunk: string) => { buffer += chunk; return drain(false); }, flush: () => drain(true), }; }