fix(web): harden multimodal draft and storage lifecycle

This commit is contained in:
creatixchu
2026-07-31 17:15:08 +08:00
parent f415e16c83
commit 8b00482d7c
34 changed files with 303 additions and 129 deletions
@@ -23,6 +23,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { DraftAttachmentId } from '../src/client/input/contract.ts'
import type { createChatStore } from '../src/client/stores.ts'
const ROOT = 'root-1' as SessionId
@@ -96,11 +97,12 @@ async function bench() {
const inputSurface = (id: SessionId) => {
const info = runtime.sessions.provideInfo(id)!
const state = info.hooks['input'] as {
getSnapshot: () => { draft: string }
getSnapshot: () => { draft: string; imageIds: readonly DraftAttachmentId[] }
subscribe: (fn: () => void) => () => void
}
const actions = info.props['inputActions'] as {
setDraft: (text: string) => void
addImages: (ids: readonly DraftAttachmentId[]) => boolean
submit: (mode?: 'queue' | 'steer') => void
}
return { state, actions }
@@ -108,7 +110,7 @@ async function bench() {
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, layoutFake,
sessionFake, layoutFake, locale,
}
}
@@ -288,6 +290,41 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('keeps a mixed draft together when the destination refuses its images', async () => {
const b = await bench()
const OTHER = 'mixed-target' as SessionId
await b.runtime.sessions.add({ id: OTHER }, { current: false })
const source = b.inputSurface(ROOT)
const destination = b.inputSurface(OTHER)
const imageId = 'draft-mixed' as DraftAttachmentId
source.actions.setDraft('carry together')
source.actions.addImages([imageId])
const destinationShell = b.composerSurface(OTHER).keyboard as unknown as {
addImages: (ids: readonly DraftAttachmentId[]) => boolean
}
vi.spyOn(destinationShell, 'addImages').mockReturnValue(false)
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
await b.residentSurface(ROOT).selectWorkspace('workspace-mixed' as never)
expect(source.state.getSnapshot()).toMatchObject({
draft: 'carry together',
imageIds: [imageId],
})
expect(destination.state.getSnapshot()).toMatchObject({ draft: '', imageIds: [] })
await b.runtime.dispose()
})
it('localizes browser image-type rejection through the active conversation locale', async () => {
const b = await bench()
b.locale.setLocale('en')
const error = b.composerSurface(ROOT).addImages?.([
new File([Uint8Array.of(1)], 'vector.svg', { type: 'image/svg+xml' }),
])
expect(error).toBe('Unsupported image format: image/svg+xml')
await b.runtime.dispose()
})
it('scopedConversation fails loud when the session resolves no scope', async () => {
const b = await bench()
// The chat-view inject resolves the scoped conversation service at inject
@@ -7,11 +7,12 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
const enT = makeTranslate(en, commonZh)
const attachment = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
@@ -25,7 +26,7 @@ const attachment = {
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 view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const frame = view.getByRole('button', { name: 'history.png,双击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -41,13 +42,23 @@ describe('MessageImage', () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} />)
const view = render(<MessageImage attachment={attachment} load={load} t={t} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('renders image controls from the active English dictionary', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} t={enT} />)
const frame = view.getByRole('button', { name: 'history.png, double-click to view original' })
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
fireEvent.doubleClick(frame)
expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy()
expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy()
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
@@ -9,7 +9,7 @@ import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from '../src/client/input/hub.ts'
import { ConversationService } from '../src/client/service.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts'
async function bench(readAttachment?: SessionFace['readAttachment']) {
const runtime = await SlotTestRuntime.create()
@@ -86,12 +86,11 @@ describe('ConversationService', () => {
expect(created).toHaveBeenCalledTimes(11)
const beforeRejectedBatch = created.mock.calls.length
expect(() => {
b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
])
}).toThrow('不支持的图片格式:image/svg+xml')
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
]))
.toThrow(UnsupportedImageMediaTypeError)
expect(created).toHaveBeenCalledTimes(beforeRejectedBatch)
} finally {
created.mockRestore()
@@ -127,6 +126,33 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('restores failed-send images before images added while the request was in flight', async () => {
const b = await bench()
const first = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
])[0]
const second = b.root.createDraftImages([
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
])[0]
if (first === undefined || second === undefined) throw new Error('draft attachment missing')
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
const request = Promise.withResolvers<{ ok: true; value: { accepted: true } }>()
b.prompt.mockReturnValueOnce(request.promise)
shell.addImages([first.id])
shell.setDraft('describe')
shell.submit('queue')
expect(shell.addImages([second.id])).toBe(true)
expect(shell.snapshot.imageIds).toEqual([second.id])
request.reject(new Error('transport died'))
await vi.waitFor(() => {
expect(shell.snapshot.imageIds).toEqual([first.id, second.id])
})
expect(b.root.draftImages(shell.snapshot.imageIds)).toEqual([first, second])
await b.runtime.dispose()
})
it('does not publish a historical image URL after disposal', async () => {
let resolveRead!: (result: Awaited<ReturnType<SessionFace['readAttachment']>>) => void
const readAttachment: SessionFace['readAttachment'] = vi.fn(() => new Promise<Awaited<ReturnType<SessionFace['readAttachment']>>>(