feat(chat): Stop button to cancel in-flight AI turns
Standard chat-app pattern: while the AI is streaming or running
tools, the Send button morphs into a Stop control (filled square
inside a faded spinner). Click it (or press Esc) to abort the turn.
Why: with MAX_TOOL_ROUNDS=18, a confused tool-loop can chew through
60-90s of compute and tokens. The user had no way to interrupt — they
just watched ✓ icons accumulate. Stop fixes that.
How:
Client (chat-panel.tsx):
- abortRef holds the in-flight AbortController; lives in a ref so the
Stop button can reach it without re-rendering on every chunk.
- sendMessage creates a fresh controller and passes signal to fetch.
- cancelMessage calls .abort(); also bound to Escape while sending.
- Button morph: while `sending`, render lucide Square overlaid on a
faded Loader2 spin, switch onClick to cancelMessage, swap aria/title
to "Stop generating (Esc)".
- Catch DOMException AbortError separately from network errors and
append "(stopped by user)" to the partial assistant message.
- Textarea no longer disabled during streaming so users can queue
the next prompt; Enter still won't submit until the turn ends.
Server (app/api/chat/route.ts):
- request.signal is captured before the ReadableStream and an `aborted`
flag is flipped on the addEventListener('abort', ...) callback.
- Loop checkpoints `aborted` (a) at the top of every round, (b) before
the inner tool-call loop, (c) before each individual executeMcpTool
call. Picks the next safe boundary instead of yanking mid-call.
- On abort: emit a "(stopped by user)" text chunk + an "aborted" event,
skip the round-cap recovery summary (don't pay for tokens the user
just canceled), persist the partial assistant message normally.
- Fetch errors that come from the abort propagating into Gemini's HTTP
client are recognized and downgraded from "error" to "aborted".
- safeClose() guards against double controller.close() when the abort
races with normal completion.
Made-with: Cursor
This commit is contained in:
@@ -235,23 +235,51 @@ export async function POST(request: Request) {
|
||||
const proto = host.startsWith('localhost') ? 'http' : 'https';
|
||||
const baseUrl = `${proto}://${host}`;
|
||||
|
||||
// Honor client-side abort (Stop button). When the user clicks Stop
|
||||
// the browser's AbortController fires `request.signal.aborted` and
|
||||
// the fetch stream is closed; we use it as a polite checkpoint
|
||||
// between rounds and tool calls so we (a) don't keep paying Gemini
|
||||
// for tokens the user no longer wants and (b) persist whatever the
|
||||
// assistant produced before the cancel.
|
||||
const clientSignal = request.signal;
|
||||
|
||||
// Stream response
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let streamClosed = false;
|
||||
function emit(chunk: object) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
if (streamClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
||||
} catch {
|
||||
// controller may have been closed by the abort handler
|
||||
streamClosed = true;
|
||||
}
|
||||
}
|
||||
function safeClose() {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let messages = [...history];
|
||||
let round = 0;
|
||||
let assistantText = '';
|
||||
const assistantToolCalls: ToolCall[] = [];
|
||||
let aborted = clientSignal.aborted;
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
};
|
||||
clientSignal.addEventListener('abort', onAbort);
|
||||
|
||||
try {
|
||||
// Tool-calling loop: use non-streaming so thought_signature is
|
||||
// always present in the complete response (required by thinking models).
|
||||
while (round < MAX_TOOL_ROUNDS) {
|
||||
if (aborted) break;
|
||||
round++;
|
||||
|
||||
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
|
||||
@@ -283,9 +311,11 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
if (!resp.toolCalls.length) break;
|
||||
if (aborted) break;
|
||||
|
||||
// Execute tool calls and add results
|
||||
for (const tc of resp.toolCalls) {
|
||||
if (aborted) break;
|
||||
const result = mcp_token
|
||||
? await executeMcpTool(tc.name, tc.args, mcp_token, baseUrl)
|
||||
: JSON.stringify({ error: 'No MCP token — read-only mode.' });
|
||||
@@ -302,6 +332,19 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the user clicked Stop, surface the cancel marker so the
|
||||
// client renders "(stopped by user)" inline with the partial
|
||||
// assistant message, then skip the round-cap recovery summary
|
||||
// (we shouldn't pay Gemini for a turn the user just canceled).
|
||||
if (aborted) {
|
||||
const stopMarker = assistantText
|
||||
? '\n\n_(stopped by user)_'
|
||||
: '_(stopped by user before any response)_';
|
||||
assistantText += stopMarker;
|
||||
emit({ type: 'text', text: stopMarker });
|
||||
emit({ type: 'aborted' });
|
||||
}
|
||||
|
||||
// If the loop exited because we hit MAX_TOOL_ROUNDS while the
|
||||
// model still wanted to call tools, the user has only seen a
|
||||
// tray of ✓ icons with no narrative. Force one final no-tools
|
||||
@@ -309,7 +352,7 @@ export async function POST(request: Request) {
|
||||
const lastTurnHadTools =
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1].role === 'tool';
|
||||
if (round >= MAX_TOOL_ROUNDS && lastTurnHadTools) {
|
||||
if (!aborted && round >= MAX_TOOL_ROUNDS && lastTurnHadTools) {
|
||||
try {
|
||||
const summary = await callGeminiChat({
|
||||
systemPrompt:
|
||||
@@ -340,12 +383,28 @@ export async function POST(request: Request) {
|
||||
);
|
||||
|
||||
emit({ type: 'done' });
|
||||
controller.close();
|
||||
safeClose();
|
||||
} catch (e) {
|
||||
emit({ type: 'error', error: e instanceof Error ? e.message : String(e) });
|
||||
controller.close();
|
||||
// AbortError is the expected shape when the client cancels
|
||||
// mid-Gemini-call — don't surface it as a real error.
|
||||
const isAbort =
|
||||
aborted ||
|
||||
(e instanceof Error && (e.name === 'AbortError' || /aborted/i.test(e.message)));
|
||||
if (!isAbort) {
|
||||
emit({ type: 'error', error: e instanceof Error ? e.message : String(e) });
|
||||
} else {
|
||||
emit({ type: 'aborted' });
|
||||
}
|
||||
safeClose();
|
||||
} finally {
|
||||
clientSignal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Browser disconnected (tab closed, navigated away). Nothing to
|
||||
// do — the abort handler above already flipped the flag and the
|
||||
// loop will bail at the next checkpoint.
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
|
||||
Reference in New Issue
Block a user