diff --git a/docs/development.md b/docs/development.md index 4d39f34d..91cbdbc2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,6 +71,8 @@ The checked-in [`.env.example`](../.env.example) contains optional client observ - `VITE_POSTHOG_KEY` - `VITE_POSTHOG_HOST` +Two more build variables turn on the no-login mini round on `/ai-debate`: `VITE_DEMO_ENABLED=true` and `VITE_TURNSTILE_SITE_KEY` (Cloudflare Turnstile's public site key). Without both, the page shows its static sample round. The `demo-rebuttal` Edge Function has its own `DEMO_ENABLED` switch; see [supabase/README.md](../supabase/README.md#function-groups). + Production builds also read the Sentry build variables `SENTRY_AUTH_TOKEN`, `SENTRY_ORG` and `SENTRY_PROJECT`. Local builds leave them unset and skip source map upload. See [operations.md](operations.md#secrets). The frontend Supabase environment is resolved by [`src/lib/supabase/env.ts`](../src/lib/supabase/env.ts), not by those variables. Public browser configuration is distinct from server secrets. diff --git a/e2e/ai-debate.spec.ts b/e2e/ai-debate.spec.ts new file mode 100644 index 00000000..5061514f --- /dev/null +++ b/e2e/ai-debate.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, type Page, type Request } from '@playwright/test'; + +// The /ai-debate mini round with the demo-rebuttal Edge Function and Cloudflare +// Turnstile mocked. playwright.config.ts builds the dev server with +// VITE_DEMO_ENABLED=true and a test site key, so the live form shows. + +const TURNSTILE_STUB = `window.turnstile = { + render(element, options) { setTimeout(() => options.callback('test-turnstile-token'), 0); return 'widget-1'; }, + reset() {}, + remove() {}, +};`; + +async function mockDemo(page: Page) { + const bodies: Array> = []; + await page.route('https://challenges.cloudflare.com/**', (route) => route.fulfill({ contentType: 'text/javascript', body: TURNSTILE_STUB })); + await page.route('**/functions/v1/demo-rebuttal', async (route) => { + const request: Request = route.request(); + if (request.method() === 'OPTIONS') return route.fulfill({ status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*' } }); + const body = request.postDataJSON() as Record; + bodies.push(body); + const turn = body.sessionId ? 2 : 1; + return route.fulfill({ + status: 200, + headers: { 'Access-Control-Allow-Origin': '*' }, + contentType: 'application/json', + body: JSON.stringify({ + sessionId: '11111111-1111-4111-8111-111111111111', + turn, + turnsLeft: turn === 1 ? 1 : 0, + rebuttal: turn === 1 ? 'Phones also let pupils reach their parents in an emergency.' : 'Office phones are slower when every second counts.', + judgeNote: turn === 1 ? 'You never say how a ban is enforced.' : 'Good answer; now weigh safety against focus.', + }), + }); + }); + return bodies; +} + +test.describe('AI debate mini round', () => { + test('two turns against the mocked AI end in the trial CTA', async ({ page }) => { + const bodies = await mockDemo(page); + await page.goto('/ai-debate'); + await expect(page.getByRole('heading', { level: 1, name: 'Debate an AI that argues back' })).toBeVisible(); + await expect(page.getByRole('heading', { level: 2, name: 'Frequently asked questions' })).toBeVisible(); + + await page.getByLabel('Your argument').fill('Phones distract pupils in class and make bullying easier.'); + await page.getByRole('button', { name: 'Get the AI’s rebuttal' }).click(); + await expect(page.getByTestId('demo-rebuttal').first()).toHaveText('Phones also let pupils reach their parents in an emergency.'); + await expect(page.getByTestId('demo-judge-note').first()).toContainText('You never say how a ban is enforced.'); + + await page.getByLabel('Your reply (one more turn)').fill('The school office phone covers emergencies.'); + await page.getByRole('button', { name: 'Send reply' }).click(); + await expect(page.getByTestId('demo-rebuttal').nth(1)).toHaveText('Office phones are slower when every second counts.'); + + // Two turns is the cap: no third reply box, and the trial CTA in its place. + await expect(page.getByLabel('Your reply (one more turn)')).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Run a full timed round: start your 3-day trial' }).first()).toHaveAttribute('href', '/login'); + + expect(bodies[0]).toMatchObject({ turnstileToken: 'test-turnstile-token', side: 'for', motion: 'This House would ban smartphones in schools' }); + expect(bodies[0].sessionId).toBeUndefined(); + expect(bodies[1]).toMatchObject({ + sessionId: '11111111-1111-4111-8111-111111111111', + previous: { argument: 'Phones distract pupils in class and make bullying easier.', rebuttal: 'Phones also let pupils reach their parents in an emergency.' }, + }); + }); + + test('a switched-off server falls back to the sample round', async ({ page }) => { + await page.route('https://challenges.cloudflare.com/**', (route) => route.fulfill({ contentType: 'text/javascript', body: TURNSTILE_STUB })); + await page.route('**/functions/v1/demo-rebuttal', (route) => route.fulfill({ + status: 503, + headers: { 'Access-Control-Allow-Origin': '*' }, + contentType: 'application/json', + body: JSON.stringify({ error: 'demo_disabled' }), + })); + await page.goto('/ai-debate'); + await page.getByLabel('Your argument').fill('Phones distract pupils in class.'); + await page.getByRole('button', { name: 'Get the AI’s rebuttal' }).click(); + await expect(page.getByTestId('demo-sample')).toBeVisible(); + await expect(page.getByLabel('Your argument')).toHaveCount(0); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index fc83d886..9cb284b9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -85,6 +85,9 @@ export default defineConfig({ stderr: 'pipe', env: { VITE_E2E: '1', + // The /ai-debate mini round; e2e/ai-debate.spec.ts mocks Turnstile and the function. + VITE_DEMO_ENABLED: 'true', + VITE_TURNSTILE_SITE_KEY: 'e2e-turnstile-site-key', ...(process.env.E2E_ALLOW_PROD === '1' ? { VITE_E2E_ALLOW_PROD: '1' } : {}), }, }, diff --git a/public/llms.txt b/public/llms.txt index add369a8..2999c856 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -21,6 +21,7 @@ DebateOwl runs at https://debateowl.com/. It is a separate product from debatela - [Home](https://debateowl.com/): what DebateOwl is, the 8 prep modes, pricing and FAQ - [Pricing](https://debateowl.com/debateowl-pricing): trial, Pro and what each includes +- [AI debate partner](https://debateowl.com/ai-debate): argue one side of a motion and an AI opponent rebuts it. One exchange and one follow-up are free with no account; full timed rounds need the 3-day trial, then $5/month + tax - [About](https://debateowl.com/about): what DebateOwl is, who makes it and its former name - [Contact](https://debateowl.com/contact): email hello@debateowl.com for support and feedback, privacy@debateowl.com for privacy and data requests - [Press kit and facts](https://debateowl.com/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what DebateOwl is not diff --git a/public/og/ai-debate.png b/public/og/ai-debate.png new file mode 100644 index 00000000..426f6002 Binary files /dev/null and b/public/og/ai-debate.png differ diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 3b3369b6..375c1d67 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -76,6 +76,17 @@ function pageHtml(shell, page, assets) { [//, ``, 'twitter:title'], [//, ``, 'twitter:description'], ]) html = replaceOnce(html, pattern, value, label); + // A page with its own share image; the shell's width and height (1200x630) still apply. + if (head.image) { + const url = escapeAttribute(`${SITE_ORIGIN}${head.image.path}`); + const alt = escapeAttribute(head.image.alt); + for (const [pattern, value, label] of [ + [//, ``, 'og:image'], + [//, ``, 'og:image:alt'], + [//, ``, 'twitter:image'], + [//, ``, 'twitter:image:alt'], + ]) html = replaceOnce(html, pattern, value, label); + } // The shell document is noindex (app, login, share). A public page puts the // index directive back; a page without a canonical URL stays noindex. const robots = head.canonicalPath diff --git a/src/App.tsx b/src/App.tsx index 656e5fbb..62d7072b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ const PrivacyPage = prerenderedPage("/privacy"); const AboutPage = prerenderedPage("/about"); const ContactPage = prerenderedPage("/contact"); const PressPage = prerenderedPage("/press"); +const AiDebatePage = prerenderedPage("/ai-debate"); // The Hungarian and Spanish addresses in src/config/localized-routes.json. const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) })); const HomePage = lazyChunk(routeLoaders["/app"]); @@ -480,6 +481,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/ai-debate/MiniRound.tsx b/src/components/ai-debate/MiniRound.tsx new file mode 100644 index 00000000..09bcc956 --- /dev/null +++ b/src/components/ai-debate/MiniRound.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState, type FormEvent } from 'react'; +import { Link } from 'react-router-dom'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import site from '@/config/site.json'; +import { cn } from '@/lib/utils'; +import { + ARGUMENT_MAX, + DEMO_CLIENT_ENABLED, + DemoTurnError, + loadTurnstile, + MOTION_MAX, + PRESET_MOTIONS, + requestDemoTurn, + TURNSTILE_SITE_KEY, + type DemoSide, +} from '@/lib/ai-debate-demo'; + +const CUSTOM = 'custom'; +const fieldClass = 'mt-1.5'; + +/** The one sample exchange the page shows without JavaScript, or while the live demo is off. */ +export const SAMPLE_ROUND = { + motion: 'This House would replace school sports days with a week of outdoor learning', + side: 'For', + argument: 'Sports days reward the few pupils who are already athletic and leave everyone else watching. A week of outdoor learning gets every pupil moving and teaches skills like map reading and teamwork that last longer than one race.', + rebuttal: 'My opponent assumes a sports day only serves athletes, but most sports days are built around house points, relay teams and novelty races where effort counts as much as speed. That is exactly the teamwork they want. Their alternative also costs a school a full week of lessons, staff cover and transport, while a sports day costs one afternoon. If the problem is that some pupils feel left out, the fix is to redesign the events, not to cancel the one day the whole school competes together. And a week outdoors does not guarantee that anyone moves more: without competition, many pupils will do the minimum.', + judgeNote: 'Weakest link: "skills that last longer than one race" is asserted, not shown. Give one reason or example of why outdoor learning sticks.', +} as const; + +function Exchange({ argument, rebuttal, judgeNote, label }: { argument: string; rebuttal: string; judgeNote: string; label: string }) { + return ( +
+
+

{label}

+

{argument}

+
+
+

AI opponent

+

{rebuttal}

+
+

+ Judge note: {judgeNote} +

+
+ ); +} + +export function TrialCta() { + return ( +
+ {/* Also shown inside the article, whose link colour would otherwise win over the button's. */} + + Run a full timed round: start your {site.pricing.trialDays}-day trial + +

Then ${site.pricing.proMonthly}/month + tax. Cancel anytime.

+
+ ); +} + +function SampleRound() { + return ( +
+

+ A sample exchange. Motion: {SAMPLE_ROUND.motion}. Side: {SAMPLE_ROUND.side}. +

+ + +
+ ); +} + +const ERRORS: Record = { + ip_limit: 'You have used today’s demo rounds on this network. Start a trial to keep practicing.', + turn_limit: 'That was the last reply in this demo round. Start a trial for a full timed round.', + daily_limit: 'Today’s demo rounds are used up. Come back tomorrow, or start a trial now.', + not_allowed: 'That motion or argument cannot be used in the demo. Try a different one.', + bot_check_failed: 'The bot check did not pass. Wait for it to finish and try again.', + // A follow-up from a different IP (a phone switching networks) cannot continue the session. + unknown_session: 'Your connection changed, so this round cannot continue. Reload the page to start a new one.', +}; +const ENDS_DEMO = new Set(['ip_limit', 'turn_limit', 'daily_limit', 'unknown_session']); + +type Turn = { argument: string; rebuttal: string; judgeNote: string }; + +function LiveRound({ onDisabled }: { onDisabled: () => void }) { + const [preset, setPreset] = useState(PRESET_MOTIONS[0]); + const [customMotion, setCustomMotion] = useState(''); + const [side, setSide] = useState('for'); + const [argument, setArgument] = useState(''); + const [token, setToken] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [turns, setTurns] = useState([]); + const [turnsLeft, setTurnsLeft] = useState(1); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [ended, setEnded] = useState(false); + const widgetRef = useRef(null); + const widgetId = useRef(null); + + const motion = (preset === CUSTOM ? customMotion : preset).trim(); + const started = turns.length > 0; + const finished = ended || (started && turnsLeft === 0); + + // The bot check runs once, before the first call. + useEffect(() => { + if (started) return; + let cancelled = false; + loadTurnstile().then((turnstile) => { + if (cancelled || !widgetRef.current || widgetId.current) return; + widgetId.current = turnstile.render(widgetRef.current, { + sitekey: TURNSTILE_SITE_KEY, + action: 'ai-debate-demo', + callback: (value: string) => setToken(value), + 'expired-callback': () => setToken(null), + 'error-callback': () => setToken(null), + }); + }).catch(() => { + if (!cancelled) setError('The bot check could not load. Check your connection or ad blocker and reload the page.'); + }); + return () => { + cancelled = true; + if (widgetId.current) window.turnstile?.remove(widgetId.current); + widgetId.current = null; + }; + }, [started]); + + async function submit(event: FormEvent) { + event.preventDefault(); + const text = argument.trim(); + if (!text || !motion || pending) return; + setPending(true); + setError(null); + try { + const first = turns[0]; + const reply = await requestDemoTurn(started && sessionId && first + ? { sessionId, motion, side, argument: text, previous: { argument: first.argument, rebuttal: first.rebuttal } } + : { turnstileToken: token ?? undefined, motion, side, argument: text }); + setSessionId(reply.sessionId); + setTurns((previous) => [...previous, { argument: text, rebuttal: reply.rebuttal, judgeNote: reply.judgeNote }]); + setTurnsLeft(reply.turnsLeft); + setArgument(''); + } catch (caught) { + const code = caught instanceof DemoTurnError ? caught.code : 'unavailable'; + if (code === 'demo_disabled') { + onDisabled(); + return; + } + if (ENDS_DEMO.has(code)) setEnded(true); + if (!started && widgetId.current) { + // A Turnstile token is single-use; a new attempt needs a new one. + setToken(null); + window.turnstile?.reset(widgetId.current); + } + setError(ERRORS[code] ?? 'The AI opponent is unavailable right now. Try again in a minute.'); + } finally { + setPending(false); + } + } + + return ( +
+ {!started && ( +
+
+ + +
+ {preset === CUSTOM && ( +
+ + setCustomMotion(event.target.value)} + required + /> +
+ )} +
+ Your side +
+ {(['for', 'against'] as const).map((value) => ( + + ))} +
+
+
+ +