fix(rectification): stream answers live and keep mobile chat visible
Use the quiet overlay scrollbar on chat and the house board, replace the blocking mobile dialog with a bottom sheet, and emit agent deltas before billing so users can read progress and stop a run. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -394,20 +394,48 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
|
||||
[data-sidebar="header"] { flex: 0 0 auto; }
|
||||
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: transparent transparent; }
|
||||
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within { scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar { width: 10px; background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner { background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
|
||||
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
|
||||
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
[data-sidebar="content"]:hover, [data-sidebar="content"]:focus-within,
|
||||
.conversation:hover, .conversation:focus-within,
|
||||
.rectification-board__body:hover, .rectification-board__body:focus-within {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-ink) 26%, transparent) transparent;
|
||||
}
|
||||
[data-sidebar="content"]::-webkit-scrollbar,
|
||||
.conversation::-webkit-scrollbar,
|
||||
.rectification-board__body::-webkit-scrollbar { width: 10px; background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-button,
|
||||
.conversation::-webkit-scrollbar-button,
|
||||
.rectification-board__body::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-track, [data-sidebar="content"]::-webkit-scrollbar-corner,
|
||||
.conversation::-webkit-scrollbar-track, .conversation::-webkit-scrollbar-corner,
|
||||
.rectification-board__body::-webkit-scrollbar-track, .rectification-board__body::-webkit-scrollbar-corner { background: transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb,
|
||||
.conversation::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb { background-color: transparent; border: 3px solid transparent; border-radius: 999px; background-clip: content-box; }
|
||||
[data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
|
||||
.conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--color-ink) 26%, transparent); }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb:hover,
|
||||
.conversation::-webkit-scrollbar-thumb:hover,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb:hover { background-color: color-mix(in srgb, var(--color-ink) 40%, transparent); }
|
||||
@media (prefers-contrast: more) {
|
||||
[data-sidebar="content"] { scrollbar-color: var(--color-ink-secondary) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body { scrollbar-color: var(--color-ink-secondary) transparent; }
|
||||
[data-sidebar="content"]::-webkit-scrollbar-thumb, [data-sidebar="content"]:hover::-webkit-scrollbar-thumb, [data-sidebar="content"]:focus-within::-webkit-scrollbar-thumb,
|
||||
.conversation::-webkit-scrollbar-thumb, .conversation:hover::-webkit-scrollbar-thumb, .conversation:focus-within::-webkit-scrollbar-thumb,
|
||||
.rectification-board__body::-webkit-scrollbar-thumb, .rectification-board__body:hover::-webkit-scrollbar-thumb, .rectification-board__body:focus-within::-webkit-scrollbar-thumb { background-color: var(--color-ink-secondary); }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
[data-sidebar="content"] { scrollbar-width: auto; scrollbar-color: auto; }
|
||||
[data-sidebar="content"],
|
||||
.conversation,
|
||||
.rectification-board__body { scrollbar-width: auto; scrollbar-color: auto; }
|
||||
}
|
||||
[data-sidebar="footer"] { flex: 0 0 auto; }
|
||||
[data-sidebar="sidebar"]:focus { outline: none; }
|
||||
@@ -482,7 +510,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.chat-header-actions > * { flex: 0 0 auto; }
|
||||
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 11px; cursor: pointer; font-size: 13px; font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
|
||||
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
.conversation.is-empty { display: grid; place-items: center; padding: var(--space-8); }
|
||||
.welcome { width: min(820px, 100%); padding: var(--space-10) 0 var(--space-16); }
|
||||
.welcome > .onboarding-message:first-child { padding-bottom: var(--space-8); }
|
||||
@@ -1124,17 +1152,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.paywall-footer > a, .paywall-footer > button { width: 100%; }
|
||||
.dialog-actions { flex-direction: column-reverse; }
|
||||
.dialog-actions > button { width: 100%; }
|
||||
dialog.rectification-board {
|
||||
width: 100%;
|
||||
height: min(86dvh, 100%);
|
||||
margin-block-start: auto;
|
||||
margin-inline: 0;
|
||||
border-inline-start: 0;
|
||||
border-block-start: 1px solid var(--color-border);
|
||||
border-start-start-radius: var(--radius-lg);
|
||||
border-start-end-radius: var(--radius-lg);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.auth-story { min-height: 0; padding: var(--space-5); }
|
||||
.auth-story h2 { font-size: var(--type-title-lg); }
|
||||
.auth-panel { padding: var(--space-6) var(--space-5) max(var(--space-6), env(safe-area-inset-bottom)); }
|
||||
@@ -1612,10 +1629,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: var(--space-10) var(--session-column-gutter) var(--space-24);
|
||||
}
|
||||
|
||||
.conversation:not(.is-empty):not(.is-rectification) {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
.conversation:not(.is-empty):not(.is-rectification) .message {
|
||||
padding: 0;
|
||||
animation: none;
|
||||
@@ -1871,6 +1884,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-workspace.is-compact {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.rectification-workspace.is-compact.is-board-open {
|
||||
grid-template-rows: minmax(8rem, 1fr) minmax(13rem, 46%);
|
||||
}
|
||||
.rectification-workspace__chat {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -1897,21 +1913,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
dialog.rectification-board {
|
||||
width: min(22.5rem, 100%);
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
margin-inline-start: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-inline-start: 1px solid var(--color-border);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink);
|
||||
.rectification-board.is-sheet {
|
||||
border-inline-start: 0;
|
||||
border-block-start: 1px solid var(--color-border);
|
||||
border-start-start-radius: var(--radius-lg);
|
||||
border-start-end-radius: var(--radius-lg);
|
||||
box-shadow: 0 -8px 28px color-mix(in srgb, var(--color-ink) 10%, transparent);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
dialog.rectification-board::backdrop {
|
||||
background: var(--color-scrim);
|
||||
.rectification-board__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
margin: var(--space-2) auto 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-ink) 18%, transparent);
|
||||
}
|
||||
.rectification-board__header {
|
||||
position: sticky;
|
||||
@@ -1919,17 +1934,21 @@ dialog.rectification-board::backdrop {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-4) var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-3);
|
||||
background: var(--color-canvas-soft);
|
||||
}
|
||||
.rectification-board__title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
}
|
||||
.rectification-board__header h2 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 600;
|
||||
@@ -1942,15 +1961,24 @@ dialog.rectification-board::backdrop {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rectification-board__close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
margin-inline-start: auto;
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.rectification-board__close svg { width: 18px; height: 18px; }
|
||||
.rectification-board__close:hover { background: var(--color-canvas-muted); }
|
||||
.rectification-board__close:active { transform: scale(.96); }
|
||||
.rectification-board__close:focus-visible,
|
||||
.rectification-board-peek:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
@@ -258,6 +258,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const keyCounter = useRef(0);
|
||||
const openingStarted = useRef(false);
|
||||
const previousBoardResult = useRef<CandidateResult>(null);
|
||||
const runAbort = useRef<AbortController | null>(null);
|
||||
const [compactBoard, setCompactBoard] = useState(false);
|
||||
const [boardOpen, setBoardOpen] = useState(false);
|
||||
const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null));
|
||||
@@ -284,6 +285,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}, [candidateResult]);
|
||||
|
||||
const closeBoard = useCallback(() => setBoardOpen(false), []);
|
||||
const toggleBoard = useCallback(() => setBoardOpen((current) => !current), []);
|
||||
|
||||
const stopRun = useCallback(() => {
|
||||
runAbort.current?.abort();
|
||||
}, []);
|
||||
|
||||
const setPending = useCallback((value: boolean) => {
|
||||
setBusy(value);
|
||||
@@ -349,10 +355,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
let activityReceiptState = createRectificationActivityReceiptState();
|
||||
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
let completedTurnId: string | undefined;
|
||||
const abortController = new AbortController();
|
||||
runAbort.current = abortController;
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({
|
||||
caseId,
|
||||
sessionId,
|
||||
@@ -418,6 +427,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "streaming" }
|
||||
: message));
|
||||
} else if (event.type === "attempt.reset") {
|
||||
raw = "";
|
||||
activityReceiptState = createRectificationActivityReceiptState();
|
||||
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
completedTurnId = undefined;
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
|
||||
: message));
|
||||
} else if (event.type === "run.failed") {
|
||||
streamFailed = true;
|
||||
} else if (event.type === "error") {
|
||||
@@ -483,7 +500,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
if (result) setCandidateResult(result);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (caught) {
|
||||
const aborted = caught instanceof DOMException
|
||||
? caught.name === "AbortError"
|
||||
: caught instanceof Error && caught.name === "AbortError";
|
||||
if (aborted) {
|
||||
const parsed = parseAgentReply(raw);
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
if (parsed.text) {
|
||||
return [{
|
||||
...message,
|
||||
text: parsed.text,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: false,
|
||||
turnId: completedTurnId,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||||
if (message.renderKey !== assistantRenderKey) return [message];
|
||||
@@ -498,6 +536,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
: [];
|
||||
}));
|
||||
} finally {
|
||||
if (runAbort.current === abortController) runAbort.current = null;
|
||||
setPending(false);
|
||||
}
|
||||
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
|
||||
@@ -650,7 +689,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
ref={workspace}
|
||||
className={`rectification-workspace${compactBoard ? " is-compact" : ""}${boardOpen ? " is-board-open" : ""}`}
|
||||
>
|
||||
<div className="rectification-workspace__chat" inert={compactBoard && boardOpen ? true : undefined}>
|
||||
<div className="rectification-workspace__chat">
|
||||
<section ref={conversation} className="conversation is-rectification" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
|
||||
<div className="message-list">
|
||||
{pendingConsultationQuestion?.trim() && (
|
||||
@@ -745,7 +784,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
result={candidateResult}
|
||||
expanded={boardOpen}
|
||||
boardId={boardId}
|
||||
onOpen={() => setBoardOpen(true)}
|
||||
onOpen={toggleBoard}
|
||||
/>
|
||||
)}
|
||||
<form className="composer" onSubmit={submit}>
|
||||
@@ -765,9 +804,22 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
{busy ? (
|
||||
<Button
|
||||
className="composer-stop"
|
||||
aria-label="停止回答"
|
||||
title="停止当前推理,已生成内容会保留"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={stopRun}
|
||||
>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="composer-footer">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
groupWindowTransitions,
|
||||
rectificationBoardPeekCopy,
|
||||
@@ -63,6 +64,7 @@ function RectificationBoardBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
|
||||
<header className="rectification-board__header">
|
||||
<div className="rectification-board__title-row">
|
||||
<h2 id={titleId}>当前盘面</h2>
|
||||
@@ -71,9 +73,10 @@ function RectificationBoardBody({
|
||||
<button
|
||||
className="rectification-board__close"
|
||||
type="button"
|
||||
aria-label="关闭盘面"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭盘面
|
||||
<X aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -207,48 +210,31 @@ export function RectificationBoard({
|
||||
titleId: string;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const body = (
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const node = dialogRef.current;
|
||||
if (!compact || !node) return;
|
||||
if (open) {
|
||||
if (!node.open) node.showModal();
|
||||
} else if (node.open) {
|
||||
node.close();
|
||||
}
|
||||
}, [compact, open]);
|
||||
if (!compact || !open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [compact, open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact) return;
|
||||
const node = dialogRef.current;
|
||||
if (!node) return;
|
||||
const handleClose = () => onClose();
|
||||
node.addEventListener("close", handleClose);
|
||||
return () => node.removeEventListener("close", handleClose);
|
||||
}, [compact, onClose]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<dialog ref={dialogRef} id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
if (compact && !open) return null;
|
||||
|
||||
return (
|
||||
<aside id={boardId} className="rectification-board" aria-labelledby={titleId}>
|
||||
{body}
|
||||
<aside
|
||||
id={boardId}
|
||||
className={`rectification-board${compact ? " is-sheet" : ""}`}
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<RectificationBoardBody
|
||||
result={result}
|
||||
savedStatus={savedStatus}
|
||||
diff={diff}
|
||||
compact={compact}
|
||||
titleId={titleId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* V10 rectification turn runner over the durable V9 Case/Evidence domains.
|
||||
*
|
||||
* Every model attempt is isolated. Text, tool sets, usage and public receipt
|
||||
* events are committed only from the successful attempt. The browser never
|
||||
* sees partial text from an attempt that is retried or abandoned.
|
||||
* Tool activity and answer tokens are published as they happen so the browser
|
||||
* can render progress. A retried attempt emits `attempt.reset` first so the
|
||||
* client discards the abandoned attempt's visible text. Durable receipts,
|
||||
* billing, and settled history still come only from the successful attempt.
|
||||
*/
|
||||
import type { Agent } from "@mastra/core/agent";
|
||||
import { RectificationAgentAction, resolveRectificationStepBudget } from "@/mastra/agentic-rectification";
|
||||
@@ -316,6 +317,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|| outcome.status === "failed"
|
||||
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal)
|
||||
|| attemptNumber === MAX_ATTEMPTS) break;
|
||||
await emit({ type: "attempt.reset" });
|
||||
}
|
||||
|
||||
const outcome = finalOutcome ?? {
|
||||
@@ -336,7 +338,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (!outcome.ok) {
|
||||
await billing.release();
|
||||
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
|
||||
for (const event of outcome.events) await emit(event);
|
||||
await emit({ type: "run.failed" });
|
||||
return {
|
||||
ok: false,
|
||||
@@ -404,9 +405,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
);
|
||||
await finalizeTurn("completed", outcome.answerText, outcome.attemptId, outcome.attemptId, true);
|
||||
|
||||
for (const event of outcome.events) await emit(event);
|
||||
await emit({ type: "billing.settled" });
|
||||
for (const text of outcome.answerDeltas) await emit({ type: "answer.delta", text });
|
||||
await emit({ type: "run.completed", turnId });
|
||||
|
||||
return {
|
||||
@@ -463,13 +462,18 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
let phaseSequence = 0;
|
||||
|
||||
const recordPhase = async (phase: string, tool: string | null = null) => {
|
||||
if (phase === "answer.delta" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
|
||||
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
|
||||
emittedKeys.add(`${phase}:${tool ?? ""}`);
|
||||
phases.push(phase);
|
||||
phaseSequence += 1;
|
||||
await persistCommittedPhase(phase, tool, attemptId, phaseSequence);
|
||||
};
|
||||
|
||||
const publish = async (event: PublicStreamEvent) => {
|
||||
events.push(event);
|
||||
await emit(event);
|
||||
};
|
||||
|
||||
try {
|
||||
await recordPhase("run.started");
|
||||
await insertV9SkillRunReceipt(
|
||||
@@ -482,7 +486,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
skillPackage,
|
||||
);
|
||||
await recordPhase("skill.bound");
|
||||
events.push({ type: "skill.bound" });
|
||||
await publish({ type: "skill.bound" });
|
||||
emittedKeys.add("event:skill.bound::");
|
||||
|
||||
const result = await (agent as unknown as {
|
||||
@@ -537,7 +541,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const activityEvent = mapStreamChunkToActivity(chunk as never);
|
||||
if (activityEvent) {
|
||||
events.push(activityEvent);
|
||||
await publish(activityEvent);
|
||||
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
|
||||
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
|
||||
}
|
||||
@@ -562,22 +566,26 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (phaseEvent.type === "answer.delta") {
|
||||
const text = phaseEvent.text ?? "";
|
||||
if (text) {
|
||||
const hadVisible = Boolean(answerText.trim());
|
||||
answerText += text;
|
||||
answerDeltas.push(text);
|
||||
if (hadVisible || text.trim()) {
|
||||
await emit(phaseEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
|
||||
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
|
||||
if (!emittedKeys.has(`event:${key}`)) {
|
||||
emittedKeys.add(`event:${key}`);
|
||||
events.push(phaseEvent);
|
||||
await publish(phaseEvent);
|
||||
}
|
||||
if (phaseEvent.type === "case.loaded") {
|
||||
caseLoaded = true;
|
||||
if (!intentClassified) {
|
||||
intentClassified = true;
|
||||
await recordPhase("intent.classified");
|
||||
events.push({ type: "intent.classified" });
|
||||
await publish({ type: "intent.classified" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +619,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
|
||||
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
|
||||
await recordPhase("answer.composed");
|
||||
events.push({ type: "answer.composed" });
|
||||
await publish({ type: "answer.composed" });
|
||||
return {
|
||||
ok: true,
|
||||
status: "completed",
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
|
||||
"answer.composed",
|
||||
"billing.settled",
|
||||
"answer.delta",
|
||||
"attempt.reset",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
] as const;
|
||||
|
||||
Reference in New Issue
Block a user