返回 JoyAI-Echo
useAttachedImages.ts
根目录 / echo_longvideo / Director_Agent / webui / src / hooks / useAttachedImages.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2
3 import { encodeImage, type EncodeFailure } from "@/lib/imageEncode";
4
5 /** Lifecycle stages of one attachment:
6 *
7 * - ``encoding`` — posted to the Worker; chip shows a spinner
8 * - ``ready`` — ``dataUrl`` available; safe to submit
9 * - ``error`` — validation / decode failure; chip shows inline error
10 */
11 export type AttachmentStatus = "encoding" | "ready" | "error";
12
13 export interface AttachedImage {
14 id: string;
15 file: File;
16 /** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` /
17 * unmount. */
18 previewUrl: string;
19 status: AttachmentStatus;
20 /** Populated when ``status === "ready"``. */
21 dataUrl?: string;
22 /** Size of the final encoded payload (base64 bytes decoded). */
23 encodedBytes?: number;
24 /** Whether the Worker re-encoded the image to hit the size budget. */
25 normalized?: boolean;
26 /** Human-readable validation / encoding error when ``status === "error"``. */
27 error?: AttachmentError;
28 }
29
30 /** Machine-readable rejection reasons surfaced as inline chip errors.
31 *
32 * Callers localize these via the ``composer.imageRejected.*`` i18n table. */
33 export type AttachmentError =
34 | "unsupported_type" // server whitelist excludes this MIME
35 | "too_many_images" // per-message cap (4) reached before enqueue
36 | "magic_mismatch" // extension lies about the real content
37 | "decode_failed" // Worker couldn't decode / re-encode
38 | "too_large" // even after normalization we exceed the budget
39 | "io"; // file read failed at the browser layer
40
41 export const MAX_IMAGES_PER_MESSAGE = 4;
42
43 /** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
44 const ACCEPTED_MIMES: ReadonlySet<string> = new Set([
45 "image/png",
46 "image/jpeg",
47 "image/webp",
48 "image/gif",
49 ]);
50
51 function uuid(): string {
52 if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
53 return (crypto as Crypto).randomUUID();
54 }
55 return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`;
56 }
57
58 function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError {
59 switch (reason) {
60 case "invalid_mime":
61 case "magic_mismatch":
62 return "magic_mismatch";
63 case "too_large_after_normalize":
64 return "too_large";
65 case "io":
66 return "io";
67 case "decode_failed":
68 default:
69 return "decode_failed";
70 }
71 }
72
73 export interface UseAttachedImagesApi {
74 images: AttachedImage[];
75 /** Enqueue new files. Returns the list of rejected files so the caller can
76 * surface inline errors. Files rejected client-side (wrong MIME, limit) are
77 * *not* added to ``images`` — only recoverable encoding failures show up as
78 * error chips. */
79 enqueue: (files: Iterable<File>) => {
80 rejected: Array<{ file: File; reason: AttachmentError }>;
81 };
82 remove: (id: string) => { nextFocusId: string | null };
83 /** Revoke every staged blob URL and drop all attachments. Called after a
84 * successful submit — the optimistic bubble holds onto an independent
85 * ``data:`` URL so tearing down blob previews here is safe. */
86 clear: () => void;
87 /** ``true`` when at least one image is still encoding — Send should wait. */
88 encoding: boolean;
89 /** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */
90 full: boolean;
91 }
92
93 /** Manage the lifecycle of images attached to the Composer.
94 *
95 * Responsibilities in one place:
96 * - validation (MIME whitelist, count cap)
97 * - blob URL creation + revocation
98 * - Worker orchestration
99 * - focus bookkeeping so keyboard delete doesn't strand the user
100 */
101 export function useAttachedImages(): UseAttachedImagesApi {
102 const [images, setImages] = useState<AttachedImage[]>([]);
103 // Ref mirror so ``enqueue`` can see the authoritative length when invoked
104 // multiple times in a single tick (rapid file selection, drag of many
105 // files, paste storms). ``state`` is stale for that second + call.
106 const imagesRef = useRef<AttachedImage[]>([]);
107 imagesRef.current = images;
108
109 const setEntry = useCallback((id: string, patch: Partial<AttachedImage>) => {
110 setImages((prev) => {
111 const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img));
112 imagesRef.current = next;
113 return next;
114 });
115 }, []);
116
117 const enqueue = useCallback(
118 (files: Iterable<File>) => {
119 const rejected: Array<{ file: File; reason: AttachmentError }> = [];
120 const toAdd: AttachedImage[] = [];
121 let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length;
122
123 for (const file of files) {
124 if (!ACCEPTED_MIMES.has(file.type)) {
125 rejected.push({ file, reason: "unsupported_type" });
126 continue;
127 }
128 if (slot <= 0) {
129 rejected.push({ file, reason: "too_many_images" });
130 continue;
131 }
132 slot -= 1;
133 toAdd.push({
134 id: uuid(),
135 file,
136 previewUrl: URL.createObjectURL(file),
137 status: "encoding",
138 });
139 }
140
141 if (toAdd.length > 0) {
142 const next = [...imagesRef.current, ...toAdd];
143 imagesRef.current = next;
144 setImages(next);
145 // Fire the Worker after the commit so chips render first (good INP).
146 for (const entry of toAdd) {
147 queueMicrotask(() => {
148 encodeImage(entry.file).then(
149 (result) => {
150 if (result.ok) {
151 setEntry(entry.id, {
152 status: "ready",
153 dataUrl: result.dataUrl,
154 encodedBytes: result.bytes,
155 normalized: result.normalized,
156 });
157 } else {
158 setEntry(entry.id, {
159 status: "error",
160 error: mapEncodeFailure(result.reason),
161 });
162 }
163 },
164 () => {
165 setEntry(entry.id, {
166 status: "error",
167 error: "decode_failed",
168 });
169 },
170 );
171 });
172 }
173 }
174 return { rejected };
175 },
176 [setEntry],
177 );
178
179 const remove = useCallback((id: string) => {
180 let nextFocusId: string | null = null;
181 setImages((prev) => {
182 const idx = prev.findIndex((img) => img.id === id);
183 if (idx === -1) return prev;
184 const target = prev[idx];
185 try {
186 URL.revokeObjectURL(target.previewUrl);
187 } catch {
188 // No-op: previewUrl revocation is best-effort.
189 }
190 const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)];
191 imagesRef.current = next;
192 // Prefer moving focus to the chip at the same index, else previous.
193 const candidate = next[idx] ?? next[idx - 1];
194 nextFocusId = candidate?.id ?? null;
195 return next;
196 });
197 return { nextFocusId };
198 }, []);
199
200 const clear = useCallback(() => {
201 setImages((prev) => {
202 for (const img of prev) {
203 try {
204 URL.revokeObjectURL(img.previewUrl);
205 } catch {
206 // revoke is best-effort
207 }
208 }
209 imagesRef.current = [];
210 return [];
211 });
212 }, []);
213
214 // Final safety net: revoke any outstanding blob URLs on unmount. Safe
215 // under StrictMode double-invoke because revoked blob URLs are only
216 // referenced from in-hook chip state, which is rebuilt on remount.
217 useEffect(() => {
218 return () => {
219 for (const img of imagesRef.current) {
220 try {
221 URL.revokeObjectURL(img.previewUrl);
222 } catch {
223 // best-effort cleanup on unmount
224 }
225 }
226 };
227 }, []);
228
229 const encoding = images.some((img) => img.status === "encoding");
230 const full = images.length >= MAX_IMAGES_PER_MESSAGE;
231
232 return { images, enqueue, remove, clear, encoding, full };
233 }
234
234 lines TYPESCRIPT