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