diff --git a/kits/debate-arena/.env.example b/kits/debate-arena/.env.example new file mode 100644 index 00000000..da3eec89 --- /dev/null +++ b/kits/debate-arena/.env.example @@ -0,0 +1,9 @@ +# Lamatic API credentials (Settings -> API Keys / Project / API Docs in Studio) +LAMATIC_API_URL=YOUR_API_ENDPOINT +LAMATIC_PROJECT_ID=YOUR_PROJECT_ID +LAMATIC_API_KEY=YOUR_API_KEY + +# Deployed flow IDs (Studio -> flow -> three-dot menu -> Copy Flow Id) +DEBATE_SETUP_FLOW_ID=YOUR_DEBATE_SETUP_FLOW_ID +DEBATE_ROUND_FLOW_ID=YOUR_DEBATE_ROUND_FLOW_ID +DEBATE_JUDGE_FLOW_ID=YOUR_DEBATE_JUDGE_FLOW_ID diff --git a/kits/debate-arena/.gitignore b/kits/debate-arena/.gitignore new file mode 100644 index 00000000..9253f23e --- /dev/null +++ b/kits/debate-arena/.gitignore @@ -0,0 +1,6 @@ +.lamatic/ +node_modules/ +.next/ +.env +.env.local +*.tsbuildinfo diff --git a/kits/debate-arena/README.md b/kits/debate-arena/README.md new file mode 100644 index 00000000..1067a720 --- /dev/null +++ b/kits/debate-arena/README.md @@ -0,0 +1,64 @@ +# Debate Arena + +Pose any tradeoff or decision and watch two AI agents argue opposing sides across multiple rounds, then get an impartial judge's synthesis — a pros/cons matrix and a final recommendation. + +## Why + +Asking a single AI for advice on a hard tradeoff gets you one perspective, delivered with unearned confidence. Real decisions are clearer when you see the strongest case for each side argued out, rebutted, and then weighed by someone neutral. Debate Arena turns that into a repeatable workflow: two persona agents debate, then a judge agent synthesizes. + +## How it works + +Three flows: + +``` +"microservices vs monolith" ──▶ debate-setup ──▶ two clear positions + │ + ┌────────────────────┴────────────────────┐ + ▼ ▼ + debate-round (Position A) debate-round (Position B) + opening argument, then rebuttals each round, alternating + └────────────────────┬────────────────────┘ + ▼ + debate-judge ──▶ pros/cons + verdict +``` + +### 1. `debate-setup` + +**Input:** `topic` — a raw decision or question, in plain English (already framed as "X vs Y" or open-ended, e.g. "should I take this job"). + +**What it does:** Neutrally reframes the topic and produces exactly two clearly opposed positions a reasonable person could argue, without picking a side. States any assumptions it made explicitly. + +**Output:** `cleanTopic`, `positionA` (`label`, `stance`), `positionB` (`label`, `stance`), `context`. + +### 2. `debate-round` + +**Input:** `topic`, `position` (the side this call argues for), `opponentPosition`, `transcript` (all prior statements from both sides), `round`, `isRebuttal`. + +**What it does:** Generates that position's next statement — an opening argument on round 1, or a rebuttal of the opponent's most recent point plus a reinforced case on later rounds. This flow is called multiple times (once per side per round) by the app layer, not chained automatically, since each call needs a different persona/context. + +**Output:** `statement`, `keyPoint`. + +### 3. `debate-judge` + +**Input:** `topic`, `positionA`, `positionB`, `transcript` (the full debate). + +**What it does:** Extracts the pros and cons actually raised for each side (no inventing new ones), identifies the single strongest argument per side, and gives a final recommendation with an honest confidence level and caveats — including saying "it depends" when that's the truthful answer. + +**Output:** `prosA`, `consA`, `prosB`, `consB`, `strongestArgA`, `strongestArgB`, `recommendation`, `confidence`, `caveats`. + +## Uniqueness + +Checked against all 79 existing AgentKit registry entries (30 kits, 6 bundles, 43 templates) — nothing else runs a structured multi-agent debate-then-judge pattern. The closest neighbors (`founder-lens`, `system-design-analyzer`) analyze a single input from one perspective; none stage opposing agents against each other and synthesize a verdict. + +## Running the app locally + +```bash +cd apps +cp .env.example .env.local # fill in your Lamatic credentials + deployed flow IDs +npm install +npm run dev +``` + +Or deploy directly with the button below once the three flows are built and deployed in Lamatic Studio. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/Lamatic/AgentKit&root-directory=kits%2Fdebate-arena%2Fapps&env=DEBATE_SETUP_FLOW_ID,DEBATE_ROUND_FLOW_ID,DEBATE_JUDGE_FLOW_ID,LAMATIC_API_URL,LAMATIC_PROJECT_ID,LAMATIC_API_KEY&envDescription=Your%20Lamatic%20API%20credentials%20and%20deployed%20flow%20IDs%20are%20required.&envLink=https://github.com/Lamatic/AgentKit/tree/main/kits/debate-arena%23readme) diff --git a/kits/debate-arena/agent.md b/kits/debate-arena/agent.md new file mode 100644 index 00000000..be618dbe --- /dev/null +++ b/kits/debate-arena/agent.md @@ -0,0 +1,39 @@ +# Debate Arena + +## Overview + +Debate Arena turns any tradeoff or decision into a structured, multi-agent debate: two persona agents argue opposing positions across several rounds, rebutting each other, and a third impartial judge agent synthesizes a pros/cons matrix and a final recommendation. It is a three-flow kit (with a small Next.js app in `apps/`). + +## Flows + +### 1. `debate-setup` + +**Input:** `topic` (a raw decision or question in plain English). + +**What it does:** Uses an LLM (`groq/llama-3.3-70b-versatile`) to neutrally reframe the topic and produce exactly two clearly opposed positions, without taking a side itself. If the user's input is already framed as a binary ("X vs Y"), it keeps that framing; if open-ended, it frames the affirmative and the alternative case. States assumptions explicitly rather than burying them. + +**Output:** Structured JSON — `cleanTopic`, `positionA` (`label`, `stance`), `positionB` (`label`, `stance`), `context` — meant to seed every subsequent `debate-round` call. + +### 2. `debate-round` + +**Input:** `topic`, `position` (which side to argue), `opponentPosition`, `transcript` (all statements so far), `round`, `isRebuttal`. + +**What it does:** Uses an LLM (`groq/llama-3.3-70b-versatile`) to generate one persona's next statement. On the opening round it produces a fresh argument for that position; on later rounds, with `isRebuttal: true`, it must directly address the opponent's most recent point before reinforcing its own case. The prompt explicitly forbids fabricated statistics — arguments should reason from principles and well-known tradeoffs, not invented facts. Statements are capped around 120 words to keep the debate readable. This flow is invoked repeatedly (once per side per round) by the calling app, alternating `position`/`opponentPosition`; it is not a chained pipeline step by itself. + +**Output:** `statement`, `keyPoint` (a one-line summary of the point made, useful for the judge and for future rebuttals to target precisely). + +### 3. `debate-judge` + +**Input:** `topic`, `positionA`, `positionB`, `transcript` (every turn from both `debate-round` calls). + +**What it does:** Uses an LLM (`groq/llama-3.3-70b-versatile`) to review the full transcript and extract only the pros/cons that were actually argued (never inventing new ones), identify the single strongest argument per side, and produce a final recommendation with an honestly-stated confidence level (`low`/`medium`/`high`) and explicit caveats. If the honest answer is "it depends", the flow says so and states exactly what it depends on rather than forcing a false-confidence pick. + +**Output:** `prosA`, `consA`, `prosB`, `consB`, `strongestArgA`, `strongestArgB`, `recommendation`, `confidence`, `caveats`. + +## Guardrails + +All three flows follow `constitutions/default.md`: no fabricated facts or statistics (arguments must reason from stated principles/tradeoffs), the judge only reports pros/cons that were actually present in the transcript, confidence is stated honestly rather than defaulting to "high", and none of the flows log or retain the debate topic or transcript beyond the request/response cycle. + +## Integration + +Call `debate-setup` once with the raw topic, then call `debate-round` in a loop — once per side, per round, alternating `position`/`opponentPosition` and passing the growing `transcript` array each time — for as many rounds as desired (1-3 recommended). Finally call `debate-judge` once with the complete transcript. See `README.md` for the flow diagram, or run `apps/` locally for a ready-made UI that drives this exact sequence and renders it as a live back-and-forth debate. diff --git a/kits/debate-arena/apps/.env.example b/kits/debate-arena/apps/.env.example new file mode 100644 index 00000000..da3eec89 --- /dev/null +++ b/kits/debate-arena/apps/.env.example @@ -0,0 +1,9 @@ +# Lamatic API credentials (Settings -> API Keys / Project / API Docs in Studio) +LAMATIC_API_URL=YOUR_API_ENDPOINT +LAMATIC_PROJECT_ID=YOUR_PROJECT_ID +LAMATIC_API_KEY=YOUR_API_KEY + +# Deployed flow IDs (Studio -> flow -> three-dot menu -> Copy Flow Id) +DEBATE_SETUP_FLOW_ID=YOUR_DEBATE_SETUP_FLOW_ID +DEBATE_ROUND_FLOW_ID=YOUR_DEBATE_ROUND_FLOW_ID +DEBATE_JUDGE_FLOW_ID=YOUR_DEBATE_JUDGE_FLOW_ID diff --git a/kits/debate-arena/apps/.eslintrc.json b/kits/debate-arena/apps/.eslintrc.json new file mode 100644 index 00000000..957cd154 --- /dev/null +++ b/kits/debate-arena/apps/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals"] +} diff --git a/kits/debate-arena/apps/actions/orchestrate.ts b/kits/debate-arena/apps/actions/orchestrate.ts new file mode 100644 index 00000000..1348bbfa --- /dev/null +++ b/kits/debate-arena/apps/actions/orchestrate.ts @@ -0,0 +1,195 @@ +"use server"; + +import { z } from "zod"; +import { getLamaticClient } from "@/lib/lamatic-client"; +import kitConfig from "../../lamatic.config"; + +export type ActionResult = + | { success: true; data: T } + | { success: false; error: string }; + +const PositionSchema = z.object({ + label: z.string(), + stance: z.string(), +}); + +export type Position = z.infer; + +const DebateSetupSchema = z.object({ + cleanTopic: z.string(), + positionA: PositionSchema, + positionB: PositionSchema, + context: z.string(), +}); + +export type DebateSetup = z.infer; + +const DebateTurnSchema = z.object({ + round: z.number(), + side: z.enum(["A", "B"]), + label: z.string(), + statement: z.string(), + keyPoint: z.string(), +}); + +export type DebateTurn = z.infer; + +const DebateRoundResultSchema = z.object({ + statement: z.string(), + keyPoint: z.string(), +}); + +const DebateVerdictSchema = z.object({ + prosA: z.array(z.string()), + consA: z.array(z.string()), + prosB: z.array(z.string()), + consB: z.array(z.string()), + strongestArgA: z.string(), + strongestArgB: z.string(), + recommendation: z.string(), + confidence: z.enum(["low", "medium", "high"]), + caveats: z.array(z.string()), +}); + +export type DebateVerdict = z.infer; + +/** + * lamatic.config.ts is the source of truth for which env var holds each + * step's deployed flow ID. Resolving through the config (instead of + * hardcoding the env var name at each call site) keeps this file in sync + * if the config ever changes a step's envKey. + */ +type StepId = (typeof kitConfig.steps)[number]["id"]; + +function requireFlowId(stepId: StepId): string { + const step = kitConfig.steps.find((s) => s.id === stepId); + if (!step) { + throw new Error(`Unknown step "${stepId}" -- check lamatic.config.ts`); + } + + const flowId = process.env[step.envKey]; + if (!flowId) { + throw new Error( + `Missing ${step.envKey}. Set it in .env.local (see .env.example) after deploying the "${stepId}" flow in Lamatic Studio.` + ); + } + return flowId; +} + +/** + * Step 1: turn a raw decision/question into a neutral topic framing plus + * two clearly opposed positions. + */ +export async function runDebateSetup( + topic: string +): Promise> { + try { + if (!topic.trim()) { + return { success: false, error: "Please describe the decision or question you want debated." }; + } + + const client = getLamaticClient(); + const flowId = requireFlowId("debate-setup"); + + const res = await client.executeFlow(flowId, { topic: topic.trim() }); + + if (res.status !== "success" || !res.result) { + return { success: false, error: res.message || "Could not frame the debate topic. Please try rephrasing it." }; + } + + const parsed = DebateSetupSchema.safeParse(res.result); + if (!parsed.success) { + return { success: false, error: "The debate-setup flow returned an unexpected response shape. Please try again." }; + } + + return { success: true, data: parsed.data }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unexpected error framing the debate." }; + } +} + +/** + * Step 2: generate one agent's next statement (opening argument or + * rebuttal) for a given side. Called repeatedly by the client -- once per + * side per round -- with the accumulated transcript so far. + */ +export async function runDebateRound(params: { + topic: string; + position: Position; + opponentPosition: Position; + transcript: DebateTurn[]; + round: number; + side: "A" | "B"; + isRebuttal: boolean; +}): Promise> { + try { + const client = getLamaticClient(); + const flowId = requireFlowId("debate-round"); + + const res = await client.executeFlow(flowId, { + topic: params.topic, + position: params.position, + opponentPosition: params.opponentPosition, + transcript: params.transcript, + round: params.round, + isRebuttal: params.isRebuttal, + }); + + if (res.status !== "success" || !res.result) { + return { success: false, error: res.message || "Could not generate the next argument." }; + } + + const parsed = DebateRoundResultSchema.safeParse(res.result); + if (!parsed.success) { + return { success: false, error: "The debate-round flow returned an unexpected response shape. Please try again." }; + } + + const turn: DebateTurn = { + round: params.round, + side: params.side, + label: params.position.label, + statement: parsed.data.statement, + keyPoint: parsed.data.keyPoint, + }; + + return { success: true, data: turn }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unexpected error generating this round." }; + } +} + +/** + * Step 3: given the full transcript, synthesize a pros/cons matrix and a + * final recommendation. + */ +export async function runDebateJudge(params: { + topic: string; + positionA: Position; + positionB: Position; + transcript: DebateTurn[]; +}): Promise> { + try { + const client = getLamaticClient(); + const flowId = requireFlowId("debate-judge"); + + const res = await client.executeFlow(flowId, { + topic: params.topic, + positionA: params.positionA, + positionB: params.positionB, + transcript: params.transcript, + }); + + if (res.status !== "success" || !res.result) { + return { success: false, error: res.message || "Could not reach a verdict." }; + } + + const parsed = DebateVerdictSchema.safeParse(res.result); + if (!parsed.success) { + return { success: false, error: "The debate-judge flow returned an unexpected response shape. Please try again." }; + } + + return { success: true, data: parsed.data }; + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unexpected error judging the debate." }; + } +} diff --git a/kits/debate-arena/apps/app/globals.css b/kits/debate-arena/apps/app/globals.css new file mode 100644 index 00000000..57e65302 --- /dev/null +++ b/kits/debate-arena/apps/app/globals.css @@ -0,0 +1,422 @@ +@import "tailwindcss"; + +:root { + --color-error: #ff9b9b; + --color-error-border: #fa5b5b; +} + +* { + box-sizing: border-box; +} + +html, +body { + padding: 0; + margin: 0; + background: #0f1115; + color: #e8e9ed; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +main { + max-width: 860px; + margin: 0 auto; + padding: 40px 20px 80px; +} + +h1 { + font-size: 1.6rem; + margin-bottom: 4px; +} + +.subtitle { + color: #9aa0ab; + margin-bottom: 28px; + font-size: 0.95rem; +} + +textarea, +select, +input[type="text"], +input[type="number"] { + width: 100%; + background: #181b21; + border: 1px solid #2a2e37; + border-radius: 10px; + color: #e8e9ed; + padding: 12px 14px; + font-size: 0.95rem; + font-family: inherit; + resize: vertical; +} + +textarea:focus, +select:focus, +input[type="text"]:focus, +input[type="number"]:focus { + outline: none; + border-color: #5b7cfa; +} + +.controls-row { + display: flex; + gap: 12px; + align-items: center; + margin-top: 12px; + flex-wrap: wrap; +} + +button { + background: #5b7cfa; + color: white; + border: none; + border-radius: 10px; + padding: 11px 20px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; +} + +button:disabled { + background: #35394a; + cursor: not-allowed; +} + +.positions-banner { + display: flex; + gap: 16px; + margin: 24px 0; +} + +.position-card { + flex: 1; + border-radius: 12px; + padding: 14px 16px; + border: 1px solid #2a2e37; +} + +.position-card.a { + background: rgba(91, 124, 250, 0.1); + border-color: rgba(91, 124, 250, 0.4); +} + +.position-card.b { + background: rgba(250, 121, 91, 0.1); + border-color: rgba(250, 121, 91, 0.4); +} + +.position-card .label { + font-weight: 700; + margin-bottom: 4px; +} + +.position-card .stance { + font-size: 0.88rem; + color: #c3c6cf; +} + +.transcript { + display: flex; + flex-direction: column; + gap: 14px; + margin: 24px 0; +} + +.bubble { + max-width: 78%; + padding: 12px 16px; + border-radius: 14px; + font-size: 0.92rem; + line-height: 1.45; + animation: fadeIn 0.35s ease; +} + +.bubble .round-tag { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; + margin-bottom: 4px; +} + +.bubble.a { + align-self: flex-start; + background: rgba(91, 124, 250, 0.14); + border: 1px solid rgba(91, 124, 250, 0.35); + border-top-left-radius: 3px; +} + +.bubble.b { + align-self: flex-end; + background: rgba(250, 121, 91, 0.14); + border: 1px solid rgba(250, 121, 91, 0.35); + border-top-right-radius: 3px; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.thinking { + font-size: 0.85rem; + color: #9aa0ab; + font-style: italic; +} + +.verdict-panel { + margin-top: 32px; + border-radius: 14px; + border: 1px solid #2a2e37; + padding: 20px; + background: #14171d; +} + +.verdict-panel h2 { + margin-top: 0; + font-size: 1.15rem; +} + +.matrix { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin: 16px 0; +} + +.matrix-col h3 { + font-size: 0.9rem; + margin-bottom: 6px; +} + +.matrix-col ul { + margin: 0; + padding-left: 18px; + font-size: 0.86rem; + color: #c3c6cf; +} + +.recommendation { + margin-top: 16px; + padding: 14px; + border-radius: 10px; + background: rgba(91, 250, 178, 0.08); + border: 1px solid rgba(91, 250, 178, 0.3); +} + +.confidence-tag { + display: inline-block; + font-size: 0.72rem; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: #2a2e37; + margin-left: 8px; +} + +.confidence-tag.high { + background: rgba(91, 250, 178, 0.16); + color: #7dfcb8; +} + +.confidence-tag.medium { + background: rgba(250, 200, 91, 0.16); + color: #fcd97d; +} + +.confidence-tag.low { + background: rgba(250, 121, 91, 0.16); + color: #fca487; +} + +.caveats { + margin-top: 10px; + font-size: 0.82rem; + color: #9aa0ab; +} + +.error-banner { + margin: 16px 0; + padding: 12px 14px; + border-radius: 10px; + background: rgba(250, 91, 91, 0.1); + border: 1px solid rgba(250, 91, 91, 0.35); + color: var(--color-error); + font-size: 0.9rem; +} + +.field-error { + margin: 6px 0 0; + color: var(--color-error); + font-size: 0.82rem; +} + +.top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.rounds-label { + font-size: 0.85rem; + color: #9aa0ab; +} + +#rounds { + width: 70px; +} + +.ghost-button { + background: transparent; + color: #c3c6cf; + border: 1px solid #2a2e37; + border-radius: 10px; + padding: 8px 14px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.ghost-button:hover { + border-color: #5b7cfa; + color: #e8e9ed; +} + +.ghost-button.danger:hover { + border-color: var(--color-error-border); + color: var(--color-error); +} + +.verdict-panel .ghost-button { + margin-top: 16px; +} + +.retry-button { + margin-left: 12px; + background: transparent; + color: var(--color-error); + border: 1px solid rgba(250, 91, 91, 0.5); + border-radius: 8px; + padding: 4px 12px; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.retry-button:hover { + background: rgba(250, 91, 91, 0.12); +} + +.regenerate-button { + display: block; + margin-top: 8px; + background: transparent; + border: none; + color: inherit; + opacity: 0.6; + font-size: 0.76rem; + text-decoration: underline; + cursor: pointer; + padding: 0; +} + +.regenerate-button:hover:not(:disabled) { + opacity: 1; +} + +.regenerate-button:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +.history-panel { + margin: 12px 0 24px; + border: 1px solid #2a2e37; + border-radius: 12px; + padding: 14px 16px; + background: #14171d; +} + +.history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.history-list button { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + padding: 8px 10px; + color: #e8e9ed; + font-weight: 400; + font-size: 0.86rem; + text-align: left; + cursor: pointer; +} + +.history-list button:hover { + border-color: #2a2e37; + background: #181b21; +} + +.history-topic { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-date { + flex-shrink: 0; + font-size: 0.76rem; + color: #9aa0ab; +} + +@media (max-width: 640px) { + main { + padding: 24px 14px 60px; + } + + .top-row { + flex-direction: column; + gap: 12px; + } + + .positions-banner { + flex-direction: column; + } + + .matrix { + grid-template-columns: 1fr; + } + + .bubble { + max-width: 92%; + } + + .controls-row { + align-items: stretch; + } + + .history-list button { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } +} diff --git a/kits/debate-arena/apps/app/layout.tsx b/kits/debate-arena/apps/app/layout.tsx new file mode 100644 index 00000000..5552fca1 --- /dev/null +++ b/kits/debate-arena/apps/app/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Debate Arena", + description: + "Pose any tradeoff or decision and watch two AI agents argue opposing sides, then get an impartial judge's verdict.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/kits/debate-arena/apps/app/page.tsx b/kits/debate-arena/apps/app/page.tsx new file mode 100644 index 00000000..5ad1bcbc --- /dev/null +++ b/kits/debate-arena/apps/app/page.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { + runDebateSetup, + runDebateRound, + runDebateJudge, + type DebateSetup, + type DebateTurn, + type DebateVerdict, + type Position, +} from "@/actions/orchestrate"; + +type Phase = "idle" | "framing" | "debating" | "judging" | "done" | "error"; + +type Step = + | { kind: "setup" } + | { kind: "round"; round: number; side: "A" | "B" } + | { kind: "judge" }; + +type SavedDebate = { + id: string; + savedAt: string; + topic: string; + setup: DebateSetup; + transcript: DebateTurn[]; + verdict: DebateVerdict; +}; + +const HISTORY_KEY = "debate-arena-history"; +const MAX_HISTORY = 20; +const MAX_ROUNDS = 10; + +// Mirrors the shape server actions already validate on the way in -- history +// comes back out of localStorage, which can be edited, corrupted, or left +// over from an older version of this app, so it gets the same treatment. +const PositionSchema = z.object({ + label: z.string(), + stance: z.string(), +}); + +const SavedDebateSchema = z.object({ + id: z.string(), + savedAt: z.string(), + topic: z.string(), + setup: z.object({ + cleanTopic: z.string(), + positionA: PositionSchema, + positionB: PositionSchema, + context: z.string(), + }), + transcript: z.array( + z.object({ + round: z.number(), + side: z.enum(["A", "B"]), + label: z.string(), + statement: z.string(), + keyPoint: z.string(), + }) + ), + verdict: z.object({ + prosA: z.array(z.string()), + consA: z.array(z.string()), + prosB: z.array(z.string()), + consB: z.array(z.string()), + strongestArgA: z.string(), + strongestArgB: z.string(), + recommendation: z.string(), + confidence: z.enum(["low", "medium", "high"]), + caveats: z.array(z.string()), + }), +}); + +function loadHistory(): SavedDebate[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(HISTORY_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + // Drop any entries that don't match the expected shape instead of + // trusting them blindly -- a stale or hand-edited record shouldn't be + // able to crash the page when the history panel is opened. + return parsed.filter((entry): entry is SavedDebate => SavedDebateSchema.safeParse(entry).success); + } catch { + return []; + } +} + +function saveHistory(entries: SavedDebate[]) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY))); + } catch { + // localStorage unavailable (private browsing, quota, etc.) -- history just won't persist + } +} + +function confidenceClass(confidence: DebateVerdict["confidence"]): string { + return `confidence-tag ${confidence}`; +} + +function slugify(text: string): string { + return text + .slice(0, 40) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "debate"; +} + +function debateToMarkdown( + topic: string, + setup: DebateSetup, + transcript: DebateTurn[], + verdict: DebateVerdict +): string { + const lines: string[] = []; + lines.push(`# Debate: ${setup.cleanTopic}`); + lines.push(""); + lines.push(`_Original question: ${topic}_`); + lines.push(""); + lines.push(`## Positions`); + lines.push(`- **${setup.positionA.label}**: ${setup.positionA.stance}`); + lines.push(`- **${setup.positionB.label}**: ${setup.positionB.stance}`); + lines.push(""); + lines.push(`## Transcript`); + lines.push(""); + for (const turn of transcript) { + lines.push(`### Round ${turn.round} — ${turn.label}`); + lines.push(turn.statement); + lines.push(""); + } + lines.push(`## Verdict`); + lines.push(""); + lines.push(`**Confidence:** ${verdict.confidence}`); + lines.push(""); + lines.push(`**${setup.positionA.label} — Pros**`); + verdict.prosA.forEach((p) => lines.push(`- ${p}`)); + lines.push(""); + lines.push(`**${setup.positionA.label} — Cons**`); + verdict.consA.forEach((c) => lines.push(`- ${c}`)); + lines.push(""); + lines.push(`**${setup.positionB.label} — Pros**`); + verdict.prosB.forEach((p) => lines.push(`- ${p}`)); + lines.push(""); + lines.push(`**${setup.positionB.label} — Cons**`); + verdict.consB.forEach((c) => lines.push(`- ${c}`)); + lines.push(""); + lines.push(`**Recommendation:** ${verdict.recommendation}`); + if (verdict.caveats.length > 0) { + lines.push(""); + lines.push(`**Caveats:** ${verdict.caveats.join(" · ")}`); + } + return lines.join("\n"); +} + +export default function Home() { + const [topic, setTopic] = useState(""); + const [rounds, setRounds] = useState(2); + const [roundsInput, setRoundsInput] = useState("2"); + const [phase, setPhase] = useState("idle"); + const [setup, setSetup] = useState(null); + const [transcript, setTranscript] = useState([]); + const [verdict, setVerdict] = useState(null); + const [error, setError] = useState(null); + const [thinking, setThinking] = useState(null); + const [regeneratingRound, setRegeneratingRound] = useState(null); + const [history, setHistory] = useState([]); + const [showHistory, setShowHistory] = useState(false); + + const ctxRef = useRef<{ cleanTopic: string; positionA: Position; positionB: Position } | null>(null); + const failedStepRef = useRef(null); + + const isRunning = phase === "framing" || phase === "debating" || phase === "judging"; + + const parsedRoundsInput = Number(roundsInput); + const roundsError = + roundsInput.trim() === "" + ? "Enter a number of rounds." + : !Number.isInteger(parsedRoundsInput) + ? "Rounds must be a whole number." + : parsedRoundsInput < 1 + ? "Rounds can't be less than 1." + : parsedRoundsInput > MAX_ROUNDS + ? `Rounds can't be more than ${MAX_ROUNDS}.` + : null; + + useEffect(() => { + setHistory(loadHistory()); + }, []); + + function resetAll() { + setError(null); + setSetup(null); + setTranscript([]); + setVerdict(null); + setPhase("idle"); + setThinking(null); + setRegeneratingRound(null); + ctxRef.current = null; + failedStepRef.current = null; + } + + async function runStep(step: Step, localTranscript: DebateTurn[]): Promise { + const ctx = ctxRef.current; + + if (step.kind === "setup") { + setPhase("framing"); + setThinking("Framing the debate and picking two opposing positions..."); + + const res = await runDebateSetup(topic); + if (!res.success) { + failedStepRef.current = step; + setError(res.error); + setPhase("error"); + setThinking(null); + return; + } + + setSetup(res.data); + ctxRef.current = { + cleanTopic: res.data.cleanTopic, + positionA: res.data.positionA, + positionB: res.data.positionB, + }; + await runStep({ kind: "round", round: 1, side: "A" }, localTranscript); + return; + } + + if (step.kind === "round") { + if (!ctx) return; + setPhase("debating"); + + const isRebuttal = step.round > 1; + const position = step.side === "A" ? ctx.positionA : ctx.positionB; + const opponentPosition = step.side === "A" ? ctx.positionB : ctx.positionA; + setThinking(`Round ${step.round}: ${position.label} is ${isRebuttal ? "responding" : "opening"}...`); + + const res = await runDebateRound({ + topic: ctx.cleanTopic, + position, + opponentPosition, + transcript: localTranscript, + round: step.round, + side: step.side, + isRebuttal, + }); + + if (!res.success) { + failedStepRef.current = step; + setError(res.error); + setPhase("error"); + setThinking(null); + return; + } + + localTranscript.push(res.data); + setTranscript([...localTranscript]); + + const next: Step = + step.side === "A" + ? { kind: "round", round: step.round, side: "B" } + : step.round < rounds + ? { kind: "round", round: step.round + 1, side: "A" } + : { kind: "judge" }; + await runStep(next, localTranscript); + return; + } + + if (step.kind === "judge") { + if (!ctx) return; + setPhase("judging"); + setThinking("The judge is weighing both sides..."); + + const res = await runDebateJudge({ + topic: ctx.cleanTopic, + positionA: ctx.positionA, + positionB: ctx.positionB, + transcript: localTranscript, + }); + + if (!res.success) { + failedStepRef.current = step; + setError(res.error); + setPhase("error"); + setThinking(null); + return; + } + + setVerdict(res.data); + setPhase("done"); + setThinking(null); + failedStepRef.current = null; + + const entry: SavedDebate = { + id: `${Date.now()}`, + savedAt: new Date().toISOString(), + topic, + setup: { + cleanTopic: ctx.cleanTopic, + positionA: ctx.positionA, + positionB: ctx.positionB, + context: "", + }, + transcript: localTranscript, + verdict: res.data, + }; + setHistory((prev) => { + const updated = [entry, ...prev].slice(0, MAX_HISTORY); + saveHistory(updated); + return updated; + }); + } + } + + async function startDebate() { + if (!topic.trim()) { + setError("Describe the decision or question you want debated first."); + setPhase("error"); + return; + } + resetAll(); + await runStep({ kind: "setup" }, []); + } + + async function retryFailedStep() { + const step = failedStepRef.current; + if (!step) return; + setError(null); + await runStep(step, [...transcript]); + } + + async function regenerateRound(turnIndex: number) { + const ctx = ctxRef.current; + const turn = transcript[turnIndex]; + if (!ctx || !turn) return; + + setRegeneratingRound(turnIndex); + setError(null); + + const position = turn.side === "A" ? ctx.positionA : ctx.positionB; + const opponentPosition = turn.side === "A" ? ctx.positionB : ctx.positionA; + const priorTranscript = transcript.slice(0, turnIndex); + + const res = await runDebateRound({ + topic: ctx.cleanTopic, + position, + opponentPosition, + transcript: priorTranscript, + round: turn.round, + side: turn.side, + isRebuttal: turn.round > 1, + }); + + setRegeneratingRound(null); + + if (!res.success) { + setError(res.error); + return; + } + + const updated = [...transcript]; + updated[turnIndex] = res.data; + setTranscript(updated); + + // Regenerating the last turn invalidates any verdict/history entry that + // was judged against the statement it replaced -- re-run the judge on + // the corrected transcript so the displayed verdict, the download, and + // the saved history entry all stay consistent with what's on screen. + if (verdict) { + await runStep({ kind: "judge" }, updated); + } + } + + function downloadMarkdown() { + if (!setup || !verdict) return; + const md = debateToMarkdown(topic, setup, transcript, verdict); + const blob = new Blob([md], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `debate-${slugify(setup.cleanTopic)}.md`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + function clearHistory() { + setHistory([]); + saveHistory([]); + } + + function loadFromHistory(entry: SavedDebate) { + setTopic(entry.topic); + setSetup(entry.setup); + setTranscript(entry.transcript); + setVerdict(entry.verdict); + setPhase("done"); + setError(null); + setThinking(null); + setRegeneratingRound(null); + failedStepRef.current = null; + ctxRef.current = { + cleanTopic: entry.setup.cleanTopic, + positionA: entry.setup.positionA, + positionB: entry.setup.positionB, + }; + setShowHistory(false); + } + + return ( +
+
+
+

Debate Arena

+

+ Pose any tradeoff or decision. Two AI agents will argue opposing sides, then an impartial judge + weighs in with a verdict. +

+
+ +
+ + {showHistory && ( +
+ {history.length === 0 ? ( +

No saved debates yet -- finish one and it will show up here.

+ ) : ( + <> +
    + {history.map((entry) => ( +
  • + +
  • + ))} +
+ + + )} +
+ )} + + +