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

View File

@@ -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/connection/README.md
README.md: 8b4a8fddc2fdc48873a4f93c99fce62a3dd17766
README.zh.md: cac04f5735f30498a301fb1a70748f8d7426dfbf
README.md: 8e8cb23cf56745116142d786ad712d7230f4f2c5
README.zh.md: dc2416d8b4dafa06f715e60f97e6dcd6d7fc29d9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. Its independent `maxRequestBodyBytes` config caps every buffered API request (32 MiB default) and must be large enough for the configured aggregate image payload after base64 and envelope expansion; changing image policy does not silently redefine the carrier cap for text and other methods. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法而这些方法在真正的认证层出现之前仍只限回环本机。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`业务错误响应会像传输错误一样使该连接代失败。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。其独立的 `maxRequestBodyBytes` 配置会限制每个缓冲 API 请求(默认 32 MiB并且必须足以容纳配置的图片总载荷经 base64 和请求封装膨胀后的大小;修改图片策略不会静默地为文本和其他方法重定义载体上限。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏

View File

@@ -16,6 +16,8 @@ export const name = 'client-connection'
/** Headroom for RPC JSON fields around the aggregate base64 image payload. */
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
/** Independent default carrier cap; deployments may raise it for larger valid non-image RPCs. */
const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy', 'attachments']
@@ -31,10 +33,17 @@ export interface ConnectionConfig {
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
/**
* Maximum buffered JSON body for every `/api` request. This carrier policy
* is independent of image limits but must be large enough for the configured
* aggregate image bytes after base64 and envelope expansion.
*/
maxRequestBodyBytes?: number
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES),
})
/**
@@ -80,9 +89,16 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const maxRequestBodyBytes = Math.ceil(
const requiredImageBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES
if (maxRequestBodyBytes < requiredImageBodyBytes) {
throw new Error(
`client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least `
+ `${String(requiredImageBodyBytes)} for the configured aggregate image limit`,
)
}
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,

View File

@@ -51,7 +51,10 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
async function mounted(config?: {
trustedHosts?: string[]
maxRequestBodyBytes?: number
}): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
@@ -96,6 +99,17 @@ describe('connection node half', () => {
expect(routes).toHaveLength(0)
})
it('fails loud when the independent carrier cap cannot hold the configured image batch', () => {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('attachments', fakeAttachments())
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
.toThrow(/must be at least .* aggregate image limit/)
expect(routes).toHaveLength(0)
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()

View File

@@ -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

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.

View File

@@ -4,7 +4,7 @@
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock与输入区一同 sticky 的会话统计行)、输入区 dock带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约api-contracts v3 §7 加 slot 终端设计store seatprops 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'` 列表 slotSession scope并将其列在 `children` 表中ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id``order``label`投影而来。聊天视图是该包自身的环配置项其他插件ui-trajectory通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView``ViewEntry``ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。

View File

@@ -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)
}
},

View File

@@ -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('图片读取服务不可用'))
}

View File

@@ -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>
)

View File

@@ -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('图片读取服务不可用'))
}

View File

@@ -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
}

View File

@@ -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',

View File

@@ -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)
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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

View File

@@ -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']>>>(