Files
Jyotisha/frontend/src/components/chat-message-row.tsx
T

185 lines
6.6 KiB
TypeScript

"use client";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { memo, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
type GsapCore = typeof import("gsap")["gsap"];
const useEntryEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
let gsapCore: GsapCore | undefined;
let gsapRequest: Promise<GsapCore> | undefined;
function loadGsap() {
gsapRequest ??= import("gsap").then((module) => {
const core = module.gsap ?? module.default;
gsapCore = (core as GsapCore & { gsap?: GsapCore }).gsap ?? core;
return gsapCore;
});
return gsapRequest;
}
function motionPreferred() {
return typeof window !== "undefined"
&& typeof window.matchMedia === "function"
&& !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
if (motionPreferred()) prefetchOnIdle(loadGsap);
/* Restored after real-device review: R3 removed it on the grounds that it was
the most repeated element in a long transcript, but without it the assistant
turn has no visual owner — in the rectification surface especially, where
narration, a question and its options all stack as plain text. */
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
export const ChatMessageRow = memo(function ChatMessageRow({
message,
showActivity = message.state !== "settled",
vargaSentence,
afterAnswer,
stoppedNotice,
}: Readonly<{
message: ChatMessageView;
showActivity?: boolean;
vargaSentence?: string | null;
afterAnswer?: ReactNode;
stoppedNotice?: string;
}>) {
const messageRow = useRef<HTMLElement>(null);
const smalltalk = message.responseKind === "smalltalk";
// Only a smalltalk reply is quiet. The wait before the server's first event
// used to be quiet too, so nothing showed for the classification round trip
// (BUG-1074); now the timeline's queued row is on screen from the send frame
// and disappears when the run.completed event says the reply was smalltalk.
const quiet = smalltalk;
const assistantLabel = quiet ? "Jyotisha" : message.state === "thinking"
? "Jyotisha 正在分析"
: message.state === "streaming"
? "Jyotisha 正在回答"
: "Jyotisha";
const activityState = message.activity
? ({
received: "working",
"loading-method": "searching",
"chart-calculation": "solving",
"evidence-validation": "working",
"answer-composition": "composing",
} as const)[message.activity.phase]
: message.state === "thinking" ? "working" : "composing";
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
const consultTimeline = quiet ? undefined : message.timeline;
// Rows with a step timeline (every consultation and rectification reply) render
// `ConsultationRunTimeline`; the activity panel is the fallback for rows without one.
const showLiveActivity = !quiet && showActivity && consultTimeline === undefined;
const showThinkingPanel = !quiet && (showLiveActivity || (consultTimeline === undefined && Boolean(message.thinkingText?.trim())));
const showSpokenAnswer = Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
useEntryEffect(() => {
const row = messageRow.current;
if (!row) return;
if (!gsapCore) {
if (motionPreferred()) void loadGsap();
return;
}
const gsap = gsapCore;
const motion = gsap.matchMedia();
motion.add("(prefers-reduced-motion: no-preference)", () => {
gsap.fromTo(row, {
autoAlpha: 0,
y: message.role === "user" ? 8 : 12,
}, {
autoAlpha: 1,
clearProps: "opacity,transform,visibility",
duration: 0.16,
ease: "cubic-bezier(.22, 1, .36, 1)",
y: 0,
});
});
return () => motion.revert();
}, [message.role]);
const thinkingPanel = showThinkingPanel
? (
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
vargaSentence={vargaSentence}
hasAnswer={hasAnswer}
showLive={showLiveActivity}
/>
)
: null;
const spokenAnswer = showSpokenAnswer || (consultTimeline !== undefined && hasAnswer)
? (
<ChatMessageContent
text={message.text}
auditRows={smalltalk ? undefined : message.agentExecutionReceipt?.techniqueAuditTable}
streaming={message.state !== "settled"}
/>
)
: null;
return (
<article
ref={messageRow}
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" ? (
consultTimeline !== undefined ? (
<div className="consultation-thinking-report">
<ConsultationRunTimeline
rows={consultTimeline}
live={showActivity && message.state !== "settled" && !message.settling}
vargaSentence={vargaSentence}
/>
{hasAnswer ? (
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
{afterAnswer}
</section>
) : afterAnswer}
</div>
) : (
<>
{stackedThinkingAndAnswer ? (
<div className="message-stage-and-answer">
{thinkingPanel}
<section className="consultation-report-analysis" aria-label="回复">
{spokenAnswer}
{afterAnswer}
</section>
</div>
) : (
<>
{thinkingPanel}
{spokenAnswer}
{afterAnswer}
</>
)}
</>
)
) : <><p>{message.text}</p>{afterAnswer}</>}
{stoppedNotice ? <p className="message-stopped-notice">{stoppedNotice}</p> : null}
</div>
</div>
</article>
);
});