Keep full consultation tool contracts unchanged. Persist short replies and refund the original reservation atomically while recording actual model usage. Verify Linux frontend 3566/3566, database 40/40, Static home and gzip +0.0493%. Co-Authored-By: Claude Code <noreply@anthropic.com>
315 lines
9.4 KiB
TypeScript
315 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { ChatMessageRow } from "@/components/chat-message-row";
|
|
import {
|
|
ChatMessageActions,
|
|
toggleChatMessageFeedback,
|
|
type ChatMessageFeedback,
|
|
} from "@/components/chat-message-actions";
|
|
import { ConversationFollowUps } from "@/components/conversation-follow-ups";
|
|
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
|
|
import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups";
|
|
import type { ConsultationDomain } from "@/lib/consultation-domain-registry";
|
|
import type { AgentActivityView, ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
|
import {
|
|
chatMessageViews,
|
|
latestAssistantView,
|
|
settledChatMessageViews,
|
|
} from "@/lib/chat-message-view";
|
|
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
|
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
|
|
import {
|
|
noteLatestEntryMount,
|
|
noteSettledListRender,
|
|
noteSettledRowRender,
|
|
noteStreamingRowRender,
|
|
noteUnsplitListRender,
|
|
} from "@/lib/home-streaming-render-probe";
|
|
import { memo, useEffect, type MutableRefObject } from "react";
|
|
|
|
export type ChatTranscriptActions = Readonly<{
|
|
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
|
|
onCopy: (feedbackKey: string, text: string) => void;
|
|
onRegenerate: (renderKey: string) => void;
|
|
onFollowUp: (question: string) => void;
|
|
}>;
|
|
|
|
export type ChatTranscriptProps = Readonly<{
|
|
messages: readonly ChatMessage[];
|
|
loading: boolean;
|
|
streamingText: string;
|
|
streamingActivity?: AgentActivityView;
|
|
streamingThinking?: string;
|
|
streamingSections?: readonly PublicThinkingSection[];
|
|
streamingTimeline?: readonly ConsultationTimelineRow[];
|
|
streamingResponseKind?: "smalltalk";
|
|
sessionId: string;
|
|
sessionType: "consultation" | "birth_time_rectification";
|
|
theme?: ConsultationDomain;
|
|
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
|
|
copiedMessageKey: string | null;
|
|
cancellationPending: boolean;
|
|
productEntrypointsDisabled: boolean;
|
|
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
|
}>;
|
|
|
|
type MessageEntryProps = Readonly<{
|
|
message: ChatMessageView;
|
|
views: readonly ChatMessageView[];
|
|
index: number;
|
|
sessionId: string;
|
|
sessionType: "consultation" | "birth_time_rectification";
|
|
theme?: ConsultationDomain;
|
|
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
|
|
copiedMessageKey: string | null;
|
|
loading: boolean;
|
|
cancellationPending: boolean;
|
|
productEntrypointsDisabled: boolean;
|
|
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
|
}>;
|
|
|
|
/**
|
|
* One transcript row. The same component renders history rows, the row that is
|
|
* still streaming and the row that just settled: actions and follow-ups appear
|
|
* once the view is settled, nothing remounts when it does.
|
|
*/
|
|
function MessageEntry({
|
|
message,
|
|
views,
|
|
index,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
loading,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: MessageEntryProps) {
|
|
const showActions = message.role === "assistant"
|
|
&& message.state === "settled"
|
|
&& Boolean(message.text);
|
|
const feedbackKey = `${sessionId}:${message.renderKey}`;
|
|
const latestRegeneratableKey = !loading && !cancellationPending
|
|
? [...views].reverse().find((item) => (
|
|
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
|
|
))?.renderKey
|
|
: undefined;
|
|
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
|
|
const followUps = showActions
|
|
&& message.renderKey === latestRegeneratableKey
|
|
&& sessionType === "consultation"
|
|
&& previousQuestion
|
|
? deriveConsultationFollowUps({
|
|
question: previousQuestion,
|
|
answer: message.text,
|
|
theme,
|
|
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
|
|
})
|
|
: [];
|
|
return (
|
|
<div className="message-entry">
|
|
<ChatMessageRow message={message} />
|
|
{showActions && (
|
|
<ChatMessageActions
|
|
feedback={messageFeedback[feedbackKey]}
|
|
copied={copiedMessageKey === feedbackKey}
|
|
canRegenerate={message.renderKey === latestRegeneratableKey}
|
|
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
|
|
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
|
|
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
|
|
/>
|
|
)}
|
|
<ConversationFollowUps
|
|
questions={followUps}
|
|
disabled={productEntrypointsDisabled}
|
|
onSelect={(question) => actionsRef.current.onFollowUp(question)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const HistoryMessageEntry = memo(function HistoryMessageEntry(props: MessageEntryProps) {
|
|
noteSettledRowRender();
|
|
return <MessageEntry {...props} />;
|
|
});
|
|
|
|
/**
|
|
* History rows: every settled message except the trailing assistant reply,
|
|
* which `LatestAssistantEntry` owns so it keeps one identity from the first
|
|
* streamed token through settlement.
|
|
*/
|
|
export const SettledMessageList = memo(function SettledMessageList({
|
|
messages,
|
|
excludeLatestAssistant = false,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
loading,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline"> & {
|
|
excludeLatestAssistant?: boolean;
|
|
}) {
|
|
noteSettledListRender();
|
|
const views = settledChatMessageViews(messages);
|
|
const history = excludeLatestAssistant && views.at(-1)?.role === "assistant" ? views.slice(0, -1) : views;
|
|
return (
|
|
<>
|
|
{history.map((message, index) => (
|
|
<HistoryMessageEntry
|
|
key={message.renderKey}
|
|
message={message}
|
|
views={views}
|
|
index={index}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
/** The trailing assistant reply, streaming or settled, under one React identity. */
|
|
export const LatestAssistantEntry = memo(function LatestAssistantEntry(props: MessageEntryProps) {
|
|
noteStreamingRowRender();
|
|
useEffect(() => {
|
|
noteLatestEntryMount();
|
|
}, []);
|
|
return <MessageEntry {...props} />;
|
|
});
|
|
|
|
export const ChatTranscript = memo(function ChatTranscript({
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
streamingResponseKind,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: ChatTranscriptProps) {
|
|
const latest = latestAssistantView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
streamingResponseKind,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<SettledMessageList
|
|
messages={messages}
|
|
excludeLatestAssistant
|
|
loading={loading}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
{latest ? (
|
|
<LatestAssistantEntry
|
|
key={latest.view.renderKey}
|
|
message={latest.view}
|
|
views={latest.views}
|
|
index={latest.views.length - 1}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
});
|
|
|
|
export function UnsplitChatTranscript({
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
streamingResponseKind,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: ChatTranscriptProps) {
|
|
noteUnsplitListRender();
|
|
const views = chatMessageViews(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
streamingResponseKind,
|
|
);
|
|
return (
|
|
<>
|
|
{views.map((message, index) => {
|
|
noteSettledRowRender();
|
|
if (message.state !== "settled") noteStreamingRowRender();
|
|
return (
|
|
<MessageEntry
|
|
key={message.renderKey}
|
|
message={message}
|
|
views={views}
|
|
index={index}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export { toggleChatMessageFeedback };
|