返回 JoyAI-Echo
MessageBubble.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / MessageBubble.tsx
1 import { useState } from "react";
2 import { ImageIcon } from "lucide-react";
3 import { useTranslation } from "react-i18next";
4
5 import { ImageLightbox } from "@/components/ImageLightbox";
6 import { MarkdownText } from "@/components/MarkdownText";
7 import { QuestionCards } from "@/components/thread/QuestionCards";
8 import { MemoryReviewCard } from "@/components/thread/MemoryReviewCard";
9 import type { NextContinuousControl } from "@/components/thread/MemoryReviewCard";
10 import { cn } from "@/lib/utils";
11 import type {
12 MemoryReview,
13 MemoryWorkspaceAsset,
14 ShotMemoryAssetCreate,
15 UIImage,
16 UIMessage,
17 UIVideo,
18 } from "@/lib/types";
19
20 interface MessageBubbleProps {
21 message: UIMessage;
22 onAnswerQuestion?: (messageId: string, cardId: string, value: string) => void;
23 /** Agent turn finished (stream_end resuming:false); cards become tappable. */
24 questionsReady?: boolean;
25 onMemoryReviewAction?: (
26 review: MemoryReview,
27 action: "approve" | "reselect" | "manual_select" | "select_mode",
28 memoryId?: string,
29 timestampSec?: number,
30 selectionMode?: "manual" | "vlm",
31 retainedMemoryIds?: string[],
32 ) => void | Promise<void>;
33 memoryAssets?: MemoryWorkspaceAsset[];
34 onCreateMemoryAsset?: (
35 shotId: number,
36 asset: ShotMemoryAssetCreate,
37 ) => Promise<void>;
38 /** Resolve Shot a Memory → Shot a+1 continuous control; null hides switch. */
39 getNextContinuous?: (
40 review: MemoryReview,
41 ) => NextContinuousControl | null;
42 }
43
44 /**
45 * Render a single message. Following agent-chat-ui: user turns are a rounded
46 * "pill" right-aligned with a muted fill; assistant turns render as bare
47 * markdown so prose/code read like a document rather than a chat bubble.
48 * Each turn fades+slides in for a touch of motion polish.
49 *
50 * Trace rows are not rendered in the thread UI.
51 */
52 export function MessageBubble({
53 message,
54 onAnswerQuestion,
55 questionsReady = true,
56 onMemoryReviewAction,
57 memoryAssets = [],
58 onCreateMemoryAsset,
59 getNextContinuous,
60 }: MessageBubbleProps) {
61 const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
62 if (message.kind === "trace") {
63 return null;
64 }
65
66 if (message.role === "user") {
67 const images = message.images ?? [];
68 const hasImages = images.length > 0;
69 const hasText = message.content.trim().length > 0;
70 return (
71 <div
72 className={cn(
73 "group ml-auto flex max-w-[min(85%,36rem)] flex-col items-end gap-1.5",
74 baseAnim,
75 )}
76 >
77 {hasImages ? <UserImages images={images} /> : null}
78 {hasText ? (
79 <p
80 className={cn(
81 "ml-auto w-fit rounded-[18px] bg-secondary/70 px-4 py-2",
82 "text-left text-[15px]/[1.8] whitespace-pre-wrap break-words",
83 )}
84 >
85 {message.content}
86 </p>
87 ) : null}
88 </div>
89 );
90 }
91
92 const empty = message.content.trim().length === 0;
93 const showTypingDots = message.turnWaiting === true && empty;
94 const videos = message.videos ?? [];
95 const hasVideos = videos.length > 0;
96 const hasQuestions = (message.questions?.length ?? 0) > 0;
97
98 // message.questions = [
99 // {
100 // id: "1",
101 // question: "What is the capital of France?",
102 // options: [{ label: "Paris" }, { label: "London" }, { label: "Berlin" }],
103 // allowCustom: false,
104 // answered: null,
105 // },
106 // ];
107
108 return (
109 <div
110 className={cn("w-full text-sm", baseAnim)}
111 style={{ lineHeight: "var(--cjk-line-height)" }}
112 >
113 {showTypingDots ? (
114 <TypingDots />
115 ) : (
116 <>
117 {!empty ? <MarkdownText>{message.content}</MarkdownText> : null}
118 {hasVideos ? <AssistantVideos videos={videos} /> : null}
119 {hasQuestions && !message.isStreaming ? (
120 <QuestionCards
121 cards={message.questions!}
122 disabled={!questionsReady}
123 onAnswer={(cardId, value) =>
124 onAnswerQuestion?.(message.id, cardId, value)
125 }
126 />
127 ) : null}
128 {message.memoryReview ? (
129 <MemoryReviewCard
130 review={message.memoryReview}
131 memoryAssets={memoryAssets}
132 onCreateMemoryAsset={onCreateMemoryAsset}
133 nextContinuous={
134 getNextContinuous?.(message.memoryReview) ?? null
135 }
136 onApprove={
137 onMemoryReviewAction
138 ? (retainedMemoryIds) =>
139 onMemoryReviewAction(
140 message.memoryReview!,
141 "approve",
142 undefined,
143 undefined,
144 undefined,
145 retainedMemoryIds,
146 )
147 : undefined
148 }
149 onReselect={
150 onMemoryReviewAction
151 ? (memoryId) =>
152 onMemoryReviewAction(
153 message.memoryReview!,
154 "reselect",
155 memoryId,
156 )
157 : undefined
158 }
159 onManualSelect={
160 onMemoryReviewAction
161 ? (memoryId, timestampSec) =>
162 onMemoryReviewAction(
163 message.memoryReview!,
164 "manual_select",
165 memoryId,
166 timestampSec,
167 )
168 : undefined
169 }
170 onSelectMode={
171 onMemoryReviewAction
172 ? (selectionMode) =>
173 onMemoryReviewAction(
174 message.memoryReview!,
175 "select_mode",
176 undefined,
177 undefined,
178 selectionMode,
179 )
180 : undefined
181 }
182 />
183 ) : null}
184 {message.isStreaming && !empty ? <StreamCursor /> : null}
185 </>
186 )}
187 </div>
188 );
189 }
190
191 /**
192 * Right-aligned preview row for images attached to a user turn.
193 *
194 * Visual follows agent-chat-ui: a single wrapping row of fixed-size square
195 * thumbnails that stay modest next to the text pill regardless of how many
196 * images are attached.
197 *
198 * The URL is expected to be a self-contained ``data:`` URL (the Composer
199 * hands the normalized base64 payload to the optimistic bubble so that the
200 * preview survives React StrictMode double-mount — blob URLs would be
201 * revoked by the Composer's cleanup before remount). Historical replays
202 * have no URL (the backend strips data URLs before persisting), so we
203 * render a labelled placeholder tile instead of a broken ``<img>``.
204 */
205 function UserImages({ images }: { images: UIImage[] }) {
206 const { t } = useTranslation();
207 // Only real-URL images can open in the lightbox; historical-replay
208 // placeholders (no URL) have nothing to zoom into.
209 const viewable = images
210 .map((img, i) => ({ img, i }))
211 .filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
212 const viewableImages = viewable.map(({ img }) => img);
213 const originalToViewable = new Map<number, number>(
214 viewable.map(({ i }, v) => [i, v]),
215 );
216
217 const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
218
219 return (
220 <>
221 <div className="ml-auto flex flex-wrap items-end justify-end gap-2">
222 {images.map((img, i) => (
223 <UserImageCell
224 key={`${img.url ?? "placeholder"}-${i}`}
225 image={img}
226 placeholderLabel={t("message.imageAttachment")}
227 openLabel={t("lightbox.open")}
228 onOpen={
229 originalToViewable.has(i)
230 ? () => setLightboxIndex(originalToViewable.get(i)!)
231 : undefined
232 }
233 />
234 ))}
235 </div>
236 <ImageLightbox
237 images={viewableImages}
238 index={lightboxIndex}
239 onIndexChange={setLightboxIndex}
240 onOpenChange={(open) => {
241 if (!open) setLightboxIndex(null);
242 }}
243 />
244 </>
245 );
246 }
247
248 function UserImageCell({
249 image,
250 placeholderLabel,
251 openLabel,
252 onOpen,
253 }: {
254 image: UIImage;
255 placeholderLabel: string;
256 openLabel: string;
257 onOpen?: () => void;
258 }) {
259 const hasUrl = typeof image.url === "string" && image.url.length > 0;
260 const tileClasses = cn(
261 "relative h-24 w-24 overflow-hidden rounded-[14px] border border-border/60 bg-muted/40",
262 "shadow-[0_6px_18px_-14px_rgba(0,0,0,0.45)]",
263 );
264
265 if (hasUrl && onOpen) {
266 return (
267 <button
268 type="button"
269 onClick={onOpen}
270 aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
271 title={image.name ?? undefined}
272 className={cn(
273 tileClasses,
274 "cursor-zoom-in transition-transform duration-150 motion-reduce:transition-none",
275 "hover:scale-[1.02] hover:ring-2 hover:ring-primary/30",
276 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
277 )}
278 >
279 <img
280 src={image.url}
281 alt={image.name ?? ""}
282 loading="lazy"
283 decoding="async"
284 draggable={false}
285 className="h-full w-full object-cover"
286 />
287 </button>
288 );
289 }
290
291 return (
292 <div className={tileClasses} title={image.name ?? undefined}>
293 <div
294 className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-[11px] text-muted-foreground"
295 aria-label={placeholderLabel}
296 >
297 <ImageIcon className="h-4 w-4 flex-none" aria-hidden />
298 <span className="line-clamp-2 text-center leading-tight">
299 {image.name ?? placeholderLabel}
300 </span>
301 </div>
302 </div>
303 );
304 }
305
306 function AssistantVideos({ videos }: { videos: UIVideo[] }) {
307 return (
308 <div className="mt-3 flex w-full flex-col gap-3">
309 {videos.map((video, index) => (
310 <figure
311 key={`${video.url}-${index}`}
312 className={cn(
313 "overflow-hidden rounded-2xl border border-border/60 bg-card/80",
314 "shadow-[0_18px_45px_-32px_rgba(0,0,0,0.5)]",
315 )}
316 >
317 <video
318 controls
319 playsInline
320 preload="metadata"
321 src={video.url}
322 className="block max-h-[26rem] w-full bg-black"
323 />
324 {video.name ? (
325 <figcaption className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
326 {video.name}
327 </figcaption>
328 ) : null}
329 </figure>
330 ))}
331 </div>
332 );
333 }
334
335 /** Blinking cursor appended at the end of streaming text. */
336 function StreamCursor() {
337 const { t } = useTranslation();
338 return (
339 <span
340 aria-label={t("message.streaming")}
341 className={cn(
342 "ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
343 "rounded-sm bg-foreground/70 animate-pulse",
344 )}
345 />
346 );
347 }
348
349 /** Pre-token-arrival placeholder: three bouncing dots. */
350 function TypingDots() {
351 const { t } = useTranslation();
352 const label = t("message.assistantTyping");
353 return (
354 <div
355 aria-label={label}
356 className="flex items-center gap-3 py-2 text-xs text-muted-foreground"
357 >
358 <style>{`
359 @keyframes thinking-shimmer {
360 0% { background-position: -200% 0; }
361 100% { background-position: 200% 0; }
362 }
363 `}</style>
364 <span className="shrink-0">{label}</span>
365 <div
366 className="h-1.5 min-w-20 max-w-44 flex-1 rounded-full"
367 style={{
368 background:
369 "linear-gradient(90deg, transparent 0%, hsl(var(--foreground) / 0.03) 15%, hsl(var(--foreground) / 0.10) 50%, hsl(var(--foreground) / 0.03) 85%, transparent 100%)",
370 backgroundSize: "200% 100%",
371 animation: "thinking-shimmer 1.8s ease-in-out infinite",
372 }}
373 />
374 </div>
375 );
376 }
377
377 lines Plain Text