fix: stabilize streamed answer completion
This commit is contained in:
+22
-37
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user