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
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: b87d562d9faed9beeb7881f1cf037ce75a046dde
README.zh.md: c2dbec5987fef987d5d89627259a1769ee0a29f3
README.md: 119984642fb668b6b07dd1eddc5a58e0681906c9
README.zh.md: 3aed3fdfa0cf5a46780289df96bd975b357835a0
+1 -1
View File
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store; a mixed text-and-image draft moves only when the destination accepts the complete image batch, otherwise both parts stay with the source. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
+1 -1
View File
@@ -4,7 +4,7 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store;只有目标接受完整图片批次,图文混合草稿才会移动,否则文本和图片都留在来源端。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
@@ -13,7 +13,7 @@ import type {
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts'
import type { IConversation } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
@@ -158,15 +158,17 @@ export function apply(ctx: Context): void {
const draft = from.snapshot.draft
const imageIds = from.snapshot.imageIds
const next = inputHub.shell(nextId)
if (draft !== '') {
next.setDraft(draft)
from.setDraft('')
}
// Transfer only on acceptance: a destination shell mid-submission
// refuses, and the drafts must stay owned (and releasable) by the
// source shell instead of silently leaking their object URLs.
if (imageIds.length > 0 && next.addImages(imageIds)) {
for (const id of imageIds) from.removeImage(id)
// refuses the whole mixed draft, which remains owned (and releasable)
// by the source shell instead of splitting text from its images.
if (imageIds.length === 0 || next.addImages(imageIds)) {
if (draft !== '') {
next.setDraft(draft)
from.setDraft('')
}
if (imageIds.length > 0) {
for (const id of imageIds) from.removeImage(id)
}
}
}
sessions.open(nextId)
@@ -242,6 +244,11 @@ export function apply(ctx: Context): void {
}
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
}
return error instanceof Error ? error.message : String(error)
}
},
@@ -71,8 +71,9 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, loadImage = unavailableImage, time, seq, onFork, t,
blocks, streaming, interrupted, loadImage, time, seq, onFork, t,
}: AssistantMarkdownProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
@@ -95,7 +96,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
)
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" t={t} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
@@ -123,7 +124,3 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
</div>
)
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
import css from './MessageImage.module.css'
@@ -7,9 +8,10 @@ import css from './MessageImage.module.css'
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
/** Compact history renderer with retryable loading and double-click original preview. */
export function MessageImage({ attachment, load }: {
export function MessageImage({ attachment, load, t }: {
attachment: ImageAttachmentRef
load: ImageLoader
t: ChatViewSlotProps['t']
}) {
const [src, setSrc] = useState<string | null>(null)
const [error, setError] = useState(false)
@@ -33,36 +35,37 @@ export function MessageImage({ attachment, load }: {
return () => { live = false }
}, [attachment, load])
const label = attachment.name ?? '图片'
if (error) return <button type="button" className={css.error} onClick={request}>图片加载失败,点击重试</button>
const label = attachment.name ?? t('image.label')
if (error) return <button type="button" className={css.error} onClick={request}>{t('image.loadFailed')}</button>
return (
<>
<button
type="button"
className={css.frame}
style={size}
title="双击查看原图"
aria-label={`${label},双击查看原图`}
title={t('image.openOriginal')}
aria-label={t('image.openOriginalLabel', { label })}
onDoubleClick={() => { if (src !== null) setOpen(true) }}
>
{src === null ? <span className={css.loading}>图片加载中…</span> : <img src={src} alt={label} />}
{src === null ? <span className={css.loading}>{t('image.loading')}</span> : <img src={src} alt={label} />}
</button>
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} />}
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} t={t} />}
</>
)
}
/** Wrapping image group shared by user and assistant history. */
export function ImageGallery({ images, load, align }: {
export function ImageGallery({ images, load, align, t }: {
images: readonly { attachment: ImageAttachmentRef }[]
load: ImageLoader
align: 'start' | 'end'
t: ChatViewSlotProps['t']
}) {
if (images.length === 0) return null
return (
<div className={css.gallery} data-align={align}>
{images.map((image, index) => (
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} />
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} t={t} />
))}
</div>
)
@@ -152,8 +152,9 @@ function projectUserText(text: string): ReactNode {
}
export const MessageItem = memo(function MessageItem({
node, loadImage = unavailableImage, retryActive = false, onFork, t,
node, loadImage, retryActive = false, onFork, t,
}: MessageItemProps) {
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
@@ -161,7 +162,7 @@ export const MessageItem = memo(function MessageItem({
return (
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={loadImage} align="end" />
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{(text !== '' || rest.length > 0) && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => (
@@ -186,7 +187,7 @@ export const MessageItem = memo(function MessageItem({
return (
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={loadImage} align="end" />
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
<div className={css.bubble}>
<span className={css.badge}>{t('message.steering')}</span>
{projectUserText(text)}
@@ -212,7 +213,3 @@ export const MessageItem = memo(function MessageItem({
)
}
})
function unavailableImage(): Promise<string> {
return Promise.reject(new Error('图片读取服务不可用'))
}
@@ -162,12 +162,7 @@ export class InputHub implements InputService {
// see them — release the drafts here instead of resurrecting them onto
// a dead instance where they would leak for the page lifetime.
if (this.shells.get(session.sessionId) === shell) {
if (shell?.snapshot.imageIds.length === 0) {
shell.restoreImages(imageIds)
} else {
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
for (const id of imageIds) conversation?.releaseDraftImage(id)
}
shell?.restoreImages(imageIds)
if (shell?.snapshot.draft === '') shell.setDraft(text)
return
}
@@ -23,6 +23,20 @@ export const zh = {
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '双击查看原图',
'image.openOriginalLabel': '{label},双击查看原图',
'image.remove': '移除图片 {name}',
'image.original': '原图',
'image.label': '图片',
'image.loadFailed': '图片加载失败,点击重试',
'image.loading': '图片加载中…',
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '不支持的图片格式:{type}',
'image.unknownType': '未知格式',
'access.confirm.title': '确认启用 Full access?',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
@@ -117,6 +131,20 @@ export const en = {
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.pending': 'Pending images',
'image.openOriginal': 'Double-click to view original',
'image.openOriginalLabel': '{label}, double-click to view original',
'image.remove': 'Remove image {name}',
'image.original': 'Original image',
'image.label': 'Image',
'image.loadFailed': 'Image failed to load; click to retry',
'image.loading': 'Loading image…',
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
@@ -63,6 +63,19 @@ interface ImageUrlEntry {
readonly pending: Promise<string>
}
/** Unsupported browser-declared image type, localized by the UI boundary. */
export class UnsupportedImageMediaTypeError extends Error {
/** Browser-declared MIME value, possibly empty. */
readonly mediaType: string
/** @param mediaType - Browser-declared MIME value, possibly empty. */
constructor(mediaType: string) {
super(`unsupported image media type: ${mediaType || '(empty)'}`)
this.name = 'UnsupportedImageMediaTypeError'
this.mediaType = mediaType
}
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
@@ -310,7 +323,7 @@ function imageMediaType(value: string): ImageMediaType {
case 'image/gif':
return value
default:
throw new Error(`不支持的图片格式:${value || '未知格式'}`)
throw new UnsupportedImageMediaTypeError(value)
}
}
@@ -1,8 +1,14 @@
import { useEffect, useRef } from 'react'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ImageLightbox.module.css'
/** Document-level original-image preview opened by an explicit double-click. */
export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
export function ImageLightbox({ src, alt, onClose, t }: {
src: string
alt: string
onClose: () => void
t: ChatViewSlotProps['t']
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const restoreRef = useRef<HTMLElement | null>(null)
@@ -24,11 +30,11 @@ export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string;
className={css.backdrop}
role="dialog"
aria-modal="true"
aria-label="原图预览"
aria-label={t('image.preview')}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
>
<img className={css.image} src={src} alt={alt} />
<button ref={closeRef} type="button" className={css.close} aria-label="关闭原图预览" onClick={onClose}>×</button>
<button ref={closeRef} type="button" className={css.close} aria-label={t('image.closePreview')} onClick={onClose}>×</button>
</div>
)
}
@@ -478,25 +478,25 @@ export function InputBar({
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">松开以添加图片</div>}
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{attachments.length > 0 && (
<div className={css.attachments} role="group" aria-label="待发送图片">
<div className={css.attachments} role="group" aria-label={t('image.pending')}>
{attachments.map(attachment => (
<div key={attachment.id} className={css.attachment}>
<button
type="button"
className={css.thumbnail}
title="双击查看原图"
title={t('image.openOriginal')}
onDoubleClick={() => { setPreview(attachment) }}
>
<img src={attachment.previewUrl} alt={attachment.file.name || '待发送图片'} />
<img src={attachment.previewUrl} alt={attachment.file.name || t('image.pending')} />
</button>
<button
type="button"
className={css.remove}
aria-label={`移除图片 ${attachment.file.name || ''}`}
aria-label={t('image.remove', { name: attachment.file.name })}
onClick={() => {
setDropError(null)
removeImage?.(attachment.id)
@@ -583,7 +583,14 @@ export function InputBar({
</div>
</div>
</div>
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
{preview !== null && (
<ImageLightbox
src={preview.previewUrl}
alt={preview.file.name || t('image.original')}
onClose={closePreview}
t={t}
/>
)}
{footer}
</div>
)
@@ -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']>>>(