"use client"; /** * TASK-0124 — Database AI client (generate / extract / formula). * * Mirrors `lib/ai.ts` (Ask AI + writing): a small JSON REST surface * (`dbAiApi`, via `apiFetch`) plus two SSE streamers (`sendDbGenerate`, * `sendDbExtract`) that do a raw fetch because the response is * `text/event-stream`. The SSE frame contract is the documented * `data: {…}\n\n` frames (delta / tool_call / usage / error / done); * pre-stream failures (403/402/429/413/400) arrive as a JSON body before any * SSE byte → `AiPreStreamError`. The `done` frame carries the result * (`databaseId` / `recordIds` / `ignoredColumns`). * * The client NEVER assembles the model's tool call — it only sends the user's * prompt / pasted text / description + IDs (spec: the client never names * properties by position; the server assembles the model context). */ import { ApiError } from "./api"; import { apiFetch } from "./api"; import { AiPreStreamError, parseSseFrames } from "./ai"; /* --------------------------------------------------------------- types */ /** The SSE wire contract for the two streaming database-AI endpoints. */ export type DbAiSseFrame = | { type: "delta"; text: string } | { type: "tool_call"; id: string; name: string; arguments: Record } | { type: "usage"; tokensIn: number; tokensOut: number } | { type: "error"; code: string; message: string; reason?: string; retryable?: boolean } | { type: "done"; assistantMessageId?: string; reason?: "ok" | "aborted" | "error"; databaseId?: string; propertyIds?: string[]; recordIds?: string[]; ignoredColumns?: string[]; }; export interface DbAiResult { reason: "ok" | "aborted" | "error"; /** The terminal `done` frame (carries databaseId / recordIds / …). */ done?: DbAiSseFrame; usage?: { tokensIn: number; tokensOut: number }; /** The last in-band `error` frame (invalid structure / provider outage). */ errorFrame?: { code: string; message: string; reason?: string }; } /* ------------------------------------------------------- shared SSE reader */ async function streamDb( path: string, body: Record, signal: AbortSignal | undefined ): Promise { const { authStore } = await import("./auth-store"); const { API_BASE } = await import("./constants"); const token = authStore.accessToken; const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body), signal, credentials: "include" }); if (!res.ok || !res.body) { let err: ApiError; try { const json = (await res.json()) as { error?: { code: string; message: string; details?: unknown } }; err = new ApiError(json.error?.code ?? "UNKNOWN", res.status, json.error?.message ?? "Request failed", json.error?.details); } catch { err = new ApiError("UNKNOWN", res.status, "Request failed"); } throw new AiPreStreamError(err); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let reason: "ok" | "aborted" | "error" = "error"; let done: DbAiSseFrame | undefined; let usage: { tokensIn: number; tokensOut: number } | undefined; let errorFrame: DbAiResult["errorFrame"]; const handle = (frame: DbAiSseFrame): void => { if (frame.type === "usage") usage = { tokensIn: frame.tokensIn, tokensOut: frame.tokensOut }; else if (frame.type === "done") { done = frame; reason = frame.reason === "aborted" ? "aborted" : frame.reason === "error" ? "error" : "ok"; } else if (frame.type === "error") { reason = "error"; errorFrame = { code: frame.code, message: frame.message, ...(frame.reason ? { reason: frame.reason } : {}) }; } }; try { for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); // The server's frames are the documented shape (a superset of the // generic `AiSseFrame` — the database `done` carries databaseId/…); // the parse is identical, so we parse then re-cast to the wider type. const { frames, rest } = parseSseFrames(buf) as unknown as { frames: DbAiSseFrame[]; rest: string }; buf = rest; for (const f of frames) handle(f); } const { frames } = parseSseFrames(buf + "\n\n") as unknown as { frames: DbAiSseFrame[]; rest: string }; for (const f of frames) handle(f); } catch (err) { if (err instanceof Error && err.name === "AbortError") reason = "aborted"; else throw err; } finally { try { reader.releaseLock(); } catch { /* already released on abort */ } } return { reason, ...(done ? { done } : {}), ...(usage ? { usage } : {}), ...(errorFrame ? { errorFrame } : {}) }; } /* --------------------------------------------------------------- generate */ export async function sendDbGenerate(params: { workspaceId: string; prompt: string; parentId?: string; signal?: AbortSignal; }): Promise { const body: Record = { prompt: params.prompt }; if (params.parentId) body.parentId = params.parentId; return streamDb(`/workspaces/${params.workspaceId}/ai/databases/generate`, body, params.signal); } /* --------------------------------------------------------------- extract */ export async function sendDbExtract(params: { workspaceId: string; databaseId: string; text: string; signal?: AbortSignal; }): Promise { return streamDb( `/workspaces/${params.workspaceId}/ai/databases/extract`, { databaseId: params.databaseId, text: params.text }, params.signal ); } /* ------------------------------------------------------- REST (preview / formula) */ /** The column-mapping dry-run (no write, no LLM). */ export interface DbExtractPreview { databaseId: string; isTable: boolean; columns: string[]; mapped: Array<{ column: string; propertyId: string }>; ignored: string[]; rowCount: number; } /** A sample-row evaluation from the formula endpoint. */ export interface DbFormulaSample { recordId: string; value: unknown; error?: string; } /** The formula endpoint's JSON result. */ export interface DbFormulaResult { formulaId: string; propertyId: string; expression: string; returnType: string; explanation?: string; samples: DbFormulaSample[]; tokensIn: number; tokensOut: number; } export const dbAiApi = { /** Live column-mapping preview for the extract dialog (dry-run, no write). */ previewExtract( workspaceId: string, body: { databaseId: string; text: string }, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/databases/extract/preview`, { method: "POST", body, signal }); }, /** Generate a formula from a description (JSON, non-streamed). The engine * validates the expression BEFORE saving; 422 = `formula_invalid`. */ generateFormula( workspaceId: string, body: { databaseId: string; description: string; name?: string }, signal?: AbortSignal ): Promise { return apiFetch(`/workspaces/${workspaceId}/ai/databases/formulas`, { method: "POST", body, signal }); } }; /* ------------------------------------------------------------- display helper */ /** Render a stored per-type record value for the mapping/preview tables. */ export function displayStoredValue( value: unknown, options?: Array<{ id: string; name: string }> ): string { if (value == null) return "—"; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); const v = value as Record; if (typeof v.text === "string") return v.text; if (v.value !== undefined) return String(v.value); if (v.optionId !== undefined) { // Tolerant: a legacy stored option NAME matches by name, an option id by id. const ref = v.optionId; const opt = options?.find((o) => o.id === ref) ?? options?.find((o) => o.name === ref); return opt?.name ?? String(ref); } if (v.start !== undefined) return String(v.start); return JSON.stringify(value); }