feat(web): whole-page image drop, projected intake limits, and thumbnail tiling

Second alignment step for #2248: document-level drag intake behind the new
DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat
thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged
consecutive assistant images), image limits raised to 20/10MiB/100MiB and
published to clients as the imageLimits projection, whole-batch intake
pre-check with product-copy banners, and attachment-error reasons mapped to
localized copy.
This commit is contained in:
creatixchu
2026-08-12 12:31:52 +08:00
parent 4a8ee7674a
commit 2c9036b102
46 changed files with 999 additions and 171 deletions

View File

@@ -9,6 +9,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -25,6 +26,38 @@ const attachment = {
name: 'history.png',
}
describe('attachment rejection copy', () => {
const limits = {
maxImageBytes: 10 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
it('renders megabytes without a trailing fraction unless one exists', () => {
expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB')
expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB')
})
it('maps user-solvable reasons to limit-naming copy', () => {
expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型')
expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 10MB')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片')
expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images')
})
it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => {
expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试')
expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试')
expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试')
})
})
describe('assistant images through the label bridge', () => {
it('resolves zh dictionary strings and opens the lightbox on a single click', async () => {
const view = render(
@@ -60,6 +93,27 @@ describe('assistant images through the label bridge', () => {
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('merges consecutive image blocks into one tiled gallery, split by text', async () => {
const view = render(
<AssistantMarkdown
t={t}
blocks={[
{ kind: 'image', attachment },
{ kind: 'image', attachment },
{ kind: 'text', text: 'between' },
{ kind: 'image', attachment },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:grouped')}
/>,
)
await view.findAllByAltText('history.png')
const galleries = view.container.querySelectorAll('[data-align="start"]')
expect(galleries).toHaveLength(2)
expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2)
expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown

View File

@@ -56,6 +56,14 @@ interface BenchOptions {
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string }
/** The `imageLimits` projection value (absent = no attachment service). */
imageLimits?: {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[]
}
draft?: string
running?: boolean
subagent?: Exclude<ConversationSnapshot['subagent'], null>
@@ -146,7 +154,9 @@ function bench(over?: BenchOptions) {
baselinesReady: true, recentWorkspaceId: undefined,
})),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)),
(selector ?? (v => v))(key === 'permissions'
? over?.permissions
: key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)),
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
@@ -212,18 +222,125 @@ describe('image draft rail', () => {
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
})
it('accepts file drops and prevents browser navigation', () => {
it('accepts a drop anywhere on the page under the full-page overlay', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1)], '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)
// The drag never touches the composer card: the listeners are page-wide.
expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false)
expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加')
expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false)
expect(dataTransfer.dropEffect).toBe('copy')
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false)
expect(addImages).toHaveBeenCalledWith([image])
expect(view.queryByRole('status')).toBeNull()
})
it('keeps text drags native and hides the overlay when the drag leaves or ends', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages })
// A text drag carries no Files type: no overlay, native behavior stays.
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } })
expect(view.queryByRole('status')).toBeNull()
const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragLeave(document.body, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
// An aborted drag (Escape) fires dragend without a balancing leave.
fireEvent.dragEnter(document.body, { dataTransfer })
fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer })
expect(view.getByRole('status')).toBeTruthy()
fireEvent.dragEnd(window, { dataTransfer })
expect(view.queryByRole('status')).toBeNull()
expect(addImages).not.toHaveBeenCalled()
})
it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => {
const limits = {
maxImageBytes: 1024 * 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
}
const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' })
const drop = (files: File[]) => {
fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } })
}
// Count: three at once over a two-image limit → the whole batch refused.
const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')])
expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片')
expect(overCount.props.addImages).not.toHaveBeenCalled()
cleanup()
// Per-file bytes.
const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits })
drop([png(1024 * 1024 + 1, 'big.png')])
expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB')
expect(overFile.props.addImages).not.toHaveBeenCalled()
cleanup()
// Aggregate bytes across the existing rail plus the new batch.
const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' })
const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' }
const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] })
drop([png(1024 * 1024, 'more.png')])
expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB')
expect(overTotal.props.addImages).not.toHaveBeenCalled()
cleanup()
// Within every limit: the batch passes through to addImages.
const within = bench({ addImages: vi.fn(() => null), imageLimits: limits })
const fits = png(16, 'fits.png')
drop([fits])
expect(within.props.addImages).toHaveBeenCalledWith([fits])
expect(within.view.queryByRole('alert')).toBeNull()
})
it('shows the projected limits in the drop overlay desc line', () => {
const { view } = bench({
addImages: vi.fn(() => null),
imageLimits: {
maxImageBytes: 10 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png'] as const,
},
})
fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } })
expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 10MB')
})
it('announces server attachment rejections as product copy, other codes as developer text', () => {
const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({
op: 'send',
error: { code: 'attachment-error', message: 'raw wire text', details: { reason } },
})
const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') })
expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型')
cleanup()
const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') })
expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)')
cleanup()
const other = bench({
promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } },
})
expect(other.view.getByRole('alert').textContent).toContain('boom (internal)')
})
it('shows the blocked overlay and refuses the drop while the composer is locked', () => {
const addImages = vi.fn(() => null)
const { view } = bench({ addImages, inert: true })
const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' })
const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' }
fireEvent.dragEnter(document.body, { dataTransfer })
expect(view.getByRole('status').textContent).toContain('当前无法添加图片')
fireEvent.dragOver(document.body, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(document.body, { dataTransfer })
expect(addImages).not.toHaveBeenCalled()
expect(view.queryByRole('status')).toBeNull()
})
it('sends an image-only draft and removes its thumbnail', () => {
@@ -250,7 +367,7 @@ describe('image draft rail', () => {
it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => {
vi.useFakeTimers()
try {
const addImages = vi.fn(() => '不支持的图片格式:text/plain')
const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片')
const { view, textarea } = bench({ addImages })
const paste = () => {
fireEvent.paste(textarea, {
@@ -261,12 +378,12 @@ describe('image draft rail', () => {
})
}
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
act(() => { vi.advanceTimersByTime(4000) })
expect(view.queryByRole('alert')).toBeNull()
// The identical rejection re-announces: the toast is keyed per show.
paste()
expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain')
expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片')
} finally {
vi.useRealTimers()
}