Commit
add server-side cancel debate endpoint and button
commit
d0c3cd0
4 changed files with +81 and −4
Jump to a changed file
added src/app/api/debates/[id]/cancel/route.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +import { NextResponse } from 'next/server'; | |
| 2 | +import { currentUserId } from '@/auth'; | |
| 3 | +import { getDebateOwnership } from '@/db/repositories'; | |
| 4 | +import { cancelDebate } from '@/lib/debate-registry'; | |
| 5 | + | |
| 6 | +export const runtime = 'nodejs'; | |
| 7 | + | |
| 8 | +type Params = { params: Promise<{ id: string }> }; | |
| 9 | + | |
| 10 | +/** POST: abort a running debate server-side (distinct from just disconnecting). */ | |
| 11 | +export async function POST(_request: Request, { params }: Params) { | |
| 12 | + const { id } = await params; | |
| 13 | + const ownership = await getDebateOwnership(id); | |
| 14 | + if (!ownership) return NextResponse.json({ error: 'Not found' }, { status: 404 }); | |
| 15 | + | |
| 16 | + const userId = await currentUserId(); | |
| 17 | + const canCancel = ownership.userId === null || ownership.userId === userId; | |
| 18 | + if (!canCancel) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); | |
| 19 | + | |
| 20 | + const cancelled = cancelDebate(id); | |
| 21 | + return NextResponse.json({ ok: cancelled }); | |
| 22 | +} |
modified src/components/debate/new-debate.tsx +18 −3
| @@ -128,6 +128,16 @@export function NewDebate() { | ||
| 128 | 128 | } |
| 129 | 129 | }; |
| 130 | 130 | |
| 131 | + const cancelDebateRun = async () => { | |
| 132 | + if (!view.debateId) return; | |
| 133 | + try { | |
| 134 | + await fetch(`/api/debates/${view.debateId}/cancel`, { method: 'POST' }); | |
| 135 | + toast.message('Cancelling debate...'); | |
| 136 | + } catch { | |
| 137 | + toast.error('Could not cancel'); | |
| 138 | + } | |
| 139 | + }; | |
| 140 | + | |
| 131 | 141 | const launch = () => |
| 132 | 142 | start({ |
| 133 | 143 | question: question.trim(), |
| @@ -295,9 +305,14 @@export function NewDebate() { | ||
| 295 | 305 | </div> |
| 296 | 306 | <div className="flex gap-2"> |
| 297 | 307 | {running && ( |
| 298 | - <Button variant="outline" size="sm" onClick={cancel}> | |
| 299 | - <Square className="h-3.5 w-3.5" /> Stop watching | |
| 300 | - </Button> | |
| 308 | + <> | |
| 309 | + <Button variant="outline" size="sm" onClick={cancel}> | |
| 310 | + <Square className="h-3.5 w-3.5" /> Stop watching | |
| 311 | + </Button> | |
| 312 | + <Button variant="destructive" size="sm" onClick={cancelDebateRun}> | |
| 313 | + <Square className="h-3.5 w-3.5" /> Cancel debate | |
| 314 | + </Button> | |
| 315 | + </> | |
| 301 | 316 | )} |
| 302 | 317 | <Button variant="outline" size="sm" onClick={reset}> |
| 303 | 318 | <RotateCcw className="h-3.5 w-3.5" /> New debate |
added src/lib/debate-registry.ts +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +/** | |
| 2 | + * Registry of in-flight debates so an explicit "cancel" request can abort a run | |
| 3 | + * server-side. | |
| 4 | + * | |
| 5 | + * Note this is intentionally separate from client disconnect: a browser going | |
| 6 | + * away does NOT abort the debate (graceful resume), but hitting the cancel | |
| 7 | + * endpoint does. Process-local, which is fine for the single-VPS deployment. | |
| 8 | + */ | |
| 9 | +const running = new Map<string, AbortController>(); | |
| 10 | + | |
| 11 | +export function registerDebate(id: string, controller: AbortController): void { | |
| 12 | + running.set(id, controller); | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function unregisterDebate(id: string): void { | |
| 16 | + running.delete(id); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function isDebateRunning(id: string): boolean { | |
| 20 | + return running.has(id); | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Abort a running debate. Returns false if it wasn't running here. */ | |
| 24 | +export function cancelDebate(id: string): boolean { | |
| 25 | + const controller = running.get(id); | |
| 26 | + if (!controller) return false; | |
| 27 | + controller.abort(new Error('Debate cancelled by user')); | |
| 28 | + running.delete(id); | |
| 29 | + return true; | |
| 30 | +} |
modified src/lib/debate-runner.ts +11 −1
| @@ -16,6 +16,7 @@import { PROMPT_VERSION } from '@/core/prompts'; | ||
| 16 | 16 | import type { DebateConfig } from '@/core/types'; |
| 17 | 17 | import { createDebate, finalizeDebate, persistEvent } from '@/db/repositories'; |
| 18 | 18 | import { resolveApiKey } from './byok'; |
| 19 | +import { registerDebate, unregisterDebate } from './debate-registry'; | |
| 19 | 20 | import { env } from './env'; |
| 20 | 21 | import { createOpenRouterClient } from './openrouter'; |
| 21 | 22 | import { getPricing } from './model-cache'; |
| @@ -52,6 +53,10 @@export async function startDebateStream( | ||
| 52 | 53 | ? new MockLlmClient() |
| 53 | 54 | : createOpenRouterClient({ apiKey: key.apiKey, pricing: await getPricing() }); |
| 54 | 55 | |
| 56 | + // Abort controller for explicit server-side cancellation (not client disconnect). | |
| 57 | + const abort = new AbortController(); | |
| 58 | + registerDebate(debateId, abort); | |
| 59 | + | |
| 55 | 60 | let closed = false; |
| 56 | 61 | let heartbeat: ReturnType<typeof setInterval> | undefined; |
| 57 | 62 | |
| @@ -80,12 +85,17 @@export async function startDebateStream( | ||
| 80 | 85 | |
| 81 | 86 | void (async () => { |
| 82 | 87 | try { |
| 83 | - const result = await runDebate(args.config, { llm, emit, logger: serverLogger }, { debateId }); | |
| 88 | + const result = await runDebate( | |
| 89 | + args.config, | |
| 90 | + { llm, emit, logger: serverLogger }, | |
| 91 | + { debateId, signal: abort.signal }, | |
| 92 | + ); | |
| 84 | 93 | await finalizeDebate(debateId, result); |
| 85 | 94 | } catch (err) { |
| 86 | 95 | serverLogger.error('run_failed', { debateId, err: String(err) }); |
| 87 | 96 | safeEnqueue(encodeNamed('error', { message: err instanceof Error ? err.message : 'Debate failed' })); |
| 88 | 97 | } finally { |
| 98 | + unregisterDebate(debateId); | |
| 89 | 99 | if (heartbeat) clearInterval(heartbeat); |
| 90 | 100 | if (!closed) { |
| 91 | 101 | try { |