fix(web): harden multimodal draft and storage lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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']>>>(
|
||||
|
||||
Reference in New Issue
Block a user