fix: stabilize streamed answer completion

This commit is contained in:
Jesse_Chen
2026-07-19 15:26:55 +08:00
parent c22dea9979
commit 695a969c57
4 changed files with 141 additions and 37 deletions
+22 -37
View File
@@ -12,6 +12,7 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { BirthTimeRectification } from "@/components/birth-time-rectification";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
import { Button } from "@/components/ui/button";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@@ -41,6 +42,7 @@ import {
previewRectificationJourney,
} from "@/lib/birth-time-guided-preview";
import { keepFocusWithin } from "@/lib/focus-trap";
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
import {
SessionModelPersistenceQueue,
@@ -54,7 +56,7 @@ import {
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
type Theme = ReplyTheme;
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
type Message = ChatMessage;
type Profile = BirthTimeDraft & {
name: string;
countryCode: "CN";
@@ -620,10 +622,6 @@ function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onCha
);
}
function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
function OnboardingChatMessage({ role, text, streaming = false, length = text.length, phraseSafe = false }: { role: Message["role"]; text: string; streaming?: boolean; length?: number; phraseSafe?: boolean }) {
const visibleText = streaming ? text.slice(0, length) : text;
const protectedVisibleText = protectOnboardingPhrases(visibleText);
@@ -824,7 +822,10 @@ export default function Home() {
window.removeEventListener("keydown", closeSessionMenu);
};
}, [sessionMenuId]);
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
const activeSuggestions = activeSession?.messages.reduce<readonly string[]>(
(latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest,
[],
) ?? [];
useEffect(() => {
if (!accountId) {
setChartLibrary([]);
@@ -1949,6 +1950,14 @@ export default function Home() {
);
}
function completeConsultationInterface(requestId: string) {
if (pendingConsultation.current?.requestId !== requestId) return;
pendingConsultation.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
}
async function send(
text: string,
requestedTheme?: Theme,
@@ -2051,10 +2060,7 @@ export default function Home() {
updatedAt: timestamp(),
};
updateSession(sessionId, () => previewSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
pendingConsultation.current = null;
completeConsultationInterface(requestId);
return;
}
@@ -2137,6 +2143,7 @@ export default function Home() {
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
completeConsultationInterface(requestId);
try {
await persistSession(completedSession);
} catch (caught) {
@@ -2200,12 +2207,7 @@ export default function Home() {
}
} finally {
cancellationRequests.current.delete(requestId);
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
}
completeConsultationInterface(requestId);
if (stoppedRequestAwaitingSettlement.current === requestId) {
const persistence = stoppedSessionPersistence.current.get(requestId);
if (persistence) {
@@ -2498,26 +2500,9 @@ export default function Home() {
) : (
<div className="message-list" aria-busy={isLoading}>
<span className="sr-only" aria-live="polite">{isLoading ? "Jyotisha 正在回答" : ""}</span>
{activeSession.messages.map((message, index) => (
<article className={`message message-${message.role}`} key={`${message.role}-${index}`} aria-label={message.role === "assistant" ? "Jyotisha" : "你"}>
{message.role === "assistant" && <AgentAvatar />}
<div className="message-content">
<div className="message-bubble">
{message.role === "assistant" ? <ChatMessageContent text={message.text} /> : <p>{message.text}</p>}
</div>
</div>
</article>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText).map((message) => (
<ChatMessageRow key={message.renderKey} message={message} />
))}
{isLoading && (
<article className="message message-assistant" aria-label={activeStreamingText ? "Jyotisha 正在回答" : "Jyotisha 正在分析"}>
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
{activeStreamingText ? <ChatMessageContent text={activeStreamingText} /> : <div className="thinking"><i /><i /><i /></div>}
</div>
</div>
</article>
)}
{activeError && <p className="error-message">{activeError}</p>}
<div ref={conversationEnd} />
</div>
@@ -2525,10 +2510,10 @@ export default function Home() {
</div>
<div className="composer-wrap">
{activeSuggestions.length > 0 && !isLoading && !cancellationPending && (
{activeSuggestions.length > 0 && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
<button key={question} type="button" disabled={!account || !modelCatalog || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
<button key={question} type="button" disabled={!account || !modelCatalog || isLoading || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
))}
</div>
)}
@@ -0,0 +1,32 @@
import { ChatMessageContent } from "@/components/chat-message-content";
import type { ChatMessageView } from "@/lib/chat-message-view";
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
export function ChatMessageRow({ message }: { readonly message: ChatMessageView }) {
const assistantLabel = message.state === "thinking"
? "Jyotisha 正在分析"
: message.state === "streaming"
? "Jyotisha 正在回答"
: "Jyotisha";
return (
<article
className={`message message-${message.role}`}
aria-label={message.role === "assistant" ? assistantLabel : "你"}
>
{message.role === "assistant" && <AgentAvatar />}
<div className="message-content">
<div className="message-bubble">
{message.role === "assistant" ? (
message.state === "thinking"
? <div className="thinking"><i /><i /><i /></div>
: <ChatMessageContent text={message.text} />
) : <p>{message.text}</p>}
</div>
</div>
</article>
);
}
+33
View File
@@ -0,0 +1,33 @@
export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
readonly suggestions?: readonly string[];
};
export type ChatMessageView = ChatMessage & {
readonly renderKey: string;
readonly state: "settled" | "streaming" | "thinking";
};
export function chatMessageViews(
messages: readonly ChatMessage[],
loading: boolean,
streamingText: string,
): readonly ChatMessageView[] {
const settled = messages.map((message, index) => ({
...message,
renderKey: `message-${index}`,
state: "settled" as const,
}));
if (!loading || messages.at(-1)?.role === "assistant") return settled;
return [
...settled,
{
role: "assistant",
text: streamingText,
renderKey: `message-${messages.length}`,
state: streamingText ? "streaming" : "thinking",
},
];
}
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
] as const;
test("keeps the assistant render identity stable when streaming settles", () => {
// Given: one assistant answer exists first as a transient stream.
const streaming = chatMessageViews(previousMessages, true, "完整答案");
// When: the same answer becomes part of the persisted transcript.
const settled = chatMessageViews([
...previousMessages,
{ role: "assistant", text: "完整答案" },
], false, "");
// Then: React receives the same key and can reuse the existing message shell.
assert.equal(streaming.at(-1)?.renderKey, settled.at(-1)?.renderKey);
assert.equal(streaming.at(-1)?.state, "streaming");
assert.equal(settled.at(-1)?.state, "settled");
});
test("does not duplicate a completed assistant answer while loading state settles", () => {
// Given: the final answer has entered the transcript while request cleanup lags.
const completedMessages = [
...previousMessages,
{ role: "assistant", text: "完整答案" },
] as const;
// When: the render view is derived with the old loading flag still true.
const views = chatMessageViews(completedMessages, true, "完整答案");
// Then: only the persisted answer is rendered.
assert.equal(views.length, completedMessages.length);
assert.equal(views.at(-1)?.state, "settled");
});
test("keeps the suggestion row height stable while an answer streams", () => {
// Given: a completed answer already supplies follow-up suggestions.
const suggestionBlock = pageSource.match(/\{activeSuggestions\.length > 0[\s\S]*?<div className="composer-suggestions"[\s\S]*?<\/div>\n\s*\)\}/);
// When: the suggestion visibility and button state are inspected.
assert.ok(suggestionBlock);
// Then: loading disables the actions without removing their layout slot.
assert.doesNotMatch(suggestionBlock[0].split("<div className=", 1)[0], /!isLoading/);
assert.match(suggestionBlock[0], /disabled=\{[^}]*isLoading/);
});