Files
Jyotisha/frontend/src/components/chat-transcript.tsx
T
jesse-uxandClaude Code 539d4daee4
Independent Staging Quality Gate / validate (push) Successful in 9m35s
Independent Staging Quality Gate / publish (push) Successful in 3m52s
feat(consult): add free model-classified smalltalk fast path
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>
2026-09-20 12:58:34 +08:00

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 };