Skip to content
Source
Sign inSign up
Open History to see the last commit

useComposerAttachmentIntake.ts

453 lines17.6 KB

1import type { ChatComposerProps } from "./ChatComposer";
2import {
3 type ChatFileAttachment,
4 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
7} from "@t3tools/contracts";
8import {
9 type ComposerContextReference,
10 collectInlineContextIds,
11 stripInlineContextReferences,
12} from "~/lib/composerContextReferences";
13import {
14 type ComposerFileAttachment,
15 type ComposerImageAttachment,
16 type DraftId,
17 composerFileMatchesReattachMarker,
18 composerFileNeedsReattach,
19} from "../../composerDraftStore";
20import { type ComposerPromptEditorHandle } from "../ComposerPromptEditorTiptap";
21import { type PendingUserInput } from "../../session-logic";
22import {
23 changeQuestionAttachmentPreparation,
24 useQuestionAttachmentPreparation,
25} from "../../questionAttachments";
26import {
27 classifyComposerAttachmentFile,
28 composerFileAttachmentMimeType,
29 composerImageSourceKey,
30 normalizeComposerImageFileMimeType,
31 rememberComposerImageSource,
32} from "./composerAttachmentFiles";
33import {
34 fileAttachmentTooLargeMessage,
35 formatAttachmentSize,
36} from "@t3tools/client-runtime/state/attachments";
37import { fileContextReference, imageContextReference } from "~/lib/composerContextRecords";
38import { isMacPlatform, randomUUID } from "~/lib/utils";
39import {
40 nextPastedTextFileName,
41 pastedTextDisposition,
42 wouldTextPasteExceedLimit,
43} from "@t3tools/client-runtime/text-paste";
44import {
45 inheritComposerImageThumbnail,
46 prepareImageForAttachment,
47} from "../../lib/imageCompression";
48import { requestConfirmDialog } from "~/confirmDialog";
49import { toastManager } from "../ui/toast";
50import { type RefObject, useRef } from "react";
51import {
52 addPreparingComposerImages,
53 getPreparingComposerImages,
54 isPreparingComposerImage,
55 removePreparingComposerImages,
56} from "./preparingComposerImages";
57​
58/**
59 * Takes files into the draft: validates and reserves slots, compresses images, inserts
60 * their inline chips, folds large pastes into text attachments, and confirms removing a referenced image.
61 */
62export function useComposerAttachmentIntake(input: {
63 readonly activeThreadId: ChatComposerProps["activeThreadId"];
64 readonly activePendingProgress: ChatComposerProps["activePendingProgress"];
65 readonly activePendingIsResponding: boolean;
66 readonly pendingUserInputs: ReadonlyArray<PendingUserInput>;
67 readonly supportsQuestionAttachments: boolean;
68 readonly isLiteralPendingAnswer: boolean;
69 readonly projectSelectionRequired: boolean;
70 readonly questionAttachmentTarget: DraftId | null;
71 readonly attachmentTargetKey: string;
72 readonly fileStagingLimit: number | null;
73 readonly promptRef: ChatComposerProps["promptRef"];
74 readonly composerImagesRef: ChatComposerProps["composerImagesRef"];
75 readonly composerFilesRef: ChatComposerProps["composerFilesRef"];
76 readonly composerEditorRef: RefObject<ComposerPromptEditorHandle | null>;
77 readonly isRevertingCheckpointRef: RefObject<boolean>;
78 readonly setThreadError: ChatComposerProps["setThreadError"];
79 readonly countReservedAttachments: () => number;
80 readonly addComposerImage: (image: ComposerImageAttachment) => string[];
81 readonly addComposerImagesToDraft: (images: ComposerImageAttachment[]) => string[];
82 readonly addComposerFilesToDraft: (files: ComposerFileAttachment[]) => string[];
83 readonly removeComposerImageFromDraft: (imageId: string) => void;
84 readonly insertAttachmentReferences: (
85 references: ReadonlyArray<ComposerContextReference>,
86 selection?: { start: number; end: number },
87 ) => boolean;
88}) {
89 const {
90 activeThreadId,
91 activePendingProgress,
92 activePendingIsResponding,
93 pendingUserInputs,
94 supportsQuestionAttachments,
95 isLiteralPendingAnswer,
96 projectSelectionRequired,
97 questionAttachmentTarget,
98 attachmentTargetKey,
99 fileStagingLimit,
100 promptRef,
101 composerImagesRef,
102 composerFilesRef,
103 composerEditorRef,
104 isRevertingCheckpointRef,
105 setThreadError,
106 countReservedAttachments,
107 addComposerImage,
108 addComposerImagesToDraft,
109 addComposerFilesToDraft,
110 removeComposerImageFromDraft,
111 insertAttachmentReferences,
112 } = input;
113 const pastedTextFileNamesRef = useRef<{ targetKey: string; names: Set<string> }>({
114 targetKey: "",
115 names: new Set(),
116 });
117​
118 const addComposerAttachments = async (
119 files: File[],
120 options?: {
121 readonly source?: ChatFileAttachment["source"];
122 readonly selection?: { start: number; end: number };
123 readonly skipImageInlineChip?: boolean;
124 },
125 ): Promise<boolean> => {
126 if (!activeThreadId || files.length === 0 || isRevertingCheckpointRef.current) return false;
127 if (
128 pendingUserInputs.length > 0 &&
129 (!supportsQuestionAttachments ||
130 activePendingProgress?.activeQuestion?.allowCustomAnswer === false ||
131 activePendingIsResponding)
132 ) {
133 toastManager.add({
134 type: "error",
135 title: "This question cannot accept attachments.",
136 });
137 return false;
138 }
139 // Captured before the awaits below: the user may switch threads while a
140 // large image is being compressed, and the attachments and errors belong
141 // to the thread the paste happened in.
142 const threadId = activeThreadId;
143 const imageAttachmentsGetChips =
144 !options?.skipImageInlineChip &&
145 (options?.selection !== undefined ||
146 pendingUserInputs.length > 0 ||
147 projectSelectionRequired ||
148 stripInlineContextReferences(promptRef.current).trim().length > 0);
149​
150 // Validation happens synchronously so concurrent pastes see each other:
151 // accepted files reserve their attachment slots (as preparing images)
152 // before the first await, keeping the total under the limit.
153 let reservedCount = countReservedAttachments();
154 // A pick that matches a needs-reattach marker replaces it in the draft, so
155 // it must not consume a slot; a draft full of markers would otherwise hit
156 // the capacity error before the replacement path could run.
157 const reattachMarkers = composerFilesRef.current.filter(composerFileNeedsReattach);
158 const replacedReattachMarkerIds = new Set<string>();
159 const acceptedImages: File[] = [];
160 const acceptedFiles: ComposerFileAttachment[] = [];
161 let error: string | null = null;
162 for (const file of files) {
163 const attachmentKind = classifyComposerAttachmentFile(file);
164 const fileMimeType =
165 attachmentKind === "file" ? composerFileAttachmentMimeType(file) : file.type;
166 const matchingReattachMarker =
167 attachmentKind === "file"
168 ? reattachMarkers.find(
169 (marker) =>
170 !replacedReattachMarkerIds.has(marker.id) &&
171 composerFileMatchesReattachMarker(marker, {
172 name: file.name || "file",
173 mimeType: fileMimeType,
174 sizeBytes: file.size,
175 }),
176 )
177 : undefined;
178 if (matchingReattachMarker) {
179 replacedReattachMarkerIds.add(matchingReattachMarker.id);
180 }
181 if (!matchingReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) {
182 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
183 continue;
184 }
185 if (attachmentKind === "unsupported-image") {
186 error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`;
187 continue;
188 }
189 if (attachmentKind === "image") {
190 acceptedImages.push(normalizeComposerImageFileMimeType(file));
191 } else {
192 if (fileStagingLimit === null) {
193 error = "This server does not support file attachments.";
194 continue;
195 }
196 if (file.size <= 0) {
197 error = `'${file.name}' is empty or could not be read.`;
198 continue;
199 }
200 if (file.size > fileStagingLimit) {
201 error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit);
202 continue;
203 }
204 const attachmentFile =
205 file.type === fileMimeType
206 ? file
207 : new File([file], file.name, { type: fileMimeType, lastModified: file.lastModified });
208 acceptedFiles.push({
209 type: "file",
210 id: randomUUID(),
211 name: attachmentFile.name || "file",
212 mimeType: fileMimeType,
213 sizeBytes: attachmentFile.size,
214 file: attachmentFile,
215 ...(options?.source ? { source: options.source } : {}),
216 });
217 }
218 if (!matchingReattachMarker) {
219 reservedCount += 1;
220 }
221 }
222 setThreadError(threadId, error);
223 let insertedAny = false;
224 if (acceptedFiles.length > 0) {
225 const storedIds = new Set(addComposerFilesToDraft(acceptedFiles));
226 const storedFiles = acceptedFiles.filter((file) => storedIds.has(file.id));
227 if (storedFiles.length > 0) {
228 insertedAny = insertAttachmentReferences(
229 storedFiles.map(fileContextReference),
230 options?.selection,
231 );
232 }
233 if (options?.source?._tag === "pasted-text" && storedFiles.length > 0) {
234 const attached = storedFiles[0]!;
235 toastManager.add({
236 type: "info",
237 title: `Large paste attached as ${attached.name}`,
238 description: `${formatAttachmentSize(attached.sizeBytes)} · Use ${
239 isMacPlatform(navigator.platform) ? "⌘⇧V" : "Ctrl+Shift+V"
240 } to keep a large paste inline.`,
241 data: { hideCopyButton: true },
242 });
243 }
244 }
245 if (acceptedImages.length === 0) return insertedAny;
246​
247 // The store drops a repeated image only after compressing it, so a likely repeat prepares
248 // without a tile instead of flashing one. Only the store decides it is a duplicate.
249 const imageSourceKeys = new Set(
250 [...composerImagesRef.current, ...getPreparingComposerImages(attachmentTargetKey)].map(
251 (image) => composerImageSourceKey(image.file),
252 ),
253 );
254 // The draft image reuses its placeholder's id and thumbnail so the strip tile stays exactly
255 // as it is when the compressed file replaces it.
256 const preparingImages = acceptedImages.map((file) => {
257 const sourceKey = composerImageSourceKey(file);
258 const hidden = imageSourceKeys.has(sourceKey);
259 imageSourceKeys.add(sourceKey);
260 return { id: randomUUID(), name: file.name || "image", file, hidden };
261 });
262 addPreparingComposerImages(attachmentTargetKey, preparingImages);
263 if (questionAttachmentTarget)
264 changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length);
265 const compressed: { id: string; file: File }[] = [];
266 // Keyed by image so a failure whose tile was removed (even mid-compression) is not reported.
267 const compressionErrors = new Map<string, string>();
268 for (const { id, file } of preparingImages) {
269 // Removed from the strip while an earlier image was compressing.
270 if (!isPreparingComposerImage(attachmentTargetKey, id)) continue;
271 // A rejection must still reach the cleanup below: a leaked
272 // placeholder would block sending from this draft for good.
273 const result = await prepareImageForAttachment(
274 file,
275 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
276 ).catch(() => ({ ok: false, reason: "unreadable" }) as const);
277 if (!result.ok) {
278 compressionErrors.set(
279 id,
280 result.reason === "unreadable"
281 ? `'${file.name}' could not be read as an image.`
282 : `'${file.name}' is too large to attach, even after compression.`,
283 );
284 continue;
285 }
286 await inheritComposerImageThumbnail(file, result.file);
287 rememberComposerImageSource(file, result.file);
288 compressed.push({ id, file: result.file });
289 }
290 const questionDiscarded =
291 questionAttachmentTarget !== null &&
292 !useQuestionAttachmentPreparation.getState().counts[questionAttachmentTarget];
293 // Same tick as the draft insert below, so the strip swaps placeholders for
294 // real tiles in one render. A placeholder removed meanwhile stays removed.
295 const stillPreparingIds = removePreparingComposerImages(
296 attachmentTargetKey,
297 new Set(preparingImages.map((image) => image.id)),
298 );
299 if (questionAttachmentTarget)
300 changeQuestionAttachmentPreparation(questionAttachmentTarget, -stillPreparingIds.size);
301 if (questionDiscarded) return false;
302 const nextImages: ComposerImageAttachment[] = compressed
303 .filter(({ id }) => stillPreparingIds.has(id))
304 .map(({ id, file }) => ({
305 type: "image",
306 id,
307 name: file.name || "image",
308 mimeType: file.type,
309 sizeBytes: file.size,
310 previewUrl: URL.createObjectURL(file),
311 file,
312 }));
313 const storedImageIds = new Set(
314 nextImages.length === 1 && nextImages[0]
315 ? addComposerImage(nextImages[0])
316 : nextImages.length > 1
317 ? addComposerImagesToDraft(nextImages)
318 : [],
319 );
320 const storedImages = nextImages.filter((image) => storedImageIds.has(image.id));
321 // The draft may be gone by now; revoking a URL the store already revoked is harmless.
322 for (const image of nextImages) {
323 if (!storedImageIds.has(image.id)) URL.revokeObjectURL(image.previewUrl);
324 }
325 if (storedImages.length > 0 && imageAttachmentsGetChips) {
326 insertedAny =
327 insertAttachmentReferences(storedImages.map(imageContextReference)) || insertedAny;
328 }
329 // Only failures are reported here. Success must not pass `null`: by
330 // now other work (a failed send, an overlapping paste) may have set a
331 // thread error this call knows nothing about, and clearing it would
332 // swallow that message.
333 const compressionError = [...compressionErrors]
334 .filter(([id]) => stillPreparingIds.has(id))
335 .map(([, message]) => message)
336 .at(-1);
337 if (compressionError !== undefined) {
338 setThreadError(threadId, compressionError);
339 }
340 return insertedAny;
341 };
342​
343 const removeComposerImage = (imageId: string) => {
344 if (removePreparingComposerImages(attachmentTargetKey, new Set([imageId])).size > 0) {
345 // Its compression finishes in the background and is dropped.
346 if (questionAttachmentTarget)
347 changeQuestionAttachmentPreparation(questionAttachmentTarget, -1);
348 return;
349 }
350 const image = composerImagesRef.current.find((candidate) => candidate.id === imageId);
351 const referenced = collectInlineContextIds(promptRef.current).includes(
352 image ? imageContextReference(image).contextId : "",
353 );
354 if (!referenced) {
355 removeComposerImageFromDraft(imageId);
356 return;
357 }
358 const confirmation = requestConfirmDialog(
359 `Remove ${image?.name ?? "this image"} from the message?\nIt is referenced in your text; removing it also removes every reference.`,
360 { variant: "destructive" },
361 );
362 if (!confirmation) {
363 removeComposerImageFromDraft(imageId);
364 return;
365 }
366 void confirmation.then((confirmed) => {
367 if (confirmed) removeComposerImageFromDraft(imageId);
368 });
369 };
370​
371 const foldPastedText = (
372 plainText: string,
373 bypassAutoAttachment: boolean,
374 selectionOverride?: { start: number; end: number },
375 ): boolean => {
376 if (isLiteralPendingAnswer) return false;
377 const questionCanAttach =
378 pendingUserInputs.length === 0 ||
379 (supportsQuestionAttachments &&
380 activePendingProgress?.activeQuestion?.allowCustomAnswer !== false &&
381 !activePendingIsResponding);
382 const hasAttachmentSlot = countReservedAttachments() < PROVIDER_SEND_TURN_MAX_ATTACHMENTS;
383 const selection = selectionOverride ?? composerEditorRef.current?.readSelectionRange();
384 const wouldExceedInputLimit = wouldTextPasteExceedLimit({
385 valueLength: promptRef.current.length,
386 selection: selection ?? { start: 0, end: 0 },
387 textLength: plainText.length,
388 maxLength: PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
389 });
390 const shouldFold =
391 pastedTextDisposition({
392 text: plainText,
393 bypassAutoAttachment,
394 wouldExceedInputLimit,
395 canAttach: true,
396 }) === "attachment";
397 if (!shouldFold) {
398 return false;
399 }
400​
401 const canStageAttachment =
402 Boolean(activeThreadId) &&
403 !isRevertingCheckpointRef.current &&
404 questionCanAttach &&
405 hasAttachmentSlot;
406 if (!canStageAttachment || fileStagingLimit === null) {
407 if (!wouldExceedInputLimit) {
408 return false;
409 }
410 toastManager.add({
411 type: "error",
412 title: "Pasted text is too large for this message",
413 description: "Remove some text or an attachment, then paste again.",
414 data: { hideCopyButton: true },
415 });
416 return true;
417 }
418​
419 if (pastedTextFileNamesRef.current.targetKey !== attachmentTargetKey) {
420 pastedTextFileNamesRef.current = { targetKey: attachmentTargetKey, names: new Set() };
421 }
422 const reservedNames = pastedTextFileNamesRef.current.names;
423 for (const file of composerFilesRef.current) reservedNames.add(file.name);
424 const foldedFileName = nextPastedTextFileName([...reservedNames]);
425 reservedNames.add(foldedFileName);
426 const foldedFile = new File([plainText], foldedFileName, {
427 type: "text/plain;charset=utf-8",
428 });
429 if (foldedFile.size > fileStagingLimit) {
430 reservedNames.delete(foldedFileName);
431 if (!wouldExceedInputLimit) return false;
432 toastManager.add({
433 type: "error",
434 title: "Pasted text is too large to attach",
435 description: "Reduce the clipboard contents or save a smaller excerpt as a file.",
436 data: { hideCopyButton: true },
437 });
438 return true;
439 }
440​
441 void addComposerAttachments([foldedFile], {
442 source: { _tag: "pasted-text" },
443 ...(selection ? { selection } : {}),
444 });
445 return true;
446 };
447​
448 return {
449 addComposerAttachments,
450 removeComposerImage,
451 foldPastedText,
452 };