From 603a7679de91e221802a7f7b0ab7df23c7e5526c Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 23 Jun 2026 19:52:03 +0800 Subject: [PATCH] refactor(web): extract attachment upload into a composable (#1034) Move the image/video attachment state, the file-picker / paste / drag-drop handlers, the upload machinery, the preview lightbox, and the paste-listener + object-URL cleanup lifecycle out of Composer into useAttachmentUpload. The composer keeps handleSubmit / handleSteer (which read the attachments to build the payload) and the hasUpload toolbar flag; it consumes the returned refs and handlers directly. The destructured names match the originals so the template bindings are unchanged. handleSubmit / handleSteer now call the composable's clearAfterSubmit() to revoke object URLs and drop the list. Composer.vue: 1937 -> 1787 lines. Adds unit tests for useAttachmentUpload. No behavior change. --- .changeset/web-extract-attachment-upload.md | 5 + apps/kimi-web/src/components/Composer.vue | 198 ++-------------- .../src/composables/useAttachmentUpload.ts | 218 ++++++++++++++++++ apps/kimi-web/test/attachment-upload.test.ts | 110 +++++++++ 4 files changed, 357 insertions(+), 174 deletions(-) create mode 100644 .changeset/web-extract-attachment-upload.md create mode 100644 apps/kimi-web/src/composables/useAttachmentUpload.ts create mode 100644 apps/kimi-web/test/attachment-upload.test.ts diff --git a/.changeset/web-extract-attachment-upload.md b/.changeset/web-extract-attachment-upload.md new file mode 100644 index 000000000..9c281c27d --- /dev/null +++ b/.changeset/web-extract-attachment-upload.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Extract the composer's image/video attachment handling into a reusable composable. diff --git a/apps/kimi-web/src/components/Composer.vue b/apps/kimi-web/src/components/Composer.vue index a21c741cb..21a3f9bed 100644 --- a/apps/kimi-web/src/components/Composer.vue +++ b/apps/kimi-web/src/components/Composer.vue @@ -13,27 +13,7 @@ import { useInputHistory } from '../composables/useInputHistory'; import { useSlashMenu } from '../composables/useSlashMenu'; import { useMentionMenu } from '../composables/useMentionMenu'; import { useComposerDraft } from '../composables/useComposerDraft'; - -// --------------------------------------------------------------------------- -// Attachment state -// --------------------------------------------------------------------------- - -interface Attachment { - /** Unique local id (used as :key) */ - localId: string; - /** File name */ - name: string; - /** image or video — drives the chip preview and the content-block type. */ - kind: 'image' | 'video'; - /** Object URL for the thumbnail preview */ - previewUrl: string; - /** True while uploading */ - uploading: boolean; - /** Resolved daemon file id (set after upload completes) */ - fileId?: string; - /** True if upload failed */ - error?: boolean; -} +import { useAttachmentUpload } from '../composables/useAttachmentUpload'; // --------------------------------------------------------------------------- // Props & emits @@ -165,160 +145,36 @@ function handleInput(): void { } // --------------------------------------------------------------------------- -// Attachments +// Attachments — see useAttachmentUpload. The composer keeps handleSubmit / +// handleSteer (which read the attachments to build the payload) and the +// `hasUpload` toolbar flag. // --------------------------------------------------------------------------- +const { + attachments, + previewAttachment, + fileInputRef, + isDragOver, + removeAttachment, + openAttachmentPreview, + closeAttachmentPreview, + openFilePicker, + handleFileInputChange, + handleDragOver, + handleDragLeave, + handleDrop, + clearAfterSubmit, +} = useAttachmentUpload({ uploadImage: () => props.uploadImage }); -const attachments = ref([]); -const previewAttachment = ref(null); -const fileInputRef = ref(null); -const isDragOver = ref(false); - -let localIdCounter = 0; -function nextLocalId(): string { - return `att_${++localIdCounter}`; -} - -function revokeAttachment(att: Attachment): void { - try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ } -} - -function mediaKind(mime: string): 'image' | 'video' | null { - if (mime.startsWith('image/')) return 'image'; - if (mime.startsWith('video/')) return 'video'; - return null; -} - -async function addFiles(files: File[]): Promise { - if (!props.uploadImage) return; - const media = files - .map((file) => ({ file, kind: mediaKind(file.type) })) - .filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null); - if (media.length === 0) return; - - for (const { file, kind } of media) { - const localId = nextLocalId(); - const previewUrl = URL.createObjectURL(file); - const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true }; - attachments.value = [...attachments.value, att]; - - // Upload in background; update the attachment when done - props.uploadImage(file, file.name).then((result) => { - attachments.value = attachments.value.map((a) => - a.localId === localId - ? { ...a, uploading: false, fileId: result?.fileId, error: result === null } - : a, - ); - }).catch(() => { - attachments.value = attachments.value.map((a) => - a.localId === localId ? { ...a, uploading: false, error: true } : a, - ); - }); - } -} - -function removeAttachment(localId: string): void { - const att = attachments.value.find((a) => a.localId === localId); - if (previewAttachment.value?.localId === localId) previewAttachment.value = null; - if (att) revokeAttachment(att); - attachments.value = attachments.value.filter((a) => a.localId !== localId); -} - -function openAttachmentPreview(att: Attachment): void { - previewAttachment.value = att; -} - -function closeAttachmentPreview(): void { - previewAttachment.value = null; -} - -function openFilePicker(): void { - fileInputRef.value?.click(); -} - -function handleFileInputChange(e: Event): void { - const input = e.target as HTMLInputElement; - const files = Array.from(input.files ?? []); - void addFiles(files); - // Reset so re-selecting the same file fires change again - input.value = ''; -} - -// Global document-level paste handler — captures Ctrl+V anywhere the composer is mounted. -function handleDocumentPaste(e: ClipboardEvent): void { - if (!props.uploadImage) return; - - const cd = e.clipboardData; - if (!cd) return; - - // Collect image files from both .items and .files to cover all browsers/OS. - const files: File[] = []; - const seenKeys = new Set(); - - const addBlob = (blob: File | Blob, name: string): void => { - const key = `${blob.size}:${blob.type}:${name}`; - if (seenKeys.has(key)) return; - seenKeys.add(key); - const ext = blob.type.split('/')[1] ?? 'png'; - const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`; - files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type })); - }; - - // From DataTransferItemList - for (const item of Array.from(cd.items)) { - if (item.kind === 'file' && mediaKind(item.type)) { - const blob = item.getAsFile(); - if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`); - } - } - - // From FileList (some browsers/OS put screenshots here directly) - for (const file of Array.from(cd.files)) { - if (mediaKind(file.type)) { - addBlob(file, file.name); - } - } - - if (files.length === 0) return; // No media — let normal text paste proceed unmodified. - - e.preventDefault(); - void addFiles(files); -} - -// Drag-drop handlers -function handleDragOver(e: DragEvent): void { - if (!props.uploadImage) return; - const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file'); - if (!hasFiles) return; - e.preventDefault(); - isDragOver.value = true; -} - -function handleDragLeave(): void { - isDragOver.value = false; -} - -function handleDrop(e: DragEvent): void { - isDragOver.value = false; - if (!props.uploadImage) return; - e.preventDefault(); - const files = Array.from(e.dataTransfer?.files ?? []); - void addFiles(files); -} +// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef"). +void fileInputRef; onMounted(() => { - document.addEventListener('paste', handleDocumentPaste); // Fit the box to a restored draft on first render. if (text.value) void nextTick(autosize); }); -// Revoke all object URLs and remove global listener on unmount onUnmounted(() => { - document.removeEventListener('paste', handleDocumentPaste); document.removeEventListener('mousedown', onModesDocClick); - for (const att of attachments.value) { - revokeAttachment(att); - } - previewAttachment.value = null; clearCompositionEndTimer(); }); @@ -369,12 +225,9 @@ function handleSubmit(): void { attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })), }; - // Revoke object URLs for submitted attachments + // Revoke object URLs and drop the submitted attachments. previewAttachment.value = null; - for (const att of attachments.value) { - revokeAttachment(att); - } - attachments.value = []; + clearAfterSubmit(); text.value = ''; slashOpen.value = false; @@ -399,10 +252,7 @@ function handleSteer(): void { text: trimmed, attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })), }; - for (const att of attachments.value) { - revokeAttachment(att); - } - attachments.value = []; + clearAfterSubmit(); history.push(trimmed); text.value = ''; slashOpen.value = false; diff --git a/apps/kimi-web/src/composables/useAttachmentUpload.ts b/apps/kimi-web/src/composables/useAttachmentUpload.ts new file mode 100644 index 000000000..750ead340 --- /dev/null +++ b/apps/kimi-web/src/composables/useAttachmentUpload.ts @@ -0,0 +1,218 @@ +// apps/kimi-web/src/composables/useAttachmentUpload.ts +import { onMounted, onUnmounted, ref } from 'vue'; + +export interface Attachment { + /** Unique local id (used as :key) */ + localId: string; + /** File name */ + name: string; + /** image or video — drives the chip preview and the content-block type. */ + kind: 'image' | 'video'; + /** Object URL for the thumbnail preview */ + previewUrl: string; + /** True while uploading */ + uploading: boolean; + /** Resolved daemon file id (set after upload completes) */ + fileId?: string; + /** True if upload failed */ + error?: boolean; +} + +type UploadImage = ( + file: Blob, + name?: string, +) => Promise<{ fileId: string; name: string; mediaType: string } | null>; + +export interface AttachmentUploadDeps { + /** Upload a blob; resolves to the daemon file id, or null on failure. + Getter so a prop change is picked up. Undefined disables attaching. */ + uploadImage: () => UploadImage | undefined; +} + +/** + * Image/video attachment handling for the composer: file picker, paste, drag & + * drop, the upload machinery, the chip strip, and the preview lightbox. + * + * The composer keeps `handleSubmit`/`handleSteer` (which read the attachments to + * build the payload) and the `hasUpload` toolbar flag; this composable owns the + * attachment state, all the file-input UI handlers, and the paste listener + + * object-URL cleanup lifecycle. + */ +export function useAttachmentUpload(deps: AttachmentUploadDeps) { + const { uploadImage } = deps; + + const attachments = ref([]); + const previewAttachment = ref(null); + const fileInputRef = ref(null); + const isDragOver = ref(false); + + let localIdCounter = 0; + function nextLocalId(): string { + return `att_${++localIdCounter}`; + } + + function revokeAttachment(att: Attachment): void { + try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ } + } + + function mediaKind(mime: string): 'image' | 'video' | null { + if (mime.startsWith('image/')) return 'image'; + if (mime.startsWith('video/')) return 'video'; + return null; + } + + async function addFiles(files: File[]): Promise { + const upload = uploadImage(); + if (!upload) return; + const media = files + .map((file) => ({ file, kind: mediaKind(file.type) })) + .filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null); + if (media.length === 0) return; + + for (const { file, kind } of media) { + const localId = nextLocalId(); + const previewUrl = URL.createObjectURL(file); + const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true }; + attachments.value = [...attachments.value, att]; + + // Upload in background; update the attachment when done. + upload(file, file.name).then((result) => { + attachments.value = attachments.value.map((a) => + a.localId === localId + ? { ...a, uploading: false, fileId: result?.fileId, error: result === null } + : a, + ); + }).catch(() => { + attachments.value = attachments.value.map((a) => + a.localId === localId ? { ...a, uploading: false, error: true } : a, + ); + }); + } + } + + function removeAttachment(localId: string): void { + const att = attachments.value.find((a) => a.localId === localId); + if (previewAttachment.value?.localId === localId) previewAttachment.value = null; + if (att) revokeAttachment(att); + attachments.value = attachments.value.filter((a) => a.localId !== localId); + } + + function openAttachmentPreview(att: Attachment): void { + previewAttachment.value = att; + } + + function closeAttachmentPreview(): void { + previewAttachment.value = null; + } + + function openFilePicker(): void { + fileInputRef.value?.click(); + } + + function handleFileInputChange(e: Event): void { + const input = e.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + void addFiles(files); + // Reset so re-selecting the same file fires change again. + input.value = ''; + } + + // Global document-level paste handler — captures Ctrl+V anywhere the composer is mounted. + function handleDocumentPaste(e: ClipboardEvent): void { + if (!uploadImage()) return; + + const cd = e.clipboardData; + if (!cd) return; + + // Collect image files from both .items and .files to cover all browsers/OS. + const files: File[] = []; + const seenKeys = new Set(); + + const addBlob = (blob: File | Blob, name: string): void => { + const key = `${blob.size}:${blob.type}:${name}`; + if (seenKeys.has(key)) return; + seenKeys.add(key); + const ext = blob.type.split('/')[1] ?? 'png'; + const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`; + files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type })); + }; + + // From DataTransferItemList. + for (const item of Array.from(cd.items)) { + if (item.kind === 'file' && mediaKind(item.type)) { + const blob = item.getAsFile(); + if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`); + } + } + + // From FileList (some browsers/OS put screenshots here directly). + for (const file of Array.from(cd.files)) { + if (mediaKind(file.type)) { + addBlob(file, file.name); + } + } + + if (files.length === 0) return; // No media — let normal text paste proceed unmodified. + + e.preventDefault(); + void addFiles(files); + } + + // Drag-drop handlers. + function handleDragOver(e: DragEvent): void { + if (!uploadImage()) return; + const hasFiles = Array.from(e.dataTransfer?.items ?? []).some((item) => item.kind === 'file'); + if (!hasFiles) return; + e.preventDefault(); + isDragOver.value = true; + } + + function handleDragLeave(): void { + isDragOver.value = false; + } + + function handleDrop(e: DragEvent): void { + isDragOver.value = false; + if (!uploadImage()) return; + e.preventDefault(); + const files = Array.from(e.dataTransfer?.files ?? []); + void addFiles(files); + } + + /** Revoke every object URL and drop all attachments (called after submit/steer). */ + function clearAfterSubmit(): void { + for (const att of attachments.value) { + revokeAttachment(att); + } + attachments.value = []; + } + + onMounted(() => { + document.addEventListener('paste', handleDocumentPaste); + }); + + // Revoke all object URLs and remove the global listener on unmount. + onUnmounted(() => { + document.removeEventListener('paste', handleDocumentPaste); + for (const att of attachments.value) { + revokeAttachment(att); + } + previewAttachment.value = null; + }); + + return { + attachments, + previewAttachment, + fileInputRef, + isDragOver, + removeAttachment, + openAttachmentPreview, + closeAttachmentPreview, + openFilePicker, + handleFileInputChange, + handleDragOver, + handleDragLeave, + handleDrop, + clearAfterSubmit, + }; +} diff --git a/apps/kimi-web/test/attachment-upload.test.ts b/apps/kimi-web/test/attachment-upload.test.ts new file mode 100644 index 000000000..0629c02f1 --- /dev/null +++ b/apps/kimi-web/test/attachment-upload.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAttachmentUpload, type Attachment } from '../src/composables/useAttachmentUpload'; + +// The composable registers its paste listener and cleanup via onMounted / +// onUnmounted. Outside a component (unit test) there is no active instance, so +// Vue would warn; stub the two hooks since these tests don't exercise the +// lifecycle itself. +vi.mock('vue', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, onMounted: vi.fn(), onUnmounted: vi.fn() }; +}); + +type UploadImage = ( + file: Blob, + name?: string, +) => Promise<{ fileId: string; name: string; mediaType: string } | null>; + +function setup(uploadImage?: UploadImage) { + return useAttachmentUpload({ uploadImage: () => uploadImage }); +} + +function imageFile(name: string): File { + return { name, type: 'image/png' } as unknown as File; +} + +function inputEvent(files: File[]): Event { + return { target: { files, value: 'x' } } as unknown as Event; +} + +describe('useAttachmentUpload', () => { + let createObjectURL: ReturnType; + let revokeObjectURL: ReturnType; + + beforeEach(() => { + createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + revokeObjectURL = vi.fn(); + (globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL; + (globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('adds an uploading attachment via the file input', () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f1', name: 'a.png', mediaType: 'image/png' }); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ name: 'a.png', kind: 'image', uploading: true }); + expect(createObjectURL).toHaveBeenCalledOnce(); + }); + + it('ignores non-media files', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'a.txt', type: 'text/plain' } as unknown as File])); + expect(att.attachments.value).toHaveLength(0); + }); + + it('is a no-op when uploadImage is not provided', () => { + const att = setup(undefined); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + expect(att.attachments.value).toHaveLength(0); + }); + + it('removeAttachment drops the entry and revokes its object URL', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + const localId = att.attachments.value[0].localId; + + att.removeAttachment(localId); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('removeAttachment also closes the preview when it shows the removed entry', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png')])); + const added = att.attachments.value[0]; + att.openAttachmentPreview(added); + expect(att.previewAttachment.value).not.toBeNull(); + + att.removeAttachment(added.localId); + expect(att.previewAttachment.value).toBeNull(); + }); + + it('openAttachmentPreview / closeAttachmentPreview toggle the preview', () => { + const att = setup(undefined); + const item: Attachment = { localId: 'x', name: 'a.png', kind: 'image', previewUrl: 'blob:x', uploading: false }; + att.openAttachmentPreview(item); + expect(att.previewAttachment.value?.localId).toBe('x'); + att.closeAttachmentPreview(); + expect(att.previewAttachment.value).toBeNull(); + }); + + it('clearAfterSubmit revokes every object URL and empties the list', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([imageFile('a.png'), imageFile('b.png')])); + expect(att.attachments.value).toHaveLength(2); + + att.clearAfterSubmit(); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).toHaveBeenCalledTimes(2); + }); +});