From a1b5e9e7084cd56431be89e36357f68de9c605d3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 17 Oct 2026 13:12:08 +0000 Subject: [PATCH 1/9] Stream every AI answer live, token by token Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/SKILL.md | 4 +- .../verify-debatelab/features/README.md | 1 + .../verify-debatelab/features/ai-tools.md | 3 +- .../features/live-generation.md | 33 +++ .../verify-debatelab/recipes/fixtures.ts | 23 +- .../recipes/live-generation.spec.ts | 110 ++++++++ .../verify-debatelab/recipes/practice.spec.ts | 6 +- docs/architecture.md | 8 +- e2e/full-stack/fake-openrouter.mjs | 16 ++ e2e/full-stack/voice.spec.ts | 5 +- e2e/helpers/ai-response.ts | 22 ++ e2e/live/live.ts | 8 +- e2e/production/canary.spec.ts | 6 +- e2e/staging/authed-flows.spec.ts | 3 +- packages/core/src/ai/partial-json.ts | 161 ++++++++++++ .../features/sparring/SparringRound.tsx | 24 +- src/components/features/tools/live-draft.tsx | 98 +++++++ .../features/tools/live-sections.ts | 220 ++++++++++++++++ src/components/features/tools/tool-run.tsx | 50 +++- src/lib/ai/client.ts | 1 + src/lib/ai/features/ask.ts | 6 + src/lib/ai/features/attack.ts | 4 +- src/lib/ai/features/brainstorm.ts | 4 +- src/lib/ai/features/crossfire.ts | 3 +- src/lib/ai/features/factoids.ts | 4 +- src/lib/ai/features/generate.ts | 4 +- src/lib/ai/features/sparring.ts | 11 +- src/lib/ai/live.ts | 97 +++++++ src/lib/ai/partial-json.test.ts | 73 ++++++ src/lib/ai/request.ts | 22 +- src/lib/ai/sse.ts | 40 +++ src/lib/ai/streaming.ts | 42 +-- src/lib/supabase/client.ts | 24 +- src/lib/voice/live-text.ts | 10 + src/locales/en.json | 11 +- src/locales/es.json | 11 +- src/locales/hu.json | 11 +- src/pages/AskPage.tsx | 28 ++ src/pages/AttackPage.tsx | 8 +- src/pages/BrainstormPage.tsx | 8 +- src/pages/CrossfirePage.tsx | 56 +++- src/pages/FactoidsPage.tsx | 8 +- src/pages/GeneratePage.tsx | 8 +- src/pages/SparringPage.tsx | 28 +- src/test/ai/live.test.ts | 77 ++++++ supabase/README.md | 2 +- supabase/functions/_shared/research.ts | 2 +- supabase/functions/ai/features/ask.ts | 3 +- supabase/functions/ai/features/attack.ts | 3 +- supabase/functions/ai/features/brainstorm.ts | 3 +- supabase/functions/ai/features/crossfire.ts | 1 + supabase/functions/ai/features/factoids.ts | 3 +- supabase/functions/ai/features/generate.ts | 4 +- supabase/functions/ai/features/sparring.ts | 2 +- supabase/functions/ai/features/types.ts | 4 +- supabase/functions/ai/index.ts | 242 +++++++++--------- supabase/functions/ai/provider.ts | 157 +++++++++++- supabase/functions/ai/stream.test.ts | 221 ++++++++++++++++ supabase/functions/ai/stream.ts | 91 +++++++ 59 files changed, 1909 insertions(+), 229 deletions(-) create mode 100644 .claude/skills/verify-debatelab/features/live-generation.md create mode 100644 .claude/skills/verify-debatelab/recipes/live-generation.spec.ts create mode 100644 e2e/helpers/ai-response.ts create mode 100644 packages/core/src/ai/partial-json.ts create mode 100644 src/components/features/tools/live-draft.tsx create mode 100644 src/components/features/tools/live-sections.ts create mode 100644 src/lib/ai/live.ts create mode 100644 src/lib/ai/partial-json.test.ts create mode 100644 src/lib/ai/sse.ts create mode 100644 src/lib/voice/live-text.ts create mode 100644 src/test/ai/live.test.ts create mode 100644 supabase/functions/ai/stream.test.ts create mode 100644 supabase/functions/ai/stream.ts diff --git a/.claude/skills/verify-debatelab/SKILL.md b/.claude/skills/verify-debatelab/SKILL.md index d1408950e..53c58227b 100644 --- a/.claude/skills/verify-debatelab/SKILL.md +++ b/.claude/skills/verify-debatelab/SKILL.md @@ -82,7 +82,8 @@ It also prints the commit the stack started at and whether there are uncommitted - The voice helpers: `stubSpeech(page)` installs a scripted microphone, recogniser and browser voice; `say(page, words)` speaks; `spokenByBrowser(page)` reads back what the browser voice said. - `admin`: a service-role client for reading rows as a second view. - `fake` / `providerCall(feature)`: what the Edge Function actually sent upstream. Use `fake.fail(feature, status)` to force a provider error. -- `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function response. Assert that its fields reach the screen, never on model wording, so a recipe passes in both modes. `LIVE` and `AI_TIMEOUT` (30 s fake, 240 s live) let a recipe branch where a check only makes sense live, and a fake-mode branch adds an `unverified` annotation instead of passing silently. +- `aiResponse(page, feature, operation?)`: the parsed body of the next `ai` Edge Function response. Pages ask for live generation, so most `ai` responses are event streams; `aiResponse` and `readAIResponse(response)` read the final `result` event (or a plain JSON body) for you, so never call `response.json()` on an `ai` response. Assert that its fields reach the screen, never on model wording, so a recipe passes in both modes. `LIVE` and `AI_TIMEOUT` (30 s fake, 240 s live) let a recipe branch where a check only makes sense live, and a fake-mode branch adds an `unverified` annotation instead of passing silently. +- `expectStreamed([...])`: every provider call for those features asked the model to stream, which proves the page showed the answer live. - `expectProviderCalls([...])`: what reached the provider. Each test writes `provider-calls.json`, and in live mode the fixture fails the test if any chat call was answered by a model other than the pin. - `evidence`: `step(line)`, `aria(name, locator?)`, `shot(name)` and `json(name, value)`. - Automatic failure on any uncaught page error, horizontal overflow, or remote Supabase request. @@ -173,6 +174,7 @@ It never touches `.verify/artifacts/` or `.verify/logs/`, and it is safe to run - Live runs are slow: sparring and crossfire take several minutes each because the model must reason. Use `-g` to run one test while iterating. - Vite does not reload `tailwind.config.ts` on a running stack: new theme keys, variants and the `future` flags produce no CSS until `$H down && $H up`. Edits to `src/` and `src/index.css` apply on reload as usual. +- The fake provider streams a structured answer in 24-character chunks 10 ms apart when the request asks to stream, so a Generate draft is on screen for about a second in fake mode. Short replies (a Crossfire turn) finish in well under that, so prove those with `expectStreamed` rather than by catching the draft. - The fake provider rejects prompts it doesn't recognize. If a prompt change makes a feature fail with a provider error, update `e2e/full-stack/fake-openrouter.mjs`. Don't mock around it. - Once, the fake-provider container was SIGKILLed about 20 seconds after `up`, and the cause was never found. Doctor catches this, and `drive` refuses to run until you `down && up`. - Demo-code users are Pro with a daily limit of 999. To test quota or tier paths, change `user_settings` through `admin` inside the recipe, as `e2e/full-stack/case-generation.spec.ts` does. diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index b01e193e6..565c095c3 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -43,6 +43,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Case generation](./case-generation.md): generate, web research, save and reopen from History on a fresh device, PDF and DOCX export, single-section regeneration, provider failure and retry, quota block (`recipes/case-generation.spec.ts`). - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). - [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`). +- [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`). - [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`). - [Model defaults](./model-defaults.md): Pro accounts start on Claude Opus 5.5, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to Opus 5.5 (`recipes/model-defaults.spec.ts`). - [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/ai-tools.md b/.claude/skills/verify-debatelab/features/ai-tools.md index 252a9a568..28742eea5 100644 --- a/.claude/skills/verify-debatelab/features/ai-tools.md +++ b/.claude/skills/verify-debatelab/features/ai-tools.md @@ -27,7 +27,8 @@ Preconditions: - **Ask, streamed.** On `/app/ask`, fill the placeholder `Ask your debate coach...` and click the button named `Send message`. The text `FAKE-PROVIDER streamed answer: ...` appears. `fake.requests()` features contain `ask-stream` and not `ask`. `ai_requests` gets `{feature: "ask", status: "success"}`. - **Ask, cited.** Open the `Options` button above the message box, click `#citation-mode`, close it with Escape, then send. The text `FAKE-PROVIDER answer` appears, and `providerCall("ask")` fired. -- **Stop and errors.** While Ask answers, the send button becomes `Stop answering`; Stop keeps the partial reply and returns focus to the box, which stays editable throughout. A failed request leaves an `alert` above the box with `Retry`, which asks again without adding the question twice. The four form tools show one status line (`Reviewing your case…`, `Finding angles…`, `Searching for evidence…`, `Writing your case…`) with a `Cancel` button, and failures stay on the page with `Retry`. +- **Stop and errors.** While Ask answers, the send button becomes `Stop answering`; Stop keeps the partial reply and returns focus to the box, which stays editable throughout. A failed request leaves an `alert` above the box with `Retry`, which asks again without adding the question twice. The four form tools show one status line (`Reviewing your case…`, `Finding angles…`, `Searching for evidence…`, `Writing your case…`, or `Researching sources…` while web research runs) with a `Cancel` button until the model starts writing, then the live draft (see [live-generation](./live-generation.md)). Failures stay on the page with `Retry`. +- **Ask, cited and with web search.** These modes answer through the structured endpoint with live generation: the reply bubble fills with the answer's text as the model writes it, and citations, sources, and follow-ups appear when it finishes. - **Discuss.** A result's `Discuss` button opens Ask, which sends the summary as the user's message through the streaming path. ## Gotchas diff --git a/.claude/skills/verify-debatelab/features/live-generation.md b/.claude/skills/verify-debatelab/features/live-generation.md new file mode 100644 index 000000000..5bc39bcc2 --- /dev/null +++ b/.claude/skills/verify-debatelab/features/live-generation.md @@ -0,0 +1,33 @@ +# Live generation + +Every AI answer appears while the model writes it. Generate, Attack, Brainstorm, and Factoids show a live draft: the result's own sections fill in token by token with a caret where text is arriving, under a `Writing live` bar with `Cancel`. Ask fills its reply bubble, a Crossfire opponent's turn and a Sparring opponent's speech appear in place as they are written, and the Crossfire debrief and Sparring scorecard fill in section by section. When the answer is complete, the finished result replaces the draft. + +## Sub-features + +- `live-generate`: Generate's draft shows `Case Structure` and later sections before the result, then the full result and its toolbar replace it. +- `live-cancel`: `Cancel` on the draft stops the request; no draft, result, or error stays on the page. +- `live-tools`: Attack, Brainstorm, Factoids, and cited Ask each ask for live generation and receive an event stream. +- `live-practice`: Crossfire turns and debriefs and Sparring speeches and scorecards ask for live generation (checked in `recipes/practice.spec.ts` with `expectStreamed`). + +## How to get to it (user POV) + +- Run any AI tool: `/app/generate`, `/app/attack`, `/app/brainstorm`, `/app/factoids`, `/app/ask` with `Options` turned on, `/app/crossfire`, `/app/sparring`. +- Plain Ask (no options) keeps its own streaming endpoint, `ai-stream` (see [ai-tools](./ai-tools.md)). + +## Driving it with verify.sh + Playwright + +Preconditions: + +- Doctor passes. The user is signed in. `startDebate(page, motion)` avoids the motion dialog. + +- **Draft.** Click `Generate Debate Case`. `getByTestId("live-draft")` appears, holding a region named `Case Structure`, one `live-caret`, and a `Cancel` button. It disappears when the result renders. +- **Request.** The page's `ai` request body has `stream: true`. The response is `text/event-stream`: `phase` events (`research`, `writing`, `checking`), many `delta` events with the model's raw text, an optional `reset` when a repair retry starts the answer again, and exactly one final `result` event `{status, body}` holding what a plain request would return. Read it with `readAIResponse(response)`. +- **Provider.** `expectStreamed(["generate"])`: every provider call for the feature had `stream: true`. Research sub-calls (`research-decision`, `research-queries`) are never streamed. +- **Errors.** Checks before the feature (auth, input, quota, model access) still answer with plain JSON and their real status. A failure after streaming starts arrives as a `result` with the error status, which the client turns into the same error a plain request gets. + +## Gotchas + +- In fake mode the Generate draft is on screen for about a second; short replies (Crossfire turns, Sparring speeches) finish too fast to catch reliably, so prove them with `expectStreamed`, not by waiting for the draft. +- A server from before live generation ignores `stream: true` and answers with JSON. The client accepts both, so a visible result alone does not prove streaming; check the content type or `expectStreamed`. +- Never call `response.json()` on an `ai` response; use `readAIResponse` or `aiResponse`. +- In voice rounds the live text has audio tags removed, as the finished reply does. diff --git a/.claude/skills/verify-debatelab/recipes/fixtures.ts b/.claude/skills/verify-debatelab/recipes/fixtures.ts index 3a87ea4e8..e11c6411c 100644 --- a/.claude/skills/verify-debatelab/recipes/fixtures.ts +++ b/.claude/skills/verify-debatelab/recipes/fixtures.ts @@ -12,6 +12,8 @@ import { expect, fake, stackReady, test as stackTest } from "../../../../e2e/ful export * from "../../../../e2e/full-stack/stack"; // Scripted microphone, recogniser, and browser voice for spoken rounds. export { say, spokenByBrowser, stubSpeech } from "../../../../e2e/helpers/voice"; +import { readAIResponse, type AIResponseBody } from "../../../../e2e/helpers/ai-response"; +export { readAIResponse }; if (!stackReady) throw new Error("Stack environment missing; run recipes through `verify.sh drive`"); @@ -47,12 +49,27 @@ export function aiResponse(page: Page, feature: string, operation?: string) { } catch { return false; } - }, { timeout: AI_TIMEOUT }).then(async (response) => { - expect(response.status(), `ai ${feature} status`).toBe(200); - return response.json() as Promise<{ output: Record; discoveredSources?: unknown[]; [key: string]: unknown }>; + }, { timeout: AI_TIMEOUT }).then(async (response): Promise => { + // Pages stream the answer live; the final event holds the status and body. + const { status, body } = await readAIResponse(response); + expect(status, `ai ${feature} status`).toBe(200); + return body; }); } +/** + * Each feature's provider calls asked the model to stream: the page showed + * the answer live instead of waiting for the whole reply. + */ +export async function expectStreamed(features: string[]) { + const calls = await providerCalls(); + for (const feature of features) { + const own = calls.filter((call) => call.feature === feature); + expect(own.length, `provider calls for ${feature}`).toBeGreaterThan(0); + expect(own.map((call) => call.stream), `${feature} calls streamed`).toEqual(own.map(() => true)); + } +} + /** The provider was called for each feature, and every call succeeded. */ export async function expectProviderCalls(features: string[]) { const calls = await providerCalls(); diff --git a/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts new file mode 100644 index 000000000..36a96ccfb --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/live-generation.spec.ts @@ -0,0 +1,110 @@ +import type { Page, Response } from "@playwright/test"; +import { AI_TIMEOUT, expect, expectStreamed, readAIResponse, startDebate, test } from "./fixtures"; + +// Feature map: features/live-generation.md. +const motion = "This House would make public transport free at the point of use"; + +/** The next `ai` request for a feature, with the body the page sent. */ +function aiRequest(page: Page, feature: string): Promise { + return page.waitForResponse((response) => { + if (!response.url().endsWith("/functions/v1/ai") || response.request().method() !== "POST") return false; + try { + return (response.request().postDataJSON() as { feature?: string }).feature === feature; + } catch { + return false; + } + }, { timeout: AI_TIMEOUT }); +} + +/** The event stream's shape: token deltas in order, then exactly one result. */ +async function expectLiveStream(response: Response) { + expect((response.request().postDataJSON() as { stream?: unknown }).stream, "the page asked for live generation").toBe(true); + expect(response.headers()["content-type"]).toContain("text/event-stream"); + const text = await response.text(); + const events = text.split("\n\n").flatMap((block) => /^event: (\w+)$/m.exec(block)?.[1] ?? []); + const deltas = events.filter((event) => event === "delta").length; + expect(deltas, "token deltas").toBeGreaterThan(3); + expect(events.filter((event) => event === "result"), "one result event").toHaveLength(1); + expect(events.at(-1)).toBe("result"); + const { status, body } = await readAIResponse(response); + expect(status).toBe(200); + return { deltas, body }; +} + +test("live-generate: a case fills in section by section while the model writes it", async ({ signedIn: page, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/generate"); + await expect(page.getByRole("textbox", { name: "Motion / Topic *" })).toHaveValue(motion); + const request = aiRequest(page, "generate"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + + // Before the answer is complete, the draft shows what is written so far. + const draft = page.getByTestId("live-draft"); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(draft.getByRole("region", { name: "Case Structure" })).toBeVisible(); + await expect(draft.getByTestId("live-caret")).toHaveCount(1); + await expect(draft.getByRole("button", { name: "Cancel" })).toBeVisible(); + await evidence.shot("01-writing-live"); + await evidence.aria("01-writing-live", draft); + + const { deltas, body } = await expectLiveStream(await request); + await evidence.step(`Generate streamed ${deltas} token deltas before its result`); + const thesis = (body.output as { caseStructure: { thesis: string } }).caseStructure.thesis; + // The finished case replaces the draft with the full result and its toolbar. + await expect(draft).toHaveCount(0); + await expect(page.getByText(thesis).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible(); + await evidence.shot("02-finished-case"); + await expectStreamed(["generate"]); +}); + +test("live-cancel: Cancel stops a live run and leaves no draft or result", async ({ signedIn: page, evidence }) => { + await startDebate(page, motion); + await page.goto("/app/generate"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + const draft = page.getByTestId("live-draft"); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await draft.getByRole("button", { name: "Cancel" }).click(); + await expect(draft).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); + await evidence.shot("01-canceled"); +}); + +test("live-tools: Attack, Brainstorm, Factoids, and cited Ask stream their answers", async ({ signedIn: page, evidence }) => { + await startDebate(page, motion); + + await page.goto("/app/attack"); + await page.getByLabel("Your Argument *").fill("Free transit raises ridership, so it always pays for itself."); + let request = aiRequest(page, "attack"); + await page.getByRole("button", { name: "Find Weaknesses" }).click(); + await expectLiveStream(await request); + await evidence.shot("01-attack"); + + await page.goto("/app/brainstorm"); + request = aiRequest(page, "brainstorm"); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await expectLiveStream(await request); + await evidence.shot("02-brainstorm"); + + await page.goto("/app/factoids"); + await page.getByLabel("Statement *").fill("Free fares increase public transport ridership."); + request = aiRequest(page, "factoids"); + await page.getByRole("button", { name: "Find Factoids" }).click(); + await expectLiveStream(await request); + await evidence.shot("03-factoids"); + + await page.goto("/app/ask"); + await page.getByRole("button", { name: "Options" }).click(); + await page.locator("#citation-mode").click(); + await page.keyboard.press("Escape"); + await page.getByPlaceholder("Ask your debate coach...").fill("How should I weigh access against cost?"); + request = aiRequest(page, "ask"); + await page.getByRole("button", { name: "Send message" }).click(); + const { body } = await expectLiveStream(await request); + const answer = (body.output as { response: string }).response; + await expect(page.getByText(answer.slice(0, 30)).first()).toBeVisible(); + await evidence.shot("04-ask-cited"); + + await expectStreamed(["attack", "brainstorm", "factoids", "ask"]); +}); diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index ded9dda58..921d96625 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -1,4 +1,4 @@ -import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, expectProviderCalls, expectStreamed, test } from "./fixtures"; // Feature map: features/practice.md. Voice rounds are recipes/voice-rounds.spec.ts. // A new account has no Current Debate, so each round first accepts the offer @@ -43,6 +43,8 @@ test("spar-round and spar-verdict: a Lincoln-Douglas round is argued, judged, an expect(session).toMatchObject({ side: "pro", debates: { motion: sparMotion } }); const calls = await expectProviderCalls(["sparring"]); expect(calls.some((call) => call.feature === "sparring" && /SPARRING ACTION: score/m.test(call.user ?? ""))).toBe(true); + // Opponent speeches and the scorecard were shown live (features/live-generation.md). + await expectStreamed(["sparring"]); }); async function startCrossfire(page: import("@playwright/test").Page, mode: "mode-question-ai" | "mode-ai-questions-user") { @@ -80,6 +82,8 @@ test("cross-turn and cross-debrief: questioning the AI gets an answer, a Jev sig await evidence.shot("03-debrief"); await evidence.aria("03-debrief", page.getByTestId("crossfire-debrief")); await expectCrossfireSaved(await user.userId()); + // The turn and the debrief were shown live (features/live-generation.md). + await expectStreamed(["crossfire"]); }); test("cross-ai-questions: the AI questions the user, answers are judged, and the debrief completes", async ({ signedIn: page, user, evidence }) => { diff --git a/docs/architecture.md b/docs/architecture.md index 00bf25c0b..30b508c00 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,7 @@ Client-side environment selection is a developer convenience, not authorization. ## AI request flow -[`src/lib/ai/client.ts`](../src/lib/ai/client.ts) is the browser AI boundary: app code imports only from it. The request transport is `request.ts` (streaming Ask uses `streaming.ts`), and each feature has a file in `features/`, mirroring `supabase/functions/ai/features/`. +[`src/lib/ai/client.ts`](../src/lib/ai/client.ts) is the browser AI boundary: app code imports only from it. The request transport is `request.ts` (streaming Ask uses `streaming.ts`, live generation `live.ts`), and each feature has a file in `features/`, mirroring `supabase/functions/ai/features/`. 1. A feature page validates input and calls a feature wrapper. 2. The client reads the current Supabase session. @@ -64,7 +64,11 @@ Client-side environment selection is a developer convenience, not authorization. 5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge. 6. The client validates or transforms the response and updates the relevant store. -[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming structured tools and practice modes. It is the HTTP entrypoint and dispatcher: authentication, validation, entitlements, model routing, source ranking, and usage accounting run there once, then one handler per feature in [`supabase/functions/ai/features/`](../supabase/functions/ai/features/) builds the prompt and shapes the output. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in [`supabase/functions/_shared/prompts/`](../supabase/functions/_shared/prompts/) beside one module of shared rules. +[`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves the structured tools and practice modes. It is the HTTP entrypoint and dispatcher: authentication, validation, entitlements, model routing, source ranking, and usage accounting run there once, then one handler per feature in [`supabase/functions/ai/features/`](../supabase/functions/ai/features/) builds the prompt and shapes the output. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves plain Ask over server-sent events. Each feature's task prompt and user-prompt builder lives in [`supabase/functions/_shared/prompts/`](../supabase/functions/_shared/prompts/) beside one module of shared rules. + +### Live generation + +Every structured answer is shown while the model writes it. A page passes a `live` callback to its feature wrapper, and the request adds `stream: true`. Every check before the feature handler (authentication, input, debate context, quota, model routing, source ranking) still answers with plain JSON and its real status. After that, [`supabase/functions/ai/stream.ts`](../supabase/functions/ai/stream.ts) answers with server-sent events: `phase` (`research`, `writing`, `checking`), `delta` with the model's raw text, `reset` when a JSON repair retry starts the answer again, and one final `result` holding the status and body a plain request returns. Only the call whose output the user sees streams; research sub-calls and Jev decisions do not. The joined text is parsed exactly as a non-streamed reply, and streamed usage is billed the same way, marked incomplete if the stream breaks. On the client, [`src/lib/ai/live.ts`](../src/lib/ai/live.ts) reads the stream, [`packages/core/src/ai/partial-json.ts`](../packages/core/src/ai/partial-json.ts) turns the JSON written so far into a draft, and the page renders it with the result's own labels. A JSON body is accepted too, so the client works against a server from before live generation. For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional. diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index 9ff7c65bb..ab00bbb39 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -22,6 +22,8 @@ let upstreamCalls = 0; const MARK = "FAKE-PROVIDER"; +const STREAM_CHUNK_CHARS = 24; +const STREAM_CHUNK_DELAY_MS = 10; const usage = { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, cost: 0.0001 }; const contention = (index) => ({ @@ -209,6 +211,19 @@ function stream(res) { res.end("data: [DONE]\n\n"); } +// Live generation: a structured answer streamed in small chunks, the way a +// model writes tokens, so the client's partial rendering runs for real. +async function streamJson(res, content) { + res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" }); + for (let index = 0; index < content.length; index += STREAM_CHUNK_CHARS) { + res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: content.slice(index, index + STREAM_CHUNK_CHARS) } }] })}\n\n`); + await new Promise((resolve) => setTimeout(resolve, STREAM_CHUNK_DELAY_MS)); + } + res.write(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`); + res.write(`data: ${JSON.stringify({ choices: [], usage })}\n\n`); + res.end("data: [DONE]\n\n"); +} + async function readBody(req) { const chunks = []; for await (const chunk of req) chunks.push(chunk); @@ -312,6 +327,7 @@ createServer(async (req, res) => { } if (feature === "ask-stream") return stream(res); if (output === null) return json(res, 500, { error: { message: `Fake ${feature} could not parse the request` } }); + if (body.stream === true) return streamJson(res, JSON.stringify(output)); return json(res, 200, { id: `fake-${requests.length}`, model: body.model, diff --git a/e2e/full-stack/voice.spec.ts b/e2e/full-stack/voice.spec.ts index 854db7164..830a86b6e 100644 --- a/e2e/full-stack/voice.spec.ts +++ b/e2e/full-stack/voice.spec.ts @@ -1,5 +1,6 @@ import { admin, chooseForMotion, expect, fake, MARK, providerCall, stackReady, test } from "./stack"; import { say, spokenByBrowser, stubSpeech } from "../helpers/voice"; +import { readAIResponse } from "../helpers/ai-response"; test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)"); @@ -16,7 +17,7 @@ test("a spoken sparring round runs on the browser voice, and no audio tag is sho page.on("response", async (response) => { if (response.request().method() !== "POST") return; const path = new URL(response.url()).pathname; - if (path.endsWith("/functions/v1/ai")) aiVoice.push(await response.json().catch(() => ({}))); + if (path.endsWith("/functions/v1/ai")) aiVoice.push(await readAIResponse(response).then(({ body }) => body).catch(() => ({}))); if (path.endsWith("/functions/v1/voice")) { const body = await response.json().catch(() => ({})); voiceAnswers.push({ status: response.status(), code: body.code }); @@ -107,7 +108,7 @@ test("a spoken Crossfire reply is cleaned, signed, and read by the browser voice const aiVoice: Array = []; page.on("response", async (response) => { if (response.request().method() === "POST" && new URL(response.url()).pathname.endsWith("/functions/v1/ai")) { - aiVoice.push(await response.json().catch(() => ({}))); + aiVoice.push(await readAIResponse(response).then(({ body }) => body).catch(() => ({}))); } }); diff --git a/e2e/helpers/ai-response.ts b/e2e/helpers/ai-response.ts new file mode 100644 index 000000000..bfe0a28aa --- /dev/null +++ b/e2e/helpers/ai-response.ts @@ -0,0 +1,22 @@ +import type { Response } from "@playwright/test"; + +export type AIResponseBody = { output: Record; discoveredSources?: unknown[]; [key: string]: unknown }; + +/** + * The status and body of an `ai` Edge Function answer. Pages ask for live + * generation, which answers with Server-Sent Events whose last `result` + * event carries the status and JSON body a plain request returns; a plain + * request (or a server from before live generation) answers with JSON. + */ +export async function readAIResponse(response: Response): Promise<{ status: number; body: AIResponseBody }> { + const contentType = response.headers()["content-type"] ?? ""; + if (!contentType.includes("text/event-stream")) { + return { status: response.status(), body: await response.json() as AIResponseBody }; + } + const text = await response.text(); + const result = text.split(/\n\n/).reverse().find((block) => /^event: result$/m.test(block)); + const data = result && /^data: (.*)$/m.exec(result)?.[1]; + if (!data) throw new Error("The ai event stream ended without a result event"); + const parsed = JSON.parse(data) as { status: number; body: AIResponseBody }; + return { status: parsed.status, body: parsed.body }; +} diff --git a/e2e/live/live.ts b/e2e/live/live.ts index 7033b9d90..619ad0126 100644 --- a/e2e/live/live.ts +++ b/e2e/live/live.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import { expect, fake } from "../full-stack/stack"; +import { readAIResponse, type AIResponseBody } from "../helpers/ai-response"; // Live runs call real providers, so outputs vary. Assert structure, never text. export const LIVE_TIMEOUT = 180_000; @@ -13,9 +14,10 @@ export function aiResponse(page: Page, feature: string) { } catch { return false; } - }, { timeout: LIVE_TIMEOUT }).then(async (response) => { - expect(response.status(), `ai ${feature} status`).toBe(200); - return response.json() as Promise<{ output: Record; discoveredSources?: unknown[] }>; + }, { timeout: LIVE_TIMEOUT }).then(async (response): Promise => { + const { status, body } = await readAIResponse(response); + expect(status, `ai ${feature} status`).toBe(200); + return body; }); } diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index 6a1686175..028917654 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -1,5 +1,6 @@ import { createClient } from "@supabase/supabase-js"; import { expect, test, type Page } from "@playwright/test"; +import { readAIResponse } from "../helpers/ai-response"; // Production canary. Everything here is read-only except the last two tests, // which make real AI requests as the dedicated canary account and run only on @@ -119,8 +120,7 @@ test("the canary account generates a case", async ({ page }) => { await page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ }) .getByRole("button", { name: /^(Not now, use it once|Use once without saving to a debate)$/ }) .click(); - const response = await generated; - expect(response.status(), "ai generate status").toBe(200); - const body = await response.json() as { output: { contentions?: unknown[] } }; + const { status, body } = await readAIResponse(await generated); + expect(status, "ai generate status").toBe(200); expect(body.output.contentions, "generated contentions").toHaveLength(3); }); diff --git a/e2e/staging/authed-flows.spec.ts b/e2e/staging/authed-flows.spec.ts index 918515267..7e9e9a573 100644 --- a/e2e/staging/authed-flows.spec.ts +++ b/e2e/staging/authed-flows.spec.ts @@ -1,5 +1,6 @@ import { createClient } from '@supabase/supabase-js'; import { expect, test, type Page } from '@playwright/test'; +import { readAIResponse } from '../helpers/ai-response'; test.describe.configure({ mode: 'serial' }); // A failed action throws well inside the test timeout, so the cleanup in `finally` still runs. @@ -154,7 +155,7 @@ test('real staging session persists, starts a debate, writes and removes a sourc response.request().method() === 'POST' && response.url().includes('/functions/v1/ai'), { timeout: 90_000 }); await page.getByRole('button', { name: 'Send message' }).click(); - expect((await aiResponse).ok()).toBe(true); + expect((await readAIResponse(await aiResponse)).status).toBe(200); const saveResponseButton = page.getByRole('button', { name: 'Save message' }).last(); await expect(saveResponseButton).toBeAttached({ timeout: 90_000 }); const assistantBubble = saveResponseButton.locator('..'); diff --git a/packages/core/src/ai/partial-json.ts b/packages/core/src/ai/partial-json.ts new file mode 100644 index 000000000..69ec9ed69 --- /dev/null +++ b/packages/core/src/ai/partial-json.ts @@ -0,0 +1,161 @@ +// Reads the JSON a model has written so far. Streaming providers send a +// structured answer one token at a time, so the text is almost always cut in +// the middle of a string, number, or keyword. This parser returns the value +// that prefix describes: complete fields as they are, the field being +// written with its text so far, and nothing invented. It never throws. + +type Parsed = { value: unknown; complete: boolean }; + +const INCOMPLETE = Symbol('incomplete'); + +class Reader { + index = 0; + constructor(readonly text: string) {} + + skipSpace(): void { + while (this.index < this.text.length && /\s/.test(this.text[this.index])) this.index++; + } + + get done(): boolean { + return this.index >= this.text.length; + } + + peek(): string { + return this.text[this.index]; + } +} + +function parseString(reader: Reader): Parsed { + // Caller has seen the opening quote. + reader.index++; + let out = ''; + while (!reader.done) { + const char = reader.text[reader.index]; + if (char === '"') { + reader.index++; + return { value: out, complete: true }; + } + if (char !== '\\') { + out += char; + reader.index++; + continue; + } + const escape = reader.text[reader.index + 1]; + if (escape === undefined) break; + if (escape === 'u') { + const hex = reader.text.slice(reader.index + 2, reader.index + 6); + if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break; + out += String.fromCharCode(parseInt(hex, 16)); + reader.index += 6; + continue; + } + const simple: Record = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; + out += simple[escape] ?? escape; + reader.index += 2; + } + reader.index = reader.text.length; + // A lone high surrogate waiting for its pair would render as garbage. + if (/[\uD800-\uDBFF]$/.test(out)) out = out.slice(0, -1); + return { value: out, complete: false }; +} + +function parseNumber(reader: Reader): Parsed | typeof INCOMPLETE { + const match = /^-?(?:\d+)?(?:\.\d*)?(?:[eE][+-]?\d*)?/.exec(reader.text.slice(reader.index)); + const raw = match?.[0] ?? ''; + reader.index += raw.length; + const atEnd = reader.done; + const value = Number(raw); + if (!raw || Number.isNaN(value) || !/\d$/.test(raw)) return INCOMPLETE; + // A number at the very end may still be growing ("12" of "125"). + return { value, complete: !atEnd }; +} + +function parseLiteral(reader: Reader): Parsed | typeof INCOMPLETE { + for (const [word, value] of [['true', true], ['false', false], ['null', null]] as const) { + const rest = reader.text.slice(reader.index, reader.index + word.length); + if (rest === word) { + reader.index += word.length; + return { value, complete: true }; + } + if (word.startsWith(rest) && reader.index + rest.length >= reader.text.length) { + reader.index = reader.text.length; + return INCOMPLETE; + } + } + return INCOMPLETE; +} + +function parseValue(reader: Reader): Parsed | typeof INCOMPLETE { + reader.skipSpace(); + if (reader.done) return INCOMPLETE; + const char = reader.peek(); + if (char === '{') return parseObject(reader); + if (char === '[') return parseArray(reader); + if (char === '"') return parseString(reader); + if (char === '-' || (char >= '0' && char <= '9')) return parseNumber(reader); + return parseLiteral(reader); +} + +function parseObject(reader: Reader): Parsed { + reader.index++; + const out: Record = {}; + while (true) { + reader.skipSpace(); + if (reader.done) return { value: out, complete: false }; + const char = reader.peek(); + if (char === '}') { + reader.index++; + return { value: out, complete: true }; + } + if (char === ',') { + reader.index++; + continue; + } + if (char !== '"') return { value: out, complete: false }; + const key = parseString(reader); + // A key still being written has no value yet. + if (!key.complete) return { value: out, complete: false }; + reader.skipSpace(); + if (reader.done || reader.peek() !== ':') return { value: out, complete: false }; + reader.index++; + const value = parseValue(reader); + if (value === INCOMPLETE) return { value: out, complete: false }; + out[key.value as string] = value.value; + if (!value.complete) return { value: out, complete: false }; + } +} + +function parseArray(reader: Reader): Parsed { + reader.index++; + const out: unknown[] = []; + while (true) { + reader.skipSpace(); + if (reader.done) return { value: out, complete: false }; + const char = reader.peek(); + if (char === ']') { + reader.index++; + return { value: out, complete: true }; + } + if (char === ',') { + reader.index++; + continue; + } + const value = parseValue(reader); + if (value === INCOMPLETE) return { value: out, complete: false }; + out.push(value.value); + if (!value.complete) return { value: out, complete: false }; + } +} + +/** + * The object a streamed JSON answer describes so far, or undefined before + * its opening brace. Text before the brace (a ```json fence or a stray + * sentence) is skipped, as the server's own parser skips it. + */ +export function parsePartialJson(text: string): Record | undefined { + const start = text.indexOf('{'); + if (start < 0) return undefined; + const reader = new Reader(text); + reader.index = start; + return parseObject(reader).value as Record; +} diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index 099f24c85..ca3b43f15 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -18,6 +18,9 @@ import { measureDelivery, type SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; import type { AppLanguage } from '@/lib/languages'; import { ClockAnnouncer, RoundClock, clockTone } from '@/components/features/practice/RoundClock'; +import { LiveCaret, LiveToolRun } from '@/components/features/tools/live-draft'; +import { sparringScoreDraftSections } from '@/components/features/tools/live-sections'; +import type { LiveRunState } from '@/components/features/tools/tool-run'; const MAX_SPEECH_CHARS = 6_000; @@ -26,8 +29,12 @@ interface SparringRoundProps { /** The student speaks this round; the language is the one they speak in. */ voice?: { language: AppLanguage } | null; aiLoading?: boolean; + /** The opponent's speech as the model writes it. */ + liveSpeech?: string; aiError?: string | null; judging?: boolean; + /** The judge's scorecard as the model writes it. */ + liveJudging?: LiveRunState; judgingError?: string | null; submissionError?: string | null; onSubmit: (content: string, delivery?: SpeechDelivery | null) => void; @@ -51,8 +58,10 @@ export function SparringRound({ session, voice = null, aiLoading = false, + liveSpeech = '', aiError, judging = false, + liveJudging, judgingError, submissionError, onSubmit, @@ -67,6 +76,7 @@ export function SparringRound({ const resetTurnTimer = useSparringStore((state) => state.resetTurnTimer); const isOnline = useOnlineStatus(); const currentTurn = session.turns[session.currentTurnIndex]; + const judgingSections = liveJudging?.draft ? sparringScoreDraftSections(liveJudging.draft, t) : []; const currentWordCount = wordCount(session.draft); const totalWords = useMemo(() => session.speeches.reduce((sum, speech) => sum + wordCount(speech.content), 0), [session.speeches]); const userTurn = currentTurn?.speaker === 'user'; @@ -182,7 +192,9 @@ export function SparringRound({ - {judging ? ( + {judging && !judgingError && judgingSections.length > 0 ? ( + + ) : judging ? ( {judgingError ? ( @@ -247,6 +259,16 @@ export function SparringRound({ {!timeExpired && submissionError &&

{submissionError}

}
+ ) : aiLoading && !aiError && liveSpeech ? ( + // The opponent's speech as it is written, in the place it will be read. +
+
+
+

{liveSpeech}

+
) : ( diff --git a/src/components/features/tools/live-draft.tsx b/src/components/features/tools/live-draft.tsx new file mode 100644 index 000000000..438a1eb12 --- /dev/null +++ b/src/components/features/tools/live-draft.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import type { LivePhase } from '@/lib/ai/client'; +import { cn } from '@/lib/utils'; +import { ToolRunStatus, useElapsedSeconds, type LiveRunState } from './tool-run'; +import type { DraftBlock, DraftSection } from './live-sections'; + +/** The status line for a phase that writes nothing yet, or the tool's own label. */ +// eslint-disable-next-line react-refresh/only-export-components +export function useLivePhaseLabel(phase: LivePhase | undefined, fallback: string): string { + const { t } = useTranslation(); + if (phase === 'research') return t('tools.live.researching', 'Researching sources…'); + if (phase === 'checking') return t('tools.live.checking', 'Checking which sources the answer used…'); + if (phase === 'writing') return t('tools.live.writing', 'Writing…'); + return fallback; +} + +/** A blinking bar at the point the model is writing. */ +export function LiveCaret({ className }: { className?: string }) { + return ( +