diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts new file mode 100644 index 0000000..18902aa --- /dev/null +++ b/app/api/analyze/route.ts @@ -0,0 +1,95 @@ +import { requireUser } from "@/lib/auth"; +import { analystConfig, jevKey, jevModel } from "@/lib/settings"; +import { digestPlan, digestForModel, PlanError } from "@/lib/digest"; +import { proposeCandidates, streamReport, AnalystError } from "@/lib/analyst"; +import { judgeCandidates, jevFallback, makeJevClient } from "@/lib/jev"; +import { sse } from "@/lib/stream"; +import { newAnalysis, patchAnalysis } from "@/lib/db"; +import { reportPrompt } from "@/lib/report-spec"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +const MAX_BODY_BYTES = 16_000_000; + +export async function POST(req: Request) { + const user = await requireUser().catch(() => null); + if (!user) return Response.json({ error: "Sign in first." }, { status: 401 }); + + const analyst = analystConfig(user.id); + const jev = jevKey(user.id); + if (!analyst) return Response.json({ error: "Set the analyst model in Settings first." }, { status: 412 }); + if (!jev) return Response.json({ error: "Set your Jev (TypeSafe) API key in Settings first." }, { status: 412 }); + + const raw = await req.text(); + if (raw.length > MAX_BODY_BYTES) return Response.json({ error: "Request too large." }, { status: 413 }); + let xml = ""; let note = ""; + try { const body = JSON.parse(raw); xml = String(body.xml ?? ""); note = String(body.note ?? "").slice(0, 500); } + catch { return Response.json({ error: "Expected JSON with an xml field." }, { status: 400 }); } + + const id = newAnalysis(user.id, note.trim().slice(0, 120) || "Plan analysis", xml); + const enc = new TextEncoder(); + const prompt = reportPrompt(); + + // The analyst model needs minutes per stage. If the DBA closes the tab, enqueue() does not throw, so the + // producer would keep firing model calls into a dead stream and queue behind its own ghost. cancel() is the + // only signal that the reader is gone: stop launching further calls and abort the one in flight. + const ac = new AbortController(); + let abandoned = false; + + const stream = new ReadableStream({ async start(ctrl) { + const send = (event: string, data: unknown) => ctrl.enqueue(enc.encode(sse(event, data))); + let oul = ""; + const close = (status: string, error?: string) => { + patchAnalysis(id, { status, ...(error ? { error } : {}), ...(oul ? { oul } : {}) }); + send("done", { id }); ctrl.close(); + }; + try { + send("stage", { stage: "digesting" }); + let digest; + try { digest = digestPlan(xml); } + catch (e) { const m = e instanceof PlanError ? e.message : "Could not read that plan."; + send("error", { stage: "digesting", message: m }); patchAnalysis(id, { status: "failed", error: m }); + send("done", { id }); return ctrl.close(); } + if (abandoned) return giveUp(id, oul); + patchAnalysis(id, { digest: JSON.stringify(digest) }); + + send("stage", { stage: "proposing" }); + let candidates; + try { candidates = await proposeCandidates(analyst, digest, note, fetch, ac.signal); } + catch (e) { const m = e instanceof AnalystError ? e.message : "The analyst model call failed."; + send("error", { stage: "proposing", message: m }); patchAnalysis(id, { status: "failed", error: m }); + send("done", { id }); return ctrl.close(); } + if (abandoned) return giveUp(id, oul); + candidates.forEach((c) => send("candidate", { key: c.key, title: c.title })); + patchAnalysis(id, { candidates: JSON.stringify(candidates) }); + + send("stage", { stage: "judging" }); + let verdict; + try { verdict = await judgeCandidates(digest, candidates, makeJevClient(jev, jevModel(user.id))); } + catch (e) { verdict = jevFallback(candidates, `Jev call failed: ${(e as Error).message}`); } + send("verdict", verdict); + patchAnalysis(id, { verdict: JSON.stringify(verdict) }); + + send("stage", { stage: "writing" }); + for await (const chunk of streamReport(analyst, + { plan: digestForModel(digest), options: candidates, verdict }, prompt, fetch, ac.signal)) { + oul += chunk; send("oul", { chunk }); + } + if (abandoned) return giveUp(id, oul); + close(oul ? "done" : "partial", oul ? undefined : "The analyst model returned an empty report."); + } catch (e) { + const m = abandoned ? "Browser stopped listening." : ((e as Error)?.message ?? "Analysis failed."); + if (!abandoned) send("error", { stage: "stream", message: m }); + patchAnalysis(id, { status: "failed", error: m, ...(oul ? { oul } : {}) }); + send("done", { id }); ctrl.close(); + } + function giveUp(id: string, oul: string) { + patchAnalysis(id, { status: oul ? "partial" : "abandoned", + error: "Closed before the run finished.", ...(oul ? { oul } : {}) }); + try { ctrl.close(); } catch { /* already closed */ } + } + }, cancel() { abandoned = true; ac.abort(); } }); + + return new Response(stream, { headers: { "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" } }); +} diff --git a/lib/analyst.ts b/lib/analyst.ts index 009935d..6fa5ef8 100644 --- a/lib/analyst.ts +++ b/lib/analyst.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { digestForModel, type Digest } from "./digest.ts"; +import { openAiTextDeltas } from "./stream.ts"; import type { AnalystConfig } from "./settings.ts"; export class AnalystError extends Error {} @@ -36,11 +37,11 @@ export function extractJson(text: string): unknown { } export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string, - fetchImpl: typeof fetch = fetch): Promise { + fetchImpl: typeof fetch = fetch, signal?: AbortSignal): Promise { const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, { - method: "POST", + method: "POST", signal, headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` }, - body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false, + body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 2048, ...cfg.extra, stream: false, messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT }, { role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }), }).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); }); @@ -56,3 +57,23 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note } return parsed.data.candidates; } + +export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt} + +You are writing the final answer for a DBA holding one SQL Server execution plan. The ranking you are given was +computed by Jev from typed judgments. It is not your opinion: do not re-rank it, soften it, or hide it. Quote each +option's scores as given. A rewrite must return the same rows as the original — when in doubt keep the original text +and say why. Never invent a number that is not in the input.`; + +export async function* streamReport(cfg: AnalystConfig, payload: unknown, openuiPrompt: string, + fetchImpl: typeof fetch = fetch, signal?: AbortSignal): AsyncGenerator { + const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, { + method: "POST", signal, + headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` }, + body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 2048, ...cfg.extra, stream: true, + messages: [{ role: "system", content: REPORT_SYSTEM_PROMPT(openuiPrompt) }, + { role: "user", content: JSON.stringify(payload) }] }), + }).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); }); + if (!res.ok || !res.body) throw new AnalystError(`Analyst model returned HTTP ${res.status} while writing the report.`); + yield* openAiTextDeltas(res); +} diff --git a/lib/report-spec.ts b/lib/report-spec.ts new file mode 100644 index 0000000..7bb26df --- /dev/null +++ b/lib/report-spec.ts @@ -0,0 +1,81 @@ +import { defineComponent, createLibrary } from "@openuidev/lang-core"; +import { z } from "zod/v4"; + +// The single source of truth for what the analyst model is allowed to emit. +// This module must never import React or @openuidev/react-lang: the API route runs it in a plain +// Node runtime to build the prompt, and React's server build throws on createContext there. +// lib/report-ui.ts attaches the React renderers to these same specs, so prompt and renderer cannot drift. + +const noop = () => null; + +export const Headline = defineComponent({ props: z.object({ title: z.string(), subtitle: z.string().optional(), + severity: z.enum(["critical", "warning", "info"]).optional() }), + name: "Headline", description: "Report title: the one-line finding, with severity.", component: noop }); + +export const Metric = defineComponent({ props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }), + name: "Metric", description: "One plan metric.", component: noop }); + +export const MetricGrid = defineComponent({ props: z.object({ items: z.array(Metric.ref) }), + name: "MetricGrid", description: "Row of plan metrics.", component: noop }); + +export const Finding = defineComponent({ props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }), + name: "Finding", description: "One problem, the plan evidence for it, its impact.", component: noop }); + +export const Findings = defineComponent({ props: z.object({ items: z.array(Finding.ref) }), + name: "Findings", description: "Everything wrong with the plan.", component: noop }); + +export const Step = defineComponent({ props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(), + owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }), + name: "Step", description: "One action step.", component: noop }); + +export const StepList = defineComponent({ props: z.object({ title: z.string(), steps: z.array(Step.ref) }), + name: "StepList", description: "Ordered action plan for one option.", component: noop }); + +export const SqlBlock = defineComponent({ props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }), + name: "SqlBlock", description: "A SQL snippet the user can copy.", component: noop }); + +export const ScoreBar = defineComponent({ props: z.object({ label: z.string(), value: z.number(), + confidence: z.number().optional(), note: z.string().optional() }), + name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.", component: noop }); + +export const OptionRow = defineComponent({ props: z.object({ rank: z.number(), title: z.string(), composite: z.number(), + headline: z.boolean(), flags: z.string().optional() }), + name: "OptionRow", description: "One ranked option.", component: noop }); + +export const OptionTable = defineComponent({ props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }), + name: "OptionTable", description: "All options ranked by composite score.", component: noop }); + +export const VerdictBlock = defineComponent({ props: z.object({ headline: z.string(), why: z.string(), + source: z.enum(["jev", "composite"]), confidence: z.number() }), + name: "VerdictBlock", description: "What Jev decided, and how sure it was.", component: noop }); + +export const Callout = defineComponent({ props: z.object({ level: z.enum(["info", "warn", "danger", "success"]), + title: z.string(), text: z.string() }), + name: "Callout", description: "A warning or note the user must not miss.", component: noop }); + +export const Report = defineComponent({ props: z.object({ blocks: z.array(z.union([Headline.ref, VerdictBlock.ref, + MetricGrid.ref, Findings.ref, OptionTable.ref, StepList.ref, SqlBlock.ref, ScoreBar.ref, Callout.ref])) }), + name: "Report", description: "Root. A query-plan report in reading order.", component: noop }); + +export const specs = [Headline, Metric, MetricGrid, Finding, Findings, Step, StepList, SqlBlock, ScoreBar, + OptionRow, OptionTable, VerdictBlock, Callout, Report]; + +/** Prompt-side library: same specs, no renderers. Safe in a non-React Node runtime. */ +export const promptLibrary = createLibrary({ root: "Report", components: specs }); + +export const reportPromptOptions = { + preamble: "You output only OpenUI Lang. No prose, no markdown fences, no commentary before or after.", + additionalRules: [ + "root = Report([...]) and it must list every block you define.", + "Block order: Headline, VerdictBlock, MetricGrid, Findings, OptionTable, then per option its StepList and SqlBlock, then ScoreBars, then Callout.", + "Use ONLY numbers present in the input. Never invent a cost, row count, impact, or confidence.", + "One SqlBlock per rewrite, titled with that option's key. T-SQL only, no markdown.", + "Every option flagged verify_semantics gets a Callout level danger: compare result rows before deploying.", + "If verdict.source is composite, say plainly that Jev did not decide.", + ], + examples: [`root = Report([head, verdict]) +head = Headline("Clustered Index Scan on Sales.Orders is 84% of the plan", "No usable key on CustomerId", "critical") +verdict = VerdictBlock("Create the covering index", "bottleneck_fit 1.00, semantic_safety 1.00, ease 0.50.", "jev", 0.82)`], +}; + +export const reportPrompt = () => promptLibrary.prompt(reportPromptOptions); diff --git a/lib/report-ui.ts b/lib/report-ui.ts index 585cc92..75523a3 100644 --- a/lib/report-ui.ts +++ b/lib/report-ui.ts @@ -1,127 +1,85 @@ -import { createElement as el, type ReactNode } from "react"; +import { createElement as el } from "react"; import { defineComponent, createLibrary } from "@openuidev/react-lang"; -import { z } from "zod/v4"; +import { specs } from "./report-spec.ts"; -// No JSX in this file on purpose: `node --test` imports it, and Node strips types but not JSX. -// el(type, props, ...children) is createElement with the noise removed. -const p = (style: Record, ...kids: ReactNode[]) => el("p", { style }, ...kids); -const muted = { color: "var(--qai-ink-muted)" } as const; -const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" } as const; -const disp = { fontFamily: "var(--qai-font-display)" } as const; +// Props, descriptions and the prompt come from lib/report-spec.ts (React-free, usable in the API route). +// This file only says what each component looks like. Names and schemas cannot drift: they are imported. +// No JSX here either — `node --test` imports modules and Node does not strip JSX. +const p = (style: Record, ...kids: any[]) => el("p", { style }, ...kids); +const muted = { color: "var(--qai-ink-muted)" }; +const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }; +const disp = { fontFamily: "var(--qai-font-display)" }; -const Headline = defineComponent({ name: "Headline", - description: "Report title: the one-line finding, with severity.", - props: z.object({ title: z.string(), subtitle: z.string().optional(), - severity: z.enum(["critical", "warning", "info"]).optional() }), - component: ({ props }) => el("header", null, +const renderers: Record any> = { + Headline: ({ props }) => el("header", null, el("p", { className: "label" }, props.severity === "critical" ? "Critical" : props.severity === "warning" ? "Warning" : "Note"), el("h2", { style: { ...disp, fontSize: "var(--qai-text-2xl)", margin: "var(--qai-space-s) 0" } }, props.title), - props.subtitle ? p(muted, props.subtitle) : null) }); + props.subtitle ? p(muted, props.subtitle) : null), -const Metric = defineComponent({ name: "Metric", description: "One plan metric.", - props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }), - component: ({ props }) => el("div", { className: "card" }, + Metric: ({ props }) => el("div", { className: "card" }, el("p", { className: "label" }, props.label), p({ ...disp, fontSize: "var(--qai-text-xl)" }, props.value), - props.note ? p(small, props.note) : null) }); + props.note ? p(small, props.note) : null), -const MetricGrid = defineComponent({ name: "MetricGrid", description: "Row of plan metrics.", - props: z.object({ items: z.array(Metric.ref) }), - component: ({ props, renderNode }) => el("div", { className: "grid-metrics" }, renderNode(props.items)) }); + MetricGrid: ({ props, renderNode }: any) => el("div", { className: "grid-metrics" }, renderNode(props.items)), -const Finding = defineComponent({ name: "Finding", description: "One problem, the plan evidence for it, its impact.", - props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }), - component: ({ props }) => el("div", { className: "card" }, + Finding: ({ props }) => el("div", { className: "card" }, el("p", { className: "label" }, props.where), p({ margin: "var(--qai-space-s) 0" }, el("strong", null, props.issue)), el("p", { className: "sql" }, props.evidence), - p(muted, props.impact)) }); + p(muted, props.impact)), -const Findings = defineComponent({ name: "Findings", description: "Everything wrong with the plan.", - props: z.object({ items: z.array(Finding.ref) }), - component: ({ props, renderNode }) => el("section", { className: "report" }, renderNode(props.items)) }); + Findings: ({ props, renderNode }: any) => el("section", { className: "report" }, renderNode(props.items)), -const Step = defineComponent({ name: "Step", description: "One action step.", - props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(), - owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }), - component: ({ props }) => el("li", { className: "card", style: { display: "flex", gap: "var(--qai-space-md)", + Step: ({ props }) => el("li", { className: "card", style: { display: "flex", gap: "var(--qai-space-md)", alignItems: "center", flexWrap: "wrap" } }, el("span", { style: { flex: "1 1 200px", minWidth: 0 } }, props.text), props.effort ? el("span", { className: "pill" }, `effort ${props.effort}`) : null, props.owner ? el("span", { className: "pill" }, props.owner) : null, - props.changeControl ? el("span", { className: "pill" }, "change control") : null) }); + props.changeControl ? el("span", { className: "pill" }, "change control") : null), -const StepList = defineComponent({ name: "StepList", description: "Ordered action plan for one option.", - props: z.object({ title: z.string(), steps: z.array(Step.ref) }), - component: ({ props, renderNode }) => el("section", null, + StepList: ({ props, renderNode }: any) => el("section", null, el("h3", { style: disp }, props.title), el("ol", { style: { listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" } }, - renderNode(props.steps))) }); + renderNode(props.steps))), -const SqlBlock = defineComponent({ name: "SqlBlock", description: "A SQL snippet the user can copy.", - props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }), - component: ({ props }) => el("figure", { className: "card", style: { margin: 0 } }, + SqlBlock: ({ props }) => el("figure", { className: "card", style: { margin: 0 } }, el("figcaption", { className: "label" }, props.title), el("pre", { className: "sql" }, props.sql), el("button", { className: "btn no-print", onClick: () => navigator.clipboard.writeText(props.sql) }, "Copy SQL"), - props.caption ? el("p", { style: small }, props.caption) : null) }); + props.caption ? el("p", { style: small }, props.caption) : null), -const ScoreBar = defineComponent({ name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.", - props: z.object({ label: z.string(), value: z.number(), confidence: z.number().optional(), note: z.string().optional() }), - component: ({ props }) => el("div", null, + ScoreBar: ({ props }) => el("div", null, el("p", { className: "label" }, props.label + (props.confidence !== undefined ? ` · confidence ${props.confidence.toFixed(2)}` : " · no confidence reported")), el("div", { className: "bar", style: { width: `${Math.round(props.value * 100)}%` } }), - props.note ? el("p", { style: small }, props.note) : null) }); + props.note ? el("p", { style: small }, props.note) : null), -const OptionRow = defineComponent({ name: "OptionRow", description: "One ranked option.", - props: z.object({ rank: z.number(), title: z.string(), composite: z.number(), headline: z.boolean(), - flags: z.string().optional() }), - component: ({ props }) => el("tr", { "data-headline": props.headline || undefined }, + OptionRow: ({ props }) => el("tr", { "data-headline": props.headline || undefined }, el("td", null, String(props.rank)), el("td", null, props.title), el("td", { className: "sql" }, props.composite.toFixed(2)), - el("td", null, props.flags || "—")) }); + el("td", null, props.flags || "—")), -const OptionTable = defineComponent({ name: "OptionTable", description: "All options ranked by composite score.", - props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }), - component: ({ props, renderNode }) => el("section", { className: "card" }, + OptionTable: ({ props, renderNode }: any) => el("section", { className: "card" }, el("h3", { style: disp }, props.title), el("p", { style: small }, props.weights), - el("table", { style: { width: "100%", borderCollapse: "collapse" } }, el("tbody", null, renderNode(props.rows)))) }); + el("table", { style: { width: "100%", borderCollapse: "collapse" } }, el("tbody", null, renderNode(props.rows)))), -const VerdictBlock = defineComponent({ name: "VerdictBlock", description: "What Jev decided, and how sure it was.", - props: z.object({ headline: z.string(), why: z.string(), source: z.enum(["jev", "composite"]), confidence: z.number() }), - component: ({ props }) => el("section", { className: "card", "data-verdict": "" }, + VerdictBlock: ({ props }) => el("section", { className: "card", "data-verdict": "" }, el("p", { className: "label" }, "First move · " + (props.source === "jev" ? `Jev, confidence ${props.confidence.toFixed(2)}` : "composite order — Jev did not decide")), p({ ...disp, fontSize: "var(--qai-text-lg)" }, props.headline), - p({}, props.why)) }); + p({}, props.why)), -const Callout = defineComponent({ name: "Callout", description: "A warning or note the user must not miss.", - props: z.object({ level: z.enum(["info", "warn", "danger", "success"]), title: z.string(), text: z.string() }), - component: ({ props }) => el("div", { className: "card", "data-level": props.level, + Callout: ({ props }) => el("div", { className: "card", "data-level": props.level, role: props.level === "danger" ? "alert" : "note" }, - el("p", null, el("strong", null, props.title)), el("p", null, props.text)) }); + el("p", null, el("strong", null, props.title)), el("p", null, props.text)), -const Report = defineComponent({ name: "Report", description: "Root. A query-plan report in reading order.", - props: z.object({ blocks: z.array(z.union([Headline.ref, VerdictBlock.ref, MetricGrid.ref, Findings.ref, - OptionTable.ref, StepList.ref, SqlBlock.ref, ScoreBar.ref, Callout.ref])) }), - component: ({ props, renderNode }) => el("div", { className: "report", id: "report" }, renderNode(props.blocks)) }); - -export const library = createLibrary({ root: "Report", components: [Headline, Metric, MetricGrid, Finding, Findings, - Step, StepList, SqlBlock, ScoreBar, OptionRow, OptionTable, VerdictBlock, Callout, Report] }); - -export const reportPromptOptions = { - preamble: "You output only OpenUI Lang. No prose, no markdown fences, no commentary before or after.", - additionalRules: [ - "root = Report([...]) and it must list every block you define.", - "Block order: Headline, VerdictBlock, MetricGrid, Findings, OptionTable, then per option its StepList and SqlBlock, then ScoreBars, then Callout.", - "Use ONLY numbers present in the input. Never invent a cost, row count, impact, or confidence.", - "One SqlBlock per rewrite, titled with that option's key. T-SQL only, no markdown.", - "Every option flagged verify_semantics gets a Callout level danger: compare result rows before deploying.", - "If verdict.source is composite, say plainly that Jev did not decide.", - ], - examples: [`root = Report([head, verdict]) -head = Headline("Clustered Index Scan on Sales.Orders is 84% of the plan", "No usable key on CustomerId", "critical") -verdict = VerdictBlock("Create the covering index", "bottleneck_fit 1.00, semantic_safety 1.00, ease 0.50.", "jev", 0.82)`], + Report: ({ props, renderNode }: any) => el("div", { className: "report", id: "report" }, renderNode(props.blocks)), }; + +export const library = createLibrary({ + root: "Report", + components: specs.map((s) => defineComponent({ name: s.name, props: s.props, description: s.description, + component: renderers[s.name] })), +}); diff --git a/lib/stream.ts b/lib/stream.ts new file mode 100644 index 0000000..40b9037 --- /dev/null +++ b/lib/stream.ts @@ -0,0 +1,36 @@ +export const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + +export async function* readSse(body: ReadableStream): AsyncGenerator<{ event: string; data: any }> { + const reader = body.getReader(); const dec = new TextDecoder(); let buf = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let at: number; + while ((at = buf.indexOf("\n\n")) !== -1) { + const frame = buf.slice(0, at); buf = buf.slice(at + 2); + const event = /^event: (.+)$/m.exec(frame)?.[1] ?? "message"; + const raw = /^data: (.*)$/m.exec(frame)?.[1] ?? ""; + try { yield { event, data: JSON.parse(raw) }; } catch { /* keep-alive or unparsable frame */ } + } + } +} + +export async function* openAiTextDeltas(res: Response): AsyncGenerator { + const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let at: number; + while ((at = buf.indexOf("\n\n")) !== -1) { + const line = (buf.slice(0, at).split("\n").find((l) => l.startsWith("data:")) ?? "").slice(5).trim(); + buf = buf.slice(at + 2); + if (!line) continue; + if (line === "[DONE]") return; + let delta: unknown; + try { delta = JSON.parse(line)?.choices?.[0]?.delta?.content; } catch { continue; } + if (typeof delta === "string" && delta) yield delta; + } + } +} diff --git a/package-lock.json b/package-lock.json index 8a06514..665189d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sql-server-qai", "version": "0.1.0", "dependencies": { + "@openuidev/lang-core": "^0.3.0", "@openuidev/react-lang": "^0.3.0", "@typesafe-ai/sdk": "^0.6.0", "fast-xml-parser": "^5.7.3", diff --git a/package.json b/package.json index 9fb0486..f47ddcb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "node --no-warnings --test tests/*.test.ts" }, "dependencies": { + "@openuidev/lang-core": "^0.3.0", "@openuidev/react-lang": "^0.3.0", "@typesafe-ai/sdk": "^0.6.0", "fast-xml-parser": "^5.7.3", diff --git a/tests/report-ui.test.ts b/tests/report-ui.test.ts index bc092de..a8c3c30 100644 --- a/tests/report-ui.test.ts +++ b/tests/report-ui.test.ts @@ -1,11 +1,15 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { library, reportPromptOptions } = await import("../lib/report-ui.ts"); +const { promptLibrary, reportPromptOptions, specs } = await import("../lib/report-spec.ts"); test("the prompt advertises every component and the root", () => { - const p = library.prompt(reportPromptOptions); + const p = promptLibrary.prompt(reportPromptOptions); for (const n of ["Report", "Headline", "VerdictBlock", "MetricGrid", "Findings", "OptionTable", "StepList", "SqlBlock", "ScoreBar", "Callout"]) assert.ok(p.includes(n), `missing ${n}`); assert.match(p, /root/i); assert.match(p, /never invent/i); }); +test("every spec has a renderer, so the model can never emit an unrenderable block", async () => { + const { library } = await import("../lib/report-ui.ts"); + for (const s of specs) assert.ok(library.components[s.name], `no renderer for ${s.name}`); +}); diff --git a/tests/stream.test.ts b/tests/stream.test.ts new file mode 100644 index 0000000..b75e106 --- /dev/null +++ b/tests/stream.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +const { sse, readSse, openAiTextDeltas } = await import("../lib/stream.ts"); + +const streamOf = (parts: string[]) => new ReadableStream({ start(c) { + parts.forEach((p) => c.enqueue(new TextEncoder().encode(p))); c.close(); } }); + +test("sse frames one event", () => + assert.equal(sse("stage", { stage: "digesting" }), 'event: stage\ndata: {"stage":"digesting"}\n\n')); + +test("readSse rejoins frames split anywhere", async () => { + const raw = sse("stage", { stage: "digesting" }) + sse("oul", { chunk: "root = Report([])" }); + const events: any[] = []; + for await (const e of readSse(streamOf([raw.slice(0, 12), raw.slice(12, 40), raw.slice(40)]))) events.push(e); + assert.deepEqual(events, [{ event: "stage", data: { stage: "digesting" } }, + { event: "oul", data: { chunk: "root = Report([])" } }]); +}); +test("readSse ignores frames it cannot parse", async () => { + const events: any[] = []; + for await (const e of readSse(streamOf([": ping\n\n", sse("done", { id: "1" })]))) events.push(e); + assert.deepEqual(events, [{ event: "done", data: { id: "1" } }]); +}); +test("openAiTextDeltas yields content and stops at [DONE]", async () => { + const res = new Response(streamOf(['data: {"choices":[{"delta":{"content":"ab"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"cd"}}]}\n\n', "data: [DONE]\n\n"]), { status: 200 }); + const out: string[] = []; + for await (const d of openAiTextDeltas(res)) out.push(d); + assert.equal(out.join(""), "abcd"); +}); +test("openAiTextDeltas survives a delta split across chunks", async () => { + const res = new Response(streamOf(['data: {"choices":[{"delta":{"con', 'tent":"xy"}}]}\n\ndata: [DONE]\n\n']), { status: 200 }); + const out: string[] = []; + for await (const d of openAiTextDeltas(res)) out.push(d); + assert.equal(out.join(""), "xy"); +});