返回 JoyAI-Echo
ThreadComposer.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / components / thread / ThreadComposer.tsx
1 import {
2 useCallback,
3 useEffect,
4 useMemo,
5 useRef,
6 useState,
7 type KeyboardEvent as ReactKeyboardEvent,
8 } from "react";
9 import { ArrowUp, ImageIcon, Loader2, X } from "lucide-react";
10 import { useTranslation } from "react-i18next";
11
12 import { Button } from "@/components/ui/button";
13 import { CountableTextarea } from "@/components/thread/CountableTextarea";
14 import {
15 DEFAULT_DURATION,
16 DurationPicker,
17 } from "@/components/thread/DurationPicker";
18 import {
19 AspectRatioPicker,
20 DEFAULT_VIDEO_SIZE,
21 type VideoSize,
22 } from "@/components/thread/AspectRatioPicker";
23 import { FirstFrameUploader } from "@/components/thread/FirstFrameUploader";
24 import {
25 DEFAULT_STORY_LANGUAGE,
26 LanguagePicker,
27 type StoryLanguage,
28 } from "@/components/thread/LanguagePicker";
29 import { FIRST_FRAME_UPLOAD_ENABLED } from "@/config/features";
30 import {
31 useAttachedImages,
32 type AttachedImage,
33 type AttachmentError,
34 MAX_IMAGES_PER_MESSAGE,
35 } from "@/hooks/useAttachedImages";
36 import type { FirstFrameData } from "@/hooks/useFirstFrameImage";
37 import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
38 import type { SendImage } from "@/hooks/useNanobotStream";
39 import { cn } from "@/lib/utils";
40
41 function formatBytes(n: number): string {
42 if (n < 1024) return `${n} B`;
43 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
44 return `${(n / (1024 * 1024)).toFixed(1)} MB`;
45 }
46
47 interface ThreadComposerProps {
48 /** Return false (or reject) to keep the draft and attachments for retry. */
49 onSend: (
50 content: string,
51 images?: SendImage[],
52 ) => void | boolean | Promise<void | boolean>;
53 disabled?: boolean;
54 /** Blocks send (ArrowUp) only; textarea stays editable when false alongside disabled=false. */
55 sendDisabled?: boolean;
56 placeholder?: string;
57 modelLabel?: string | null;
58 variant?: "thread" | "hero";
59 durationSec?: number;
60 onDurationChange?: (sec: number) => void;
61 videoSize?: VideoSize;
62 onVideoSizeChange?: (size: VideoSize) => void;
63 videoSizeLocked?: boolean;
64 storyLanguage?: StoryLanguage;
65 onStoryLanguageChange?: (language: StoryLanguage) => void;
66 /** Generate the remaining shots automatically after story confirmation. */
67 autoGenerate?: boolean;
68 onAutoGenerateChange?: (enabled: boolean) => void;
69 showAutoGenerateToggle?: boolean;
70 durationOptions?: readonly number[];
71 /** Short-video first-frame uploader (inside composer card, left of textarea). */
72 showFirstFrame?: boolean;
73 firstFrame?: FirstFrameData | null;
74 firstFrameCropping?: boolean;
75 firstFrameLocked?: boolean;
76 onFirstFramePick?: (file: File) => void;
77 onFirstFrameClear?: () => void;
78 onFirstFrameLockedAttempt?: () => void;
79 }
80
81 export function ThreadComposer({
82 onSend,
83 disabled,
84 sendDisabled,
85 placeholder,
86 modelLabel = null,
87 variant = "thread",
88 durationSec,
89 onDurationChange,
90 videoSize = DEFAULT_VIDEO_SIZE,
91 onVideoSizeChange,
92 videoSizeLocked = false,
93 storyLanguage = DEFAULT_STORY_LANGUAGE,
94 onStoryLanguageChange,
95 autoGenerate = false,
96 onAutoGenerateChange,
97 showAutoGenerateToggle = true,
98 durationOptions,
99 showFirstFrame = false,
100 firstFrame = null,
101 firstFrameCropping = false,
102 firstFrameLocked = false,
103 onFirstFramePick,
104 onFirstFrameClear,
105 onFirstFrameLockedAttempt,
106 }: ThreadComposerProps) {
107 const { t } = useTranslation();
108 const [value, setValue] = useState("");
109 const [internalDuration, setInternalDuration] = useState(DEFAULT_DURATION);
110 const [inlineError, setInlineError] = useState<string | null>(null);
111 const [submitting, setSubmitting] = useState(false);
112 const textareaRef = useRef<HTMLTextAreaElement>(null);
113 const chipRefs = useRef(new Map<string, HTMLButtonElement>());
114 const isHero = variant === "hero";
115 const resolvedDuration = durationSec ?? internalDuration;
116 const resolvedPlaceholder =
117 placeholder ?? t("thread.composer.placeholderThread");
118
119 const { images, enqueue, remove, clear, encoding } = useAttachedImages();
120
121 const formatRejection = useCallback(
122 (reason: AttachmentError): string => {
123 const key = `thread.composer.imageRejected.${reason}`;
124 return t(key, { max: MAX_IMAGES_PER_MESSAGE });
125 },
126 [t],
127 );
128
129 const addFiles = useCallback(
130 (files: File[]) => {
131 if (files.length === 0) return;
132 // First-frame mode: drop/paste replaces the single reference image.
133 if (FIRST_FRAME_UPLOAD_ENABLED && showFirstFrame && onFirstFramePick) {
134 onFirstFramePick(files[0]!);
135 setInlineError(null);
136 return;
137 }
138 if (showFirstFrame && !FIRST_FRAME_UPLOAD_ENABLED) {
139 return;
140 }
141 const { rejected } = enqueue(files);
142 if (rejected.length > 0) {
143 setInlineError(formatRejection(rejected[0].reason));
144 } else {
145 setInlineError(null);
146 }
147 },
148 [enqueue, formatRejection, showFirstFrame, onFirstFramePick],
149 );
150
151 const { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop } =
152 useClipboardAndDrop(addFiles);
153
154 useEffect(() => {
155 if (disabled) return;
156 const el = textareaRef.current;
157 if (!el) return;
158 const id = requestAnimationFrame(() => el.focus());
159 return () => cancelAnimationFrame(id);
160 }, [disabled]);
161
162 const readyImages = useMemo(
163 () =>
164 images.filter(
165 (img): img is AttachedImage & { dataUrl: string } =>
166 img.status === "ready" && typeof img.dataUrl === "string",
167 ),
168 [images],
169 );
170 const hasErrors = images.some((img) => img.status === "error");
171
172 const canSend =
173 !disabled &&
174 !sendDisabled &&
175 !submitting &&
176 !encoding &&
177 !hasErrors &&
178 (value.trim().length > 0 || readyImages.length > 0);
179
180 const handleDurationChange = useCallback(
181 (sec: number) => {
182 if (durationSec === undefined) {
183 setInternalDuration(sec);
184 }
185 onDurationChange?.(sec);
186 },
187 [durationSec, onDurationChange],
188 );
189
190 const submit = useCallback(async () => {
191 if (!canSend) return;
192 const trimmed = value.trim();
193 // Share the same normalized ``data:`` URL with both the wire payload and
194 // the optimistic bubble preview: data URLs are self-contained (no blob
195 // lifetime, safe under React StrictMode double-mount) and keep the
196 // bubble in sync with whatever the backend actually sees.
197 const payload: SendImage[] | undefined =
198 readyImages.length > 0
199 ? readyImages.map((img) => ({
200 media: {
201 data_url: img.dataUrl,
202 name: img.file.name,
203 },
204 preview: { url: img.dataUrl, name: img.file.name },
205 }))
206 : undefined;
207 setSubmitting(true);
208 try {
209 const accepted = await onSend(trimmed, payload);
210 if (accepted === false) return;
211 setValue("");
212 setInlineError(null);
213 // Bubble owns the data URL copy; safe to revoke every staged blob
214 // preview here without affecting the rendered message.
215 clear();
216 requestAnimationFrame(() => {
217 const el = textareaRef.current;
218 if (el) {
219 el.style.height = "auto";
220 el.focus();
221 }
222 });
223 } catch {
224 // Keep the complete draft intact so the user can retry.
225 } finally {
226 setSubmitting(false);
227 }
228 }, [canSend, clear, onSend, readyImages, value]);
229
230 const onKeyDown = (_e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
231 // Enter inserts newline; send via submit button only.
232 };
233
234 const removeChip = useCallback(
235 (id: string) => {
236 const { nextFocusId } = remove(id);
237 setInlineError(null);
238 requestAnimationFrame(() => {
239 const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null;
240 if (el) {
241 el.focus();
242 } else {
243 textareaRef.current?.focus();
244 }
245 });
246 },
247 [remove],
248 );
249
250 const onChipKey = useCallback(
251 (id: string) => (e: ReactKeyboardEvent<HTMLButtonElement>) => {
252 if (
253 e.key === "Delete" ||
254 e.key === "Backspace" ||
255 e.key === "Enter" ||
256 e.key === " "
257 ) {
258 e.preventDefault();
259 removeChip(id);
260 }
261 },
262 [removeChip],
263 );
264
265 const textarea = (
266 <CountableTextarea
267 ref={textareaRef}
268 value={value}
269 onChange={setValue}
270 onKeyDown={onKeyDown}
271 onPaste={onPaste}
272 rows={1}
273 placeholder={resolvedPlaceholder}
274 disabled={disabled}
275 aria-label={t("thread.composer.inputAria")}
276 showCount={false}
277 countPosition="bottom-right"
278 formatCount={(count) => t("thread.composer.charCount", { count })}
279 className={cn(
280 "w-full resize-none bg-transparent",
281 showFirstFrame
282 ? "min-h-[72px] px-1 pb-1 pt-1 text-[15px] leading-6"
283 : isHero
284 ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6"
285 : "min-h-[50px] px-4 pb-1.5 pt-3 text-[15px]",
286 "placeholder:text-muted-foreground",
287 "focus:outline-none focus-visible:outline-none",
288 "disabled:cursor-not-allowed",
289 )}
290 />
291 );
292
293 return (
294 <form
295 onSubmit={(e) => {
296 e.preventDefault();
297 void submit();
298 }}
299 onDragEnter={onDragEnter}
300 onDragOver={onDragOver}
301 onDragLeave={onDragLeave}
302 onDrop={onDrop}
303 className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
304 >
305 <div
306 className={cn(
307 "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
308 isHero
309 ? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]"
310 : "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card",
311 "focus-within:ring-1 focus-within:ring-foreground/8",
312 disabled && "opacity-60",
313 isDragging &&
314 "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
315 )}
316 >
317 {images.length > 0 && !showFirstFrame ? (
318 <div
319 className="flex flex-wrap gap-2 px-3 pt-3"
320 aria-label={t("thread.composer.attachImage")}
321 >
322 {images.map((img) => (
323 <AttachmentChip
324 key={img.id}
325 image={img}
326 labelRemove={t("thread.composer.remove")}
327 labelEncoding={t("thread.composer.encoding")}
328 normalizedHint={(orig, current) =>
329 t("thread.composer.normalizedSizeHint", {
330 orig: formatBytes(orig),
331 current: formatBytes(current),
332 })
333 }
334 formatError={formatRejection}
335 onRemove={() => removeChip(img.id)}
336 onKeyDown={onChipKey(img.id)}
337 registerRef={(el) => {
338 if (el) chipRefs.current.set(img.id, el);
339 else chipRefs.current.delete(img.id);
340 }}
341 />
342 ))}
343 </div>
344 ) : null}
345
346 {showFirstFrame ? (
347 <div
348 className={cn(
349 "flex items-start gap-3 px-3.5",
350 "pt-2",
351 )}
352 >
353 <FirstFrameUploader
354 value={firstFrame}
355 cropping={firstFrameCropping}
356 disabled={disabled || !FIRST_FRAME_UPLOAD_ENABLED}
357 clearable={!firstFrameLocked}
358 locked={firstFrameLocked}
359 onLockedAttempt={onFirstFrameLockedAttempt}
360 onPickFile={onFirstFramePick ?? (() => {})}
361 onClear={onFirstFrameClear ?? (() => {})}
362 />
363 <div className="min-w-0 flex-1">{textarea}</div>
364 </div>
365 ) : (
366 textarea
367 )}
368 {inlineError ? (
369 <div
370 role="alert"
371 className={cn(
372 "mx-3 mb-1 rounded-md border border-destructive/40 bg-destructive/8 px-2.5 py-1",
373 "text-[11.5px] font-medium text-destructive",
374 )}
375 >
376 {inlineError}
377 </div>
378 ) : null}
379 <div
380 className={cn(
381 "flex items-center justify-between gap-2",
382 isHero ? "px-3.5 pb-3.5" : "px-3 pb-2",
383 )}
384 >
385 <div className="flex min-w-0 items-center gap-2">
386 {modelLabel ? (
387 <span
388 title={modelLabel}
389 className={cn(
390 "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
391 "border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
392 isHero ? "text-[11px]" : "text-[12px]",
393 )}
394 >
395 <span className="truncate">Echo Director</span>
396 </span>
397 ) : null}
398 <>
399 <AspectRatioPicker
400 value={videoSize}
401 onChange={onVideoSizeChange ?? (() => {})}
402 disabled={disabled || videoSizeLocked}
403 size={isHero ? "hero" : "thread"}
404 />
405 {videoSizeLocked ? (
406 <span className="text-[10px] text-muted-foreground/50">
407 Size locked
408 </span>
409 ) : null}
410 <LanguagePicker
411 value={storyLanguage}
412 onChange={onStoryLanguageChange ?? (() => {})}
413 disabled={disabled}
414 size={isHero ? "hero" : "thread"}
415 />
416 {autoGenerate ? (
417 <DurationPicker
418 value={resolvedDuration}
419 onChange={handleDurationChange}
420 disabled={disabled}
421 size={isHero ? "hero" : "thread"}
422 options={durationOptions}
423 />
424 ) : null}
425 {showAutoGenerateToggle ? (
426 <div className="flex justify-end">
427 <button
428 type="button"
429 role="switch"
430 aria-checked={autoGenerate}
431 disabled={disabled}
432 onClick={() => onAutoGenerateChange?.(!autoGenerate)}
433 className="inline-flex select-none items-center gap-1.5 rounded-full border border-foreground/10 bg-foreground/[0.035] px-2.5 py-1 text-[11px] text-foreground/75 disabled:cursor-not-allowed disabled:opacity-50"
434 >
435 <span
436 className={cn(
437 "relative inline-block h-3.5 w-6 shrink-0 overflow-hidden rounded-full transition-colors",
438 autoGenerate
439 ? "bg-foreground/70"
440 : "bg-foreground/15",
441 "break-keep",
442 )}
443 >
444 <span
445 className={cn(
446 "absolute left-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-background shadow-sm transition-transform",
447 autoGenerate ? "translate-x-2.5" : "translate-x-0",
448 )}
449 />
450 </span>
451 <span className="break-keep">
452 {t("thread.composer.autoGenerate")}
453 </span>
454 </button>
455 </div>
456 ) : null}
457 </>
458 <span className="hidden select-none text-[12px] text-muted-foreground/60 sm:inline">
459 {t("thread.composer.sendHint")}
460 </span>
461 </div>
462 <span className="sm:hidden" aria-hidden />
463 <Button
464 type="submit"
465 size="icon"
466 disabled={!canSend}
467 aria-label={t("thread.composer.send")}
468 className={cn(
469 "rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
470 isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
471 canSend && "hover:scale-[1.03] active:scale-95",
472 )}
473 >
474 {submitting ? (
475 <Loader2
476 className={cn(
477 "animate-spin motion-reduce:animate-none",
478 isHero ? "h-4.5 w-4.5" : "h-4 w-4",
479 )}
480 />
481 ) : (
482 <ArrowUp className={cn(isHero ? "h-4.5 w-4.5" : "h-4 w-4")} />
483 )}
484 </Button>
485 </div>
486 </div>
487 </form>
488 );
489 }
490
491 interface AttachmentChipProps {
492 image: AttachedImage;
493 labelRemove: string;
494 labelEncoding: string;
495 normalizedHint: (origBytes: number, currentBytes: number) => string;
496 formatError: (reason: AttachmentError) => string;
497 onRemove: () => void;
498 onKeyDown: (e: ReactKeyboardEvent<HTMLButtonElement>) => void;
499 registerRef: (el: HTMLButtonElement | null) => void;
500 }
501
502 function AttachmentChip({
503 image,
504 labelRemove,
505 labelEncoding,
506 normalizedHint,
507 formatError,
508 onRemove,
509 onKeyDown,
510 registerRef,
511 }: AttachmentChipProps) {
512 const sizeLabel =
513 image.status === "ready" && image.normalized && image.encodedBytes
514 ? normalizedHint(image.file.size, image.encodedBytes)
515 : formatBytes(image.file.size);
516 const tone =
517 image.status === "error"
518 ? "border-destructive/40 bg-destructive/5 text-destructive"
519 : "border-border/70 bg-muted/60";
520
521 return (
522 <div
523 className={cn(
524 "group relative flex items-center gap-2 rounded-[12px] border px-2 py-1.5",
525 "transition-colors motion-reduce:transition-none",
526 tone,
527 )}
528 data-testid="composer-chip"
529 >
530 <div className="relative h-10 w-10 overflow-hidden rounded-md bg-background">
531 {image.previewUrl ? (
532 <img
533 src={image.previewUrl}
534 alt=""
535 aria-hidden
536 loading="eager"
537 draggable={false}
538 className="h-full w-full object-cover"
539 />
540 ) : (
541 <div className="flex h-full w-full items-center justify-center">
542 <ImageIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
543 </div>
544 )}
545 {image.status === "encoding" ? (
546 <div
547 className="absolute inset-0 flex items-center justify-center bg-background/60"
548 aria-label={labelEncoding}
549 >
550 <Loader2
551 className="h-4 w-4 animate-spin motion-reduce:animate-none"
552 aria-hidden
553 />
554 </div>
555 ) : null}
556 </div>
557 <div className="flex min-w-0 flex-col text-[11.5px] leading-4">
558 <span
559 className="truncate max-w-[14rem] font-medium"
560 title={image.file.name}
561 >
562 {image.file.name}
563 </span>
564 <span className="truncate text-muted-foreground">
565 {image.status === "error" && image.error
566 ? formatError(image.error)
567 : sizeLabel}
568 </span>
569 </div>
570 <button
571 type="button"
572 ref={registerRef}
573 onClick={onRemove}
574 onKeyDown={onKeyDown}
575 aria-label={labelRemove}
576 className={cn(
577 "ml-1 grid h-5 w-5 flex-none place-items-center rounded-full",
578 "text-muted-foreground/80 hover:bg-foreground/8 hover:text-foreground",
579 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30",
580 )}
581 >
582 <X className="h-3.5 w-3.5" aria-hidden />
583 </button>
584 </div>
585 );
586 }
587
587 lines Plain Text