fix(web): harden multimodal draft and storage lifecycle
This commit is contained in:
@@ -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']>>>(
|
||||
|
||||
Reference in New Issue
Block a user