Add web multimodal image attachments
This commit is contained in:
@@ -132,25 +132,25 @@ describe('conversation slot inject surface', () => {
|
||||
const { instance, injected } = b.conversationSurface(ROOT)
|
||||
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
|
||||
instance.actions.setDraft(' ')
|
||||
injected.send(' ', 'queue')
|
||||
injected.send(' ', [], 'queue')
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(instance.store.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
instance.actions.setDraft('hello')
|
||||
injected.send('hello', 'queue')
|
||||
injected.send('hello', [], 'queue')
|
||||
expect(instance.store.getSnapshot().draft).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
instance.actions.setDraft('retry me')
|
||||
injected.send('retry me', 'queue')
|
||||
injected.send('retry me', [], 'queue')
|
||||
await vi.waitFor(() => {
|
||||
expect(instance.store.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
|
||||
injected.send('retry me', 'queue')
|
||||
injected.send('retry me', [], 'queue')
|
||||
instance.actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
|
||||
|
||||
@@ -15,9 +15,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('createChatStore', () => {
|
||||
it('init shape: empty selection/draft/view', () => {
|
||||
it('init shape: empty selection/draft/images/view', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })
|
||||
})
|
||||
|
||||
it('actions cover the declared write set', () => {
|
||||
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
|
||||
|
||||
store.actions.setDraft('hello')
|
||||
expect(store.store.getSnapshot().draft).toBe('hello')
|
||||
store.actions.addImages(['a', 'b'])
|
||||
store.actions.removeImage('a')
|
||||
expect(store.store.getSnapshot().imageIds).toEqual(['b'])
|
||||
store.actions.pruneImages([])
|
||||
expect(store.store.getSnapshot().imageIds).toEqual([])
|
||||
store.actions.clearDraft()
|
||||
expect(store.store.getSnapshot().draft).toBe('')
|
||||
|
||||
@@ -40,12 +45,15 @@ describe('createChatStore', () => {
|
||||
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
|
||||
const store = createChatStore().create()
|
||||
// Rollback path: draft was cleared by send, nothing typed since.
|
||||
store.actions.restoreDraft('failed text')
|
||||
store.actions.restoreDraft('failed text', ['old-image'])
|
||||
expect(store.store.getSnapshot().draft).toBe('failed text')
|
||||
expect(store.store.getSnapshot().imageIds).toEqual(['old-image'])
|
||||
// The user typed something new before the failure landed: keep theirs.
|
||||
store.actions.setDraft('newer input')
|
||||
store.actions.restoreDraft('stale text')
|
||||
store.actions.addImages(['new-image'])
|
||||
store.actions.restoreDraft('stale text', ['old-image'])
|
||||
expect(store.store.getSnapshot().draft).toBe('newer input')
|
||||
expect(store.store.getSnapshot().imageIds).toEqual(['old-image', 'new-image'])
|
||||
})
|
||||
|
||||
it('persists per scope key and rehydrates a fresh instance', () => {
|
||||
|
||||
@@ -129,3 +129,89 @@ describe('error strip and variants', () => {
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('image draft rail', () => {
|
||||
it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const { textarea } = setup({ draft: '', onAddImages })
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
const prevented = fireEvent.paste(textarea, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
|
||||
{ kind: 'file', type: 'image/png', getAsFile: () => image },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(prevented).toBe(false)
|
||||
expect(onAddImages).toHaveBeenCalledWith([image])
|
||||
|
||||
fireEvent.paste(textarea, {
|
||||
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
|
||||
})
|
||||
expect(onAddImages).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
|
||||
const dataTransfer = {
|
||||
types: ['Files'],
|
||||
files: [image],
|
||||
dropEffect: 'none',
|
||||
}
|
||||
expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false)
|
||||
expect(view.getByRole('status').textContent).toContain('松开以添加图片')
|
||||
expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false)
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(onAddImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
const onAddImages = vi.fn()
|
||||
const { view } = setup({ draft: '', onAddImages })
|
||||
const card = view.container.querySelector('[class*="card"]')!
|
||||
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
|
||||
fireEvent.drop(card, {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy()
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = setup({ draft: '', disabled: true, onAddImages })
|
||||
const lockedCard = locked.view.container.querySelector('[class*="card"]')!
|
||||
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
|
||||
fireEvent.dragEnter(lockedCard, { dataTransfer })
|
||||
expect(locked.view.queryByRole('status')).toBeNull()
|
||||
fireEvent.dragOver(lockedCard, { dataTransfer })
|
||||
expect(dataTransfer.dropEffect).toBe('none')
|
||||
fireEvent.drop(lockedCard, { dataTransfer })
|
||||
expect(onAddImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
|
||||
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
|
||||
const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' }
|
||||
const onRemoveAttachment = vi.fn()
|
||||
const { view, textarea, props } = setup({
|
||||
draft: '', attachments: [attachment], onRemoveAttachment,
|
||||
})
|
||||
const send = view.getByRole('button', { name: '发送' }) as HTMLButtonElement
|
||||
expect(send.disabled).toBe(false)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(props.onSend).toHaveBeenCalledWith('queue')
|
||||
|
||||
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
|
||||
expect(onRemoveAttachment).toHaveBeenCalledWith('draft-1')
|
||||
fireEvent.doubleClick(view.getByTitle('双击查看原图'))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
expect(view.getAllByAltText('pixel.png').every(node => (node as HTMLImageElement).src.includes('blob:draft-1'))).toBe(true)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const attachment = {
|
||||
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
|
||||
mediaType: 'image/png' as const,
|
||||
bytes: 68,
|
||||
width: 640,
|
||||
height: 320,
|
||||
name: 'history.png',
|
||||
}
|
||||
|
||||
describe('MessageImage', () => {
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
fireEvent.doubleClick(frame)
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '关闭原图预览' }))
|
||||
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a retry control when durable bytes cannot be read', async () => {
|
||||
const load = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage attachment={attachment} load={load} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -175,6 +175,6 @@ describe('selection survives on the store seat', () => {
|
||||
await flush()
|
||||
const reborn = storeFor(b, 'conversation', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', imageIds: [], view: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,6 +93,29 @@ describe('send / cancel', () => {
|
||||
await expect(s.send('x', 'queue')).rejects.toThrow(/send failed: agent-busy: busy/)
|
||||
})
|
||||
|
||||
it('uploads temporary browser files as base64 image parts at the send boundary', async () => {
|
||||
const b = await bench()
|
||||
const file = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () => Promise.resolve(Uint8Array.of(1, 2, 3).buffer),
|
||||
})
|
||||
await b.scopedSvc(sid('s1')).send('describe', 'queue', [file])
|
||||
expect(b.sessionDoubles.get(sid('s1'))!.prompt).toHaveBeenCalledWith([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AQID', name: 'pixel.png' },
|
||||
{ type: 'text', text: 'describe' },
|
||||
], 'queue')
|
||||
})
|
||||
|
||||
it('rejects unsupported browser media before prompting the session', async () => {
|
||||
const b = await bench()
|
||||
const file = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(file, 'arrayBuffer', {
|
||||
value: () => Promise.resolve(Uint8Array.of(1).buffer),
|
||||
})
|
||||
await expect(b.scopedSvc(sid('s1')).send('', 'queue', [file])).rejects.toThrow(/不支持的图片格式/)
|
||||
expect(b.sessionDoubles.get(sid('s1'))?.prompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancel resolves on ok and throws the folded business error', async () => {
|
||||
const b = await bench()
|
||||
const s = b.scopedSvc(sid('s1'))
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
@@ -129,6 +132,9 @@ describe('ConversationRoot branches', () => {
|
||||
useStore={hookOf(chat)}
|
||||
actions={chat.actions}
|
||||
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
|
||||
addImages={vi.fn()}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
|
||||
@@ -121,6 +121,9 @@ describe('ConversationRoot', () => {
|
||||
subscribe: () => () => {},
|
||||
version: () => 1,
|
||||
}}
|
||||
addImages={vi.fn()}
|
||||
removeImage={vi.fn()}
|
||||
draftImages={() => []}
|
||||
send={send}
|
||||
stop={stop}
|
||||
openDetails={openDetails}
|
||||
@@ -183,7 +186,7 @@ describe('ConversationRoot', () => {
|
||||
// Typing goes through actions.setDraft into the shared store.
|
||||
expect(chat.store.getSnapshot().draft).toBe('hi')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(send).toHaveBeenCalledWith('hi', 'queue')
|
||||
expect(send).toHaveBeenCalledWith('hi', [], 'queue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user