fix(gui): harden multimodal image attachments

This commit is contained in:
Yichen Jiang
2026-07-23 19:38:37 +08:00
parent eea595fcb4
commit 580e05b794
61 changed files with 1700 additions and 214 deletions

View File

@@ -74,6 +74,7 @@ async function bench() {
manager: { get: () => sessionFake },
scope: (id: SessionId) => mint(id),
cell: () => undefined,
hostDescription: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
@@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
it('empty injects draft-image lifecycle and the startSession chain without a store', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
expect(Object.keys(injected)).toEqual([
'createDraftImages',
'releaseDraftImage',
'releaseDraftImages',
'startSession',
])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)

View File

@@ -132,8 +132,9 @@ describe('error strip and variants', () => {
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 onAddImages = vi.fn((files: readonly File[]) =>
files.some(file => file.type === 'video/mp4') ? '不支持的图片格式:video/mp4' : null)
const { view, textarea } = setup({ draft: '', onAddImages })
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
const prevented = fireEvent.paste(textarea, {
clipboardData: {
@@ -141,19 +142,25 @@ describe('image draft rail', () => {
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
{ kind: 'file', type: 'image/png', getAsFile: () => image },
],
getData: () => '同时粘贴的文字',
},
})
expect(prevented).toBe(false)
expect(prevented).toBe(true)
expect(onAddImages).toHaveBeenCalledWith([image])
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
fireEvent.paste(textarea, {
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
clipboardData: {
items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }],
getData: () => '',
},
})
expect(onAddImages).toHaveBeenCalledTimes(1)
expect(onAddImages).toHaveBeenCalledTimes(2)
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
})
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
const onAddImages = vi.fn()
const onAddImages = vi.fn(() => null)
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' })
@@ -172,15 +179,16 @@ describe('image draft rail', () => {
})
it('ignores unsupported dropped files and refuses drops while locked', () => {
const onAddImages = vi.fn()
const onAddImages = vi.fn((files: readonly File[]) =>
files.some(file => file.type === 'text/plain') ? '不支持的图片格式:text/plain' : null)
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()
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
expect(onAddImages).toHaveBeenCalledWith([documentFile])
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
const locked = setup({ draft: '', disabled: true, onAddImages })
@@ -191,12 +199,12 @@ describe('image draft rail', () => {
fireEvent.dragOver(lockedCard, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(lockedCard, { dataTransfer })
expect(onAddImages).not.toHaveBeenCalled()
expect(onAddImages).toHaveBeenCalledTimes(1)
})
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 attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const onRemoveAttachment = vi.fn()
const { view, textarea, props } = setup({
draft: '', attachments: [attachment], onRemoveAttachment,

View File

@@ -4,6 +4,7 @@ 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'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
afterEach(cleanup)
@@ -41,4 +42,23 @@ describe('MessageImage', () => {
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment, alt: 'middle' },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('middle')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -7,7 +7,9 @@
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => {
interface SessionDouble {
prompt: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
readAttachment: ReturnType<typeof vi.fn>
}
async function bench(opts?: { sessions?: boolean }) {
afterEach(() => {
vi.unstubAllGlobals()
})
async function bench(opts?: {
sessions?: boolean
description?: ReturnType<SessionsService['hostDescription']>
}) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
s = {
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
}
sessionDoubles.set(id, s)
}
@@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) {
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
hostDescription: () => opts?.description,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
@@ -133,6 +145,127 @@ describe('send / cancel', () => {
})
})
describe('image admission and URL lifecycle', () => {
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
version: '0',
cwd: '/f',
attachedSessions: 0,
activeModel: {
provider: 'anthropic',
id: 'claude-opus-4-8',
name: 'Opus',
inputModalities: ['text', 'image'],
outputModalities: ['text'],
},
imageLimits: {
maxImageBytes: 3,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 100,
mediaTypes: ['image/png'],
},
}
it('preflights host limits before allocating previews and releases draft URLs', async () => {
const createObjectURL = vi.fn(() => 'blob:draft')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench({ description })
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
const attachments = b.svc.createDraftImages([first])
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
expect(createObjectURL).toHaveBeenCalledTimes(1)
b.svc.releaseDraftImages(attachments)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
})
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
const createObjectURL = vi.fn(() => 'blob:unexpected')
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
const textOnly = await bench({
description: {
...description,
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
},
})
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
const b = await bench({ description })
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
const existing = b.svc.createDraftImages([png, png])
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
expect(createObjectURL).toHaveBeenCalledTimes(2)
})
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
const createObjectURL = vi.fn()
.mockReturnValueOnce('blob:history-1')
.mockReturnValueOnce('blob:history-2')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
b.sessionsFake.manager.get(sid('s1'))
const session = b.sessionDoubles.get(sid('s1'))!
session.readAttachment.mockResolvedValue({
ok: true,
value: { attachment: ref, data: [1] },
})
await expect(Promise.all([
b.svc.resolveImage(sid('s1'), ref),
b.svc.resolveImage(sid('s1'), ref),
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
expect(session.readAttachment).toHaveBeenCalledTimes(1)
b.svc.releaseSessionImages(sid('s1'))
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
expect(session.readAttachment).toHaveBeenCalledTimes(2)
})
it('revokes a historical URL whose load completes after its session scope was released', async () => {
const createObjectURL = vi.fn(() => 'blob:late')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
const response = Promise.withResolvers<{
ok: true
value: { attachment: ImageAttachmentRef; data: number[] }
}>()
b.sessionsFake.manager.get(sid('s1'))
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
const pending = b.svc.resolveImage(sid('s1'), ref)
b.svc.releaseSessionImages(sid('s1'))
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
await expect(pending).rejects.toThrow(/scope was released/)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
})
})
describe('startSession chain', () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()

View File

@@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
@@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
@@ -240,7 +242,13 @@ describe('EmptyState branches', () => {
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -252,7 +260,13 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState useSessions={listHook([])} startSession={startSession} />,
<EmptyState
useSessions={listHook([])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -268,6 +282,9 @@ describe('EmptyState branches', () => {
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)

View File

@@ -68,7 +68,15 @@ describe('EmptyState', () => {
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
@@ -87,12 +95,59 @@ describe('EmptyState', () => {
it('new-directory option swaps the select for a free-form input', () => {
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={() => Promise.resolve()}
/>,
)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
})
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
const { useSessions } = fakeSessions([])
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = {
kind: 'image' as const,
id: 'draft-1',
file,
previewUrl: 'blob:draft-1',
}
const createDraftImages = vi.fn()
.mockReturnValueOnce([attachment])
.mockImplementationOnce(() => { throw new Error('图片过大') })
const releaseDraftImage = vi.fn()
const releaseDraftImages = vi.fn()
const view = render(
<EmptyState
useSessions={useSessions}
createDraftImages={createDraftImages}
releaseDraftImage={releaseDraftImage}
releaseDraftImages={releaseDraftImages}
startSession={() => Promise.resolve()}
/>,
)
const textarea = view.container.querySelector('textarea')!
const clipboardData = {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
getData: () => '',
}
fireEvent.paste(textarea, { clipboardData })
expect(createDraftImages).toHaveBeenCalledWith([file], [])
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
fireEvent.paste(textarea, { clipboardData })
expect(view.getByText('图片过大')).toBeTruthy()
view.unmount()
expect(releaseDraftImages).toHaveBeenCalledWith([])
})
})
describe('ConversationRoot', () => {
@@ -121,9 +176,10 @@ describe('ConversationRoot', () => {
subscribe: () => () => {},
version: () => 1,
}}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={send}
stop={stop}
openDetails={openDetails}