diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f5017bbd..262526fd 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -753,7 +753,7 @@ or user IDs. | 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 | | 盘位骨架(仅星盘页) | D1 / 非 D1 分盘 / 西洋盘尚未返回 | `VedicChartSvg skeleton` / `WesternWheelSvg skeleton`;统一 `.chart-page-skeleton` | 2.4s ease-in-out,opacity 0.35 ↔ 0.6 | 停止动画,opacity 0.5 | | 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 | -| 流式正文的放字节奏 | 普通对话与校正面的回答正文(`stream-frame-buffer`) | 每帧最多 4 字(≈ 240 字/秒),积压超过 600 字才按 1/12 追赶;流结束后剩余文字按同一节奏在 1.5 s 内写完,最后一帧才算 settled;停止 / 失败 / 断线 / 页面隐藏一帧全放(2026-09-28,BUG-1075) | 逐帧 | 不涉及 CSS 动画,无需降级 | +| 流式正文的放字节奏 | 普通对话与校正面的回答正文(`stream-frame-buffer`) | 每帧最多 4 字(≈ 240 字/秒),积压超过 600 字才按 1/12 追赶;普通对话的流结束后剩余文字按同一节奏在 1.5 s 内写完(`settle({ paced: true })`),最后一帧才算 settled;停止 / 失败 / 断线 / 页面隐藏、以及校正面的流尾仍一帧全放(2026-09-28,BUG-1075) | 逐帧 | 不涉及 CSS 动画,无需降级 | Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 4c42a73e..e48debee 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -808,14 +808,14 @@ export function useConsultationRun(params: ConsultationRunParams) { // run was stopped or failed, in which case what arrived shows at once. const settleFrames = (immediate: boolean) => { if (immediate) { - void frames.settle({ immediate: true }); + void frames.settle(); return; } settleRequested = true; setStreamingReply((current) => ( current && current.sessionId === sessionId ? { ...current, settling: requestId } : current )); - void frames.settle(); + void frames.settle({ paced: true }); }; try { const response = await fetch("/api/consult", { @@ -849,16 +849,18 @@ export function useConsultationRun(params: ConsultationRunParams) { }), signal: controller.signal, }); + // One rejection path for the HTTP body a pre-stream check answers with + // and for the run.failed request_rejected event the stream carries once + // it is open (BUG-1074): same status semantics either way. + const rejectRun = (status: number, message: string, code?: string): never => { + if (status === 401) window.location.assign("/login"); + if (status === 402) openAccountDialog("billing", { source: "insufficient-credits" }); + throw new ConsultationResponseError(status, message, code); + }; if (!response.ok) { const contentType = response.headers.get("content-type") ?? ""; const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() }; - if (response.status === 401) window.location.assign("/login"); - if (response.status === 402) openAccountDialog("billing", { source: "insufficient-credits" }); - throw new ConsultationResponseError( - response.status, - payloadMessage(errorPayload, "服务暂时不可用"), - payloadCode(errorPayload), - ); + rejectRun(response.status, payloadMessage(errorPayload, "服务暂时不可用"), payloadCode(errorPayload)); } if (!response.body) { throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流"); @@ -966,10 +968,7 @@ export function useConsultationRun(params: ConsultationRunParams) { // A rejection the route used to send as a JSON body with an HTTP // status before the stream opened (BUG-1074): same status, same // sentence, same code, so the handling below does not change. - const status = event.status ?? 503; - if (status === 401) window.location.assign("/login"); - if (status === 402) openAccountDialog("billing", { source: "insufficient-credits" }); - throw new ConsultationResponseError(status, payloadMessage({ message: event.message }, "服务暂时不可用"), event.reason); + rejectRun(event.status ?? 503, payloadMessage({ message: event.message }, "服务暂时不可用"), event.reason); } if (event.type === "run.failed") { if (event.code === "answer_truncated") { @@ -1086,7 +1085,7 @@ export function useConsultationRun(params: ConsultationRunParams) { // Whatever arrived before the failure is what gets kept, not just the // part the pacing had released so far. settleRequested = false; - void frames.settle({ immediate: true }); + void frames.settle(); const cancelled = controller.signal.aborted; const ownsInterface = pendingConsultation.current?.requestId === requestId; const partialReply = latestPartialReply; diff --git a/frontend/src/lib/stream-frame-buffer.ts b/frontend/src/lib/stream-frame-buffer.ts index 5c0942e4..d3984a9a 100644 --- a/frontend/src/lib/stream-frame-buffer.ts +++ b/frontend/src/lib/stream-frame-buffer.ts @@ -7,9 +7,10 @@ * happens per animation frame. Answer and thinking text are released at a * steady per-frame pace so a burst of chunks reads as flowing text instead of * a jump, while a large backlog (reconnect, slow tab) catches up in roughly a - * dozen frames. When the stream ends, what is still unreleased is written out - * at the same pace within a short budget instead of appearing in one frame - * (BUG-1075); only a stop, a failure or a hidden document releases at once. + * dozen frames. When a consultation stream ends, what is still unreleased is + * written out at the same pace within a short budget instead of appearing in + * one frame (a paced settle, BUG-1075); a stop, a failure, a hidden document + * and every other caller release at once. * * Pure release arithmetic lives in exported functions so the policy is * testable without a DOM; scheduling is injectable for the same reason. @@ -94,14 +95,15 @@ export type StreamFrameBuffer = Readonly<{ /** Publish meta-only changes (timeline rows, activity) on the next frame. */ touch: () => void; /** - * The stream has ended. By default what is still unreleased is written out - * at the typing pace within STREAM_SETTLE_MAX_FRAMES, and the last frame - * flushes with `settled: true`; the promise resolves on that frame. With - * `immediate` (stop, failure, disconnect) everything is released in one - * synchronous flush, as before BUG-1075. A hidden document always releases + * The stream has ended. By default everything unreleased goes out in one + * synchronous flush (stop, failure, disconnect, and surfaces that merge a + * snapshot right after). With `paced` (the consultation reply, BUG-1075) + * what is left is written out at the typing pace within + * STREAM_SETTLE_MAX_FRAMES, only the last frame flushes with `settled: true`, + * and the promise resolves on that frame. A hidden document always releases * at once. */ - settle: (options?: Readonly<{ immediate?: boolean }>) => Promise; + settle: (options?: Readonly<{ paced?: boolean }>) => Promise; /** Drop everything, including scheduled work, without flushing. */ reset: (meta?: Meta) => void; dispose: () => void; @@ -233,7 +235,7 @@ export function createStreamFrameBuffer( if (settling) { // A second settle while one is writing out: an immediate one takes // over and flushes now; a paced one just waits for the first. - if (!settleOptions?.immediate) { + if (settleOptions?.paced) { return new Promise((resolve) => { const previous = settling!.resolve; settling!.resolve = () => { previous(); resolve(); }; @@ -242,7 +244,7 @@ export function createStreamFrameBuffer( cancelScheduled(); } const pending = pendingChars(releasedAnswer, targetAnswer); - if (settleOptions?.immediate || scheduler.hidden() || pending <= 0) { + if (!settleOptions?.paced || scheduler.hidden() || pending <= 0) { cancelScheduled(); releasedAnswer = targetAnswer; releasedThinking = targetThinking; diff --git a/frontend/tests/chat-navigation-a11y-contract.test.ts b/frontend/tests/chat-navigation-a11y-contract.test.ts index f4cb623c..0a37296e 100644 --- a/frontend/tests/chat-navigation-a11y-contract.test.ts +++ b/frontend/tests/chat-navigation-a11y-contract.test.ts @@ -42,7 +42,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/); assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/); assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/); - assert.match(pageSource, /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/); + // 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/ + // 新值: 同一句在 rejectRun 里,HTTP 体与流内 request_rejected 事件共用(状态从参数来) + // 原因: TASK-consult-first-frame-and-pacing-20260928 D2 / BUG-1074:开流后拒绝改走事件,调用点数不变(下面仍数到 2) + assert.match(pageSource, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/); // And: membership stays in-page; leaving chat is a client-side now. assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/); diff --git a/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx b/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx index 1c018c01..d71f5e42 100644 --- a/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx +++ b/frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx @@ -64,8 +64,9 @@ test("the consultation hook paces the write-out only for a finished run; stop, f // The stream ended normally: paced unless the user stopped it or the server cut it. assert.match(hook, /settleFrames\(controller\.signal\.aborted \|\| Boolean\(truncatedFailure\)\);/); assert.match(hook, /settleFrames\(controller\.signal\.aborted\);/); - // Failure path keeps what arrived, at once. - assert.match(hook, /settleRequested = false;\s*void frames\.settle\(\{ immediate: true \}\);/); + // Failure path keeps what arrived, at once; only the finished consultation reply opts into pacing. + assert.match(hook, /settleRequested = false;\s*void frames\.settle\(\);/); + assert.equal((hook.match(/frames\.settle\(\{ paced: true \}\)/g) ?? []).length, 1); // The paced write-out owns the display through the streaming reply's `settling` id; // the interface completes without waiting for it and without clearing it. assert.match(hook, /setStreamingReply\(\(current\) => \(current\?\.settling \? current : null\)\);/); @@ -165,8 +166,10 @@ test("③ a JSON rejection becomes one run.failed request_rejected with the same // Same precedence the client applied to the HTTP body: recovery, then message, then error. const generation = consultationRejectionEvent(503, { error: "暂时无法生成解读", message: "咨询服务暂时不可用,请稍后再试。", recovery: "稍后重试,或换一个模型继续。" }); assert.equal(generation.type === "run.failed" && generation.message, "稍后重试,或换一个模型继续。"); - assert.equal(consultationRejectionEvent(200, {}).type === "run.failed" && consultationRejectionEvent(200, {}).status, 503); - assert.equal(consultationRejectionEvent(503, null).type === "run.failed" && consultationRejectionEvent(503, null).message, STREAM_FIRST_FALLBACK_MESSAGE); + const outOfRange = consultationRejectionEvent(200, {}); + assert.equal(outOfRange.type === "run.failed" ? outOfRange.status : undefined, 503); + const empty = consultationRejectionEvent(503, null); + assert.equal(empty.type === "run.failed" ? empty.message : undefined, STREAM_FIRST_FALLBACK_MESSAGE); for (const event of [insufficient, full, generation]) consultationAgentPublicEventSchema.parse(event); const response = streamFirstResponse({ @@ -291,8 +294,11 @@ test("the client reads smalltalk and rejections from events, with the same statu assert.doesNotMatch(hook, /x-jyotish-response-kind/); assert.match(hook, /if \(event\.responseKind === "smalltalk"\) \{\s*responseKind = "smalltalk";/); assert.match(hook, /event\.type === "run\.failed" && event\.code === "request_rejected"/); - assert.match(hook, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/); - assert.match(hook, /throw new ConsultationResponseError\(status, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/); + assert.match(hook, /const rejectRun = \(status: number, message: string, code\?: string\): never => \{\s*if \(status === 401\) window\.location\.assign\("\/login"\);\s*if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/); + assert.match(hook, /rejectRun\(response\.status, payloadMessage\(errorPayload, "服务暂时不可用"\), payloadCode\(errorPayload\)\);/); + assert.match(hook, /rejectRun\(event\.status \?\? 503, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/); + // One redirect and one billing prompt for both paths (chat-navigation-a11y-contract counts them). + assert.equal((hook.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g) ?? []).length, 2); // The rollback / notice handling keyed on status and code is unchanged. assert.match(hook, /caught\.code === "session_full"/); assert.match(hook, /const reserveDidNotCommit = caught\.status === 400\s*\|\| caught\.status === 401\s*\|\| caught\.status === 402\s*\|\| caught\.status === 409;/); diff --git a/frontend/tests/stream-frame-buffer.test.ts b/frontend/tests/stream-frame-buffer.test.ts index fda4836b..9ea0e565 100644 --- a/frontend/tests/stream-frame-buffer.test.ts +++ b/frontend/tests/stream-frame-buffer.test.ts @@ -116,11 +116,11 @@ test("many events collapse into one flush per frame and a paced settle writes th assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network"); // 原值: buffer.settle() 同步一帧放完剩余文字(settled: true 立即到) - // 新值: 默认 settle 按打字节奏在 STREAM_SETTLE_MAX_FRAMES 内写完,最后一帧才 settled: true;immediate 才一帧放完 - // 原因: TASK-consult-first-frame-and-pacing-20260928 D3 / BUG-1075:流尾一帧全放让短回答「一下全出来」 + // 新值: settle({ paced: true }) 按打字节奏在 STREAM_SETTLE_MAX_FRAMES 内写完,最后一帧才 settled: true;无参 settle() 仍一帧放完(下一条测试) + // 原因: TASK-consult-first-frame-and-pacing-20260928 D3 / BUG-1075:流尾一帧全放让短回答「一下全出来」;其他调用方(校正面的快照合并)保持旧契约 const pendingAtSettle = answer.length - flushes.at(-1)!.answer.length; let resolved = false; - void buffer.settle().then(() => { resolved = true; }); + void buffer.settle({ paced: true }).then(() => { resolved = true; }); assert.equal(flushes.at(-1)!.settled, false, "a paced settle does not flush synchronously"); let settleFrames = 0; while (fake.scheduledFrames > 0 && settleFrames < 200) { @@ -152,7 +152,7 @@ test("typing pace: an ordinary backlog is capped at four characters a frame, a l assert.equal(frames, 160 / STREAM_RELEASE_MAX_CHARS, "a 160-character lump is typed out over 40 frames, not 12"); }); -test("a paced settle writes 300 leftover characters within the budget; immediate, stop-style settle is one flush", () => { +test("a paced settle writes 300 leftover characters within the budget; the default settle is one flush", () => { const fake = fakeScheduler(); const flushes: StreamFrameSnapshot[] = []; const buffer = createStreamFrameBuffer({ @@ -166,7 +166,7 @@ test("a paced settle writes 300 leftover characters within the budget; immediate const shownBefore = flushes.at(-1)!.answer.length; assert.ok(shownBefore < 300); - void buffer.settle(); + void buffer.settle({ paced: true }); let frames = 0; while (fake.scheduledFrames > 0 && frames < 500) { fake.tick(); @@ -187,7 +187,7 @@ test("a paced settle writes 300 leftover characters within the budget; immediate }); immediate.setAnswer(text); const before = flushes.length; - void immediate.settle({ immediate: true }); + void immediate.settle(); assert.equal(flushes.length, before + 1); assert.equal(flushes.at(-1)!.answer, text); assert.equal(flushes.at(-1)!.settled, true); @@ -205,7 +205,7 @@ test("dispose during a paced settle lets it finish, then silences the buffer", ( const text = "字".repeat(40); buffer.setAnswer(text); fake.tick(); - void buffer.settle(); + void buffer.settle({ paced: true }); buffer.dispose(); let frames = 0; while (fake.scheduledFrames > 0 && frames < 100) {