mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): preview composer image attachments (#1322)
* feat(web): preview composer image attachments * fix(web): address composer preview review
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
attachment: {
|
||||
name: 'photo.png',
|
||||
status: { type: 'requires-action', reason: 'composer-send' },
|
||||
previewUrl: 'data:image/png;base64,cGhvdG8='
|
||||
} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@assistant-ui/react', () => ({
|
||||
useThreadComposerAttachment: () => mocks.attachment,
|
||||
AttachmentPrimitive: {
|
||||
Root: ({ children, ...props }: ComponentProps<'div'>) => <div {...props}>{children}</div>,
|
||||
Remove: ({ children, ...props }: ComponentProps<'button'> & { children?: ReactNode }) => (
|
||||
<button {...props}>{children}</button>
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
import { AttachmentItem } from './AttachmentItem'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
function renderAttachment() {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
<AttachmentItem />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AttachmentItem', () => {
|
||||
it('renders an image preview with its filename and an always-visible remove button', () => {
|
||||
mocks.attachment = {
|
||||
name: 'photo.png',
|
||||
status: { type: 'requires-action', reason: 'composer-send' },
|
||||
previewUrl: 'data:image/png;base64,cGhvdG8='
|
||||
}
|
||||
|
||||
renderAttachment()
|
||||
|
||||
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
|
||||
'src',
|
||||
'data:image/png;base64,cGhvdG8='
|
||||
)
|
||||
expect(screen.getAllByText('photo.png')).toHaveLength(2)
|
||||
expect(screen.getByRole('button', { name: 'Remove attachment' })).not.toHaveClass('opacity-0')
|
||||
})
|
||||
|
||||
it('keeps the upload indicator on top of an image preview while uploading', () => {
|
||||
mocks.attachment = {
|
||||
name: 'uploading.png',
|
||||
status: { type: 'running', reason: 'uploading', progress: 0 },
|
||||
previewUrl: 'data:image/png;base64,dXBsb2FkaW5n'
|
||||
}
|
||||
|
||||
const { container } = renderAttachment()
|
||||
|
||||
expect(screen.getByRole('img', { name: 'uploading.png' })).toBeInTheDocument()
|
||||
expect(container.querySelector('[class*="bg-black/40"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens the same zoomable image viewer used by sent attachments', () => {
|
||||
mocks.attachment = {
|
||||
name: 'zoom-me.png',
|
||||
status: { type: 'requires-action', reason: 'composer-send' },
|
||||
previewUrl: 'data:image/png;base64,em9vbQ=='
|
||||
}
|
||||
|
||||
renderAttachment()
|
||||
fireEvent.click(screen.getByTitle('Click to zoom'))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'zoom-me.png' })
|
||||
expect(dialog).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('img', { name: 'zoom-me.png' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps non-image attachments in the filename chip layout', () => {
|
||||
mocks.attachment = {
|
||||
name: 'notes.txt',
|
||||
status: { type: 'requires-action', reason: 'composer-send' }
|
||||
}
|
||||
|
||||
renderAttachment()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('notes.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps upload errors in the existing error layout', () => {
|
||||
mocks.attachment = {
|
||||
name: 'broken.png',
|
||||
status: { type: 'incomplete', reason: 'error' },
|
||||
previewUrl: 'data:image/png;base64,YnJva2Vu'
|
||||
}
|
||||
|
||||
renderAttachment()
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Upload failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('broken.png')).toHaveClass('line-through')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AttachmentPrimitive, useThreadComposerAttachment } from '@assistant-ui/react'
|
||||
import type { PendingAttachment } from '@assistant-ui/react'
|
||||
import { ImagePreview } from '@/components/ImagePreview'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
import { useComposerParking } from '@/components/AssistantChat/composerParkingContext'
|
||||
|
||||
type ComposerAttachmentWithPreview = PendingAttachment & {
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none">
|
||||
@@ -32,11 +38,45 @@ function RemoveIcon() {
|
||||
}
|
||||
|
||||
export function AttachmentItem() {
|
||||
const { name, status } = useThreadComposerAttachment()
|
||||
const { name, status, previewUrl } = useThreadComposerAttachment() as ComposerAttachmentWithPreview
|
||||
const isParking = useComposerParking()
|
||||
const isUploading = status.type === 'running'
|
||||
const isError = status.type === 'incomplete'
|
||||
|
||||
if (previewUrl && !isError) {
|
||||
return (
|
||||
<AttachmentPrimitive.Root className="group relative h-16 w-24 overflow-hidden rounded-lg bg-[var(--app-subtle-bg)]">
|
||||
<ImagePreview
|
||||
src={previewUrl}
|
||||
fileName={name}
|
||||
label={name}
|
||||
galleryId="composer-attachments"
|
||||
buttonClassName="group h-full w-full cursor-zoom-in overflow-hidden rounded-lg text-left"
|
||||
imageClassName="h-full w-full object-cover transition-opacity group-hover:opacity-85"
|
||||
caption={(
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1.5 pb-1 pt-3">
|
||||
<span className="block truncate text-[10px] leading-tight text-white">{name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{isUploading ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<Spinner size="sm" label={null} className="text-white" />
|
||||
</div>
|
||||
) : null}
|
||||
{!isParking ? (
|
||||
<AttachmentPrimitive.Remove
|
||||
className="absolute right-1 top-1 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-black/65 text-white transition-colors hover:bg-black/85 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
|
||||
aria-label="Remove attachment"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<RemoveIcon />
|
||||
</AttachmentPrimitive.Remove>
|
||||
) : null}
|
||||
</AttachmentPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AttachmentPrimitive.Root className="flex items-center gap-2 rounded-lg bg-[var(--app-subtle-bg)] px-3 py-2 text-base text-[var(--app-fg)]">
|
||||
{isUploading ? <Spinner size="sm" label={null} className="text-[var(--app-hint)]" /> : null}
|
||||
|
||||
@@ -40,4 +40,22 @@ describe('ImagePreview gallery navigation', () => {
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getByRole('dialog', { name: 'First image' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps named galleries separate from ungrouped previews', () => {
|
||||
render(
|
||||
<>
|
||||
<ImagePreview src="/sent.png" fileName="sent.png" label="Sent image" />
|
||||
<ImagePreview src="/draft-one.png" fileName="draft-one.png" label="First draft" galleryId="composer-attachments" />
|
||||
<ImagePreview src="/draft-two.png" fileName="draft-two.png" label="Second draft" galleryId="composer-attachments" />
|
||||
</>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /first draft/i }))
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'First draft' })
|
||||
expect(within(dialog).getByText('1 / 2')).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Next image' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Second draft' })).toBeInTheDocument()
|
||||
expect(within(screen.getByRole('dialog')).queryByRole('img', { name: 'Sent image' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,7 @@ export function ImagePreview(props: {
|
||||
buttonClassName?: string
|
||||
imageClassName?: string
|
||||
caption?: ReactNode
|
||||
galleryId?: string
|
||||
}) {
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [previewImages, setPreviewImages] = useState<PreviewImage[]>([])
|
||||
@@ -56,7 +57,9 @@ export function ImagePreview(props: {
|
||||
const openViewer = useCallback((event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const galleryId = props.galleryId ?? ''
|
||||
const triggers = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-image-preview-trigger]'))
|
||||
.filter((trigger) => (trigger.dataset.imagePreviewGallery ?? '') === galleryId)
|
||||
const images = triggers.flatMap((trigger): PreviewImage[] => {
|
||||
const image = trigger.querySelector('img')
|
||||
if (!image) return []
|
||||
@@ -70,7 +73,7 @@ export function ImagePreview(props: {
|
||||
setPreviewImages(images)
|
||||
setPreviewIndex(index >= 0 ? index : 0)
|
||||
setViewerOpen(true)
|
||||
}, [])
|
||||
}, [props.galleryId])
|
||||
|
||||
const updateScale = useCallback((next: number | ((current: number) => number)) => {
|
||||
setScale((current) => {
|
||||
@@ -262,6 +265,7 @@ export function ImagePreview(props: {
|
||||
data-image-preview-trigger=""
|
||||
data-image-preview-file-name={props.fileName}
|
||||
data-image-preview-label={props.label}
|
||||
data-image-preview-gallery={props.galleryId ?? ''}
|
||||
className={props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'}
|
||||
title="Click to zoom"
|
||||
>
|
||||
|
||||
@@ -1,65 +1,19 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function stubUploadThenPreviewReadFailure(): void {
|
||||
let readCount = 0
|
||||
async function collectAdditions(
|
||||
file: File,
|
||||
uploadFile = vi.fn(async () => ({ success: true, path: '/uploads/file' }))
|
||||
) {
|
||||
const { createAttachmentAdapter } = await import('./attachmentAdapter')
|
||||
const adapter = createAttachmentAdapter({ uploadFile } as never, 'session-1')
|
||||
const additions = adapter.add({ file }) as AsyncIterable<Record<string, unknown>>
|
||||
const emitted: Record<string, unknown>[] = []
|
||||
|
||||
class FileReaderMock {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: FileReader['onload'] = null
|
||||
onerror: FileReader['onerror'] = null
|
||||
|
||||
readAsDataURL(): void {
|
||||
readCount += 1
|
||||
if (readCount === 1) {
|
||||
this.result = 'data:image/png;base64,dXBsb2Fk'
|
||||
this.onload?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
return
|
||||
}
|
||||
this.onerror?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
}
|
||||
for await (const attachment of additions) {
|
||||
emitted.push(attachment)
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', FileReaderMock)
|
||||
}
|
||||
|
||||
function stubUploadThenDeferredPreviewReadFailure(): {
|
||||
previewStarted: Promise<void>
|
||||
failPreview: () => void
|
||||
} {
|
||||
let readCount = 0
|
||||
let resolvePreviewStarted!: () => void
|
||||
let failPreviewRead: (() => void) | undefined
|
||||
const previewStarted = new Promise<void>((resolve) => {
|
||||
resolvePreviewStarted = resolve
|
||||
})
|
||||
|
||||
class FileReaderMock {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: FileReader['onload'] = null
|
||||
onerror: FileReader['onerror'] = null
|
||||
|
||||
readAsDataURL(): void {
|
||||
readCount += 1
|
||||
if (readCount === 1) {
|
||||
this.result = 'data:image/png;base64,dXBsb2Fk'
|
||||
this.onload?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
return
|
||||
}
|
||||
failPreviewRead = () => {
|
||||
this.onerror?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
}
|
||||
resolvePreviewStarted()
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', FileReaderMock)
|
||||
return {
|
||||
previewStarted,
|
||||
failPreview: () => {
|
||||
if (!failPreviewRead) throw new Error('Preview read did not start')
|
||||
failPreviewRead()
|
||||
}
|
||||
}
|
||||
return { emitted, uploadFile }
|
||||
}
|
||||
|
||||
describe('attachmentAdapter', () => {
|
||||
@@ -109,77 +63,65 @@ describe('attachmentAdapter', () => {
|
||||
})])
|
||||
})
|
||||
|
||||
it('keeps a successful upload ready when image preview generation fails', async () => {
|
||||
stubUploadThenPreviewReadFailure()
|
||||
const { createAttachmentAdapter } = await import('./attachmentAdapter')
|
||||
const uploadFile = vi.fn().mockResolvedValue({ success: true, path: '/uploads/proof.png' })
|
||||
const deleteUploadFile = vi.fn().mockResolvedValue({ success: true })
|
||||
const adapter = createAttachmentAdapter({ uploadFile, deleteUploadFile } as never, 'session-1')
|
||||
it('uploads an image when the initial preview read fails', async () => {
|
||||
let readCount = 0
|
||||
class FileReaderMock {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: FileReader['onload'] = null
|
||||
onerror: FileReader['onerror'] = null
|
||||
|
||||
readAsDataURL(): void {
|
||||
readCount += 1
|
||||
if (readCount === 1) {
|
||||
this.onerror?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
return
|
||||
}
|
||||
this.result = 'data:image/png;base64,dXBsb2Fk'
|
||||
this.onload?.call(this as unknown as FileReader, {} as ProgressEvent<FileReader>)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', FileReaderMock)
|
||||
|
||||
const file = new File(['proof'], 'proof.png', { type: 'image/png' })
|
||||
const states: import('@assistant-ui/react').PendingAttachment[] = []
|
||||
const { emitted, uploadFile } = await collectAdditions(file)
|
||||
|
||||
for await (const state of adapter.add({ file }) as AsyncGenerator<import('@assistant-ui/react').PendingAttachment>) {
|
||||
states.push(state)
|
||||
}
|
||||
|
||||
const ready = states.at(-1) as import('@assistant-ui/react').PendingAttachment & {
|
||||
path?: string
|
||||
previewUrl?: string
|
||||
}
|
||||
expect(uploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(readCount).toBe(2)
|
||||
expect(uploadFile).toHaveBeenCalledWith('session-1', 'proof.png', 'dXBsb2Fk', 'image/png')
|
||||
expect(ready).toMatchObject({
|
||||
type: 'file',
|
||||
name: 'proof.png',
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: { type: 'requires-action', reason: 'composer-send' },
|
||||
path: '/uploads/proof.png',
|
||||
path: '/uploads/file'
|
||||
})
|
||||
expect(ready.id).toEqual(expect.any(String))
|
||||
expect(ready.previewUrl).toBeUndefined()
|
||||
|
||||
const sent = await adapter.send(ready)
|
||||
expect(JSON.parse((sent.content[0] as { text: string }).text)).toEqual({
|
||||
__attachmentMetadata: {
|
||||
id: ready.id,
|
||||
filename: 'proof.png',
|
||||
mimeType: 'image/png',
|
||||
size: file.size,
|
||||
path: '/uploads/proof.png',
|
||||
},
|
||||
})
|
||||
|
||||
await adapter.remove(ready)
|
||||
expect(deleteUploadFile).toHaveBeenCalledWith('session-1', '/uploads/proof.png')
|
||||
expect(emitted.every((attachment) => attachment.previewUrl === undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('cleans up a successful upload when cancellation occurs during preview generation', async () => {
|
||||
const preview = stubUploadThenDeferredPreviewReadFailure()
|
||||
const { createAttachmentAdapter } = await import('./attachmentAdapter')
|
||||
const uploadFile = vi.fn().mockResolvedValue({ success: true, path: '/uploads/proof.png' })
|
||||
const deleteUploadFile = vi.fn().mockResolvedValue({ success: true })
|
||||
const adapter = createAttachmentAdapter({ uploadFile, deleteUploadFile } as never, 'session-1')
|
||||
const file = new File(['proof'], 'proof.png', { type: 'image/png' })
|
||||
const iter = adapter.add({ file }) as AsyncGenerator<import('@assistant-ui/react').PendingAttachment>
|
||||
describe('attachmentAdapter image previews', () => {
|
||||
it('includes the preview URL in every image upload state', async () => {
|
||||
const file = new File(['image'], 'photo.png', { type: 'image/png' })
|
||||
const readSpy = vi.spyOn(FileReader.prototype, 'readAsDataURL')
|
||||
const { emitted } = await collectAdditions(file)
|
||||
|
||||
const initial = await iter.next()
|
||||
const uploading = await iter.next()
|
||||
expect(uploading.value).toMatchObject({
|
||||
status: { type: 'running', reason: 'uploading', progress: 50 },
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted[0]).toMatchObject({
|
||||
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||
status: { type: 'running', progress: 0 }
|
||||
})
|
||||
expect((uploading.value as { path?: string }).path).toBeUndefined()
|
||||
expect(emitted[1]).toMatchObject({
|
||||
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||
status: { type: 'running', progress: 50 }
|
||||
})
|
||||
expect(emitted[2]).toMatchObject({
|
||||
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||
status: { type: 'requires-action' }
|
||||
})
|
||||
expect(readSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const completion = iter.next()
|
||||
await preview.previewStarted
|
||||
expect(uploadFile).toHaveBeenCalledTimes(1)
|
||||
await adapter.remove(uploading.value)
|
||||
preview.failPreview()
|
||||
it('does not generate previews for non-image attachments', async () => {
|
||||
const file = new File(['notes'], 'notes.txt', { type: 'text/plain' })
|
||||
const { emitted } = await collectAdditions(file)
|
||||
|
||||
expect(await completion).toEqual({ done: true, value: undefined })
|
||||
expect([initial.value, uploading.value]).toEqual([
|
||||
expect.objectContaining({ status: { type: 'running', reason: 'uploading', progress: 0 } }),
|
||||
expect.objectContaining({ status: { type: 'running', reason: 'uploading', progress: 50 } }),
|
||||
])
|
||||
expect(deleteUploadFile).toHaveBeenCalledTimes(1)
|
||||
expect(deleteUploadFile).toHaveBeenCalledWith('session-1', '/uploads/proof.png')
|
||||
expect(emitted).toHaveLength(3)
|
||||
expect(emitted.every((attachment) => attachment.previewUrl === undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,16 +50,26 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta
|
||||
const id = randomId()
|
||||
const contentType = file.type || 'application/octet-stream'
|
||||
|
||||
yield {
|
||||
id,
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
contentType,
|
||||
file,
|
||||
status: { type: 'running', reason: 'uploading', progress: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
let previewUrl: string | undefined
|
||||
if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) {
|
||||
try {
|
||||
previewUrl = await fileToDataUrl(file)
|
||||
} catch {
|
||||
// Preview generation is optional; retry the read for the upload payload below.
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
id,
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
contentType,
|
||||
file,
|
||||
status: { type: 'running', reason: 'uploading', progress: 0 },
|
||||
previewUrl
|
||||
} as PendingUploadAttachment
|
||||
|
||||
if (cancelledAttachmentIds.has(id)) {
|
||||
return
|
||||
}
|
||||
@@ -76,7 +86,9 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta
|
||||
return
|
||||
}
|
||||
|
||||
const content = await fileToBase64(file)
|
||||
const content = previewUrl
|
||||
? base64FromDataUrl(previewUrl)
|
||||
: await fileToBase64(file)
|
||||
if (cancelledAttachmentIds.has(id)) {
|
||||
return
|
||||
}
|
||||
@@ -87,8 +99,9 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta
|
||||
name: file.name,
|
||||
contentType,
|
||||
file,
|
||||
status: { type: 'running', reason: 'uploading', progress: 50 }
|
||||
}
|
||||
status: { type: 'running', reason: 'uploading', progress: 50 },
|
||||
previewUrl
|
||||
} as PendingUploadAttachment
|
||||
|
||||
const result = await api.uploadFile(sessionId, file.name, content, contentType)
|
||||
if (cancelledAttachmentIds.has(id)) {
|
||||
@@ -110,21 +123,6 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta
|
||||
return
|
||||
}
|
||||
|
||||
// Generate preview URL for images under 5MB
|
||||
let previewUrl: string | undefined
|
||||
if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) {
|
||||
try {
|
||||
previewUrl = await fileToDataUrl(file)
|
||||
} catch {
|
||||
// Preview generation is optional after the upload has succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelledAttachmentIds.has(id)) {
|
||||
await deleteUpload(result.path)
|
||||
return
|
||||
}
|
||||
|
||||
yield {
|
||||
id,
|
||||
type: 'file',
|
||||
@@ -181,20 +179,16 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta
|
||||
}
|
||||
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
const base64 = result.split(',')[1]
|
||||
if (!base64) {
|
||||
reject(new Error('Failed to read file'))
|
||||
return
|
||||
}
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
return base64FromDataUrl(await fileToDataUrl(file))
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const separatorIndex = dataUrl.indexOf(',')
|
||||
const base64 = separatorIndex >= 0 ? dataUrl.slice(separatorIndex + 1) : ''
|
||||
if (!base64) {
|
||||
throw new Error('Failed to read file')
|
||||
}
|
||||
return base64
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user