fix(gui): harden multimodal image attachments

This commit is contained in:
Yichen Jiang
2026-07-23 19:38:37 +08:00
parent eea595fcb4
commit 580e05b794
61 changed files with 1700 additions and 214 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-connection
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. 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 publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. 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.
## Model Experience

View File

@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ResponseValue,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {
@@ -20,6 +21,9 @@ export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
/** Successful value returned by the connection-generation host handshake. */
export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'>
import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
/**

View File

@@ -1,4 +1,4 @@
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
@@ -44,6 +44,8 @@ export type ConnectionState = 'connected' | 'reconnecting'
export interface ConnectionSinks {
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
/** Latest successful host capability snapshot for this connection generation. */
onDescription?: (description: HostDescription) => void
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
onConnected?: () => void
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
@@ -131,13 +133,18 @@ export class ConnectionController {
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
// (see ConnectionConfig.streamOpenTimeoutMs).
const timeout = new AbortController()
await Promise.all([
const [description] = await Promise.all([
this.api.host.describe({}),
Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),
])
timeout.abort()
const descriptionResult = description.result
if (!descriptionResult.ok) {
throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`)
}
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
this.attempt = 0
this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) })
this.emitState('connected')
this.callSink(this.sinks.onConnected)
} catch {

View File

@@ -19,7 +19,7 @@ export type {
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
} from './api.ts'
export { RpcId, AbstractApiClient, transportError } from './api.ts'

View File

@@ -23,9 +23,11 @@ describe('connection lifecycle', () => {
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []
const descriptions: string[] = []
let connected = 0
const controller = new ConnectionController(api, {
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
onDescription: description => descriptions.push(description.version),
onConnected: () => { connected++ },
}, FAST)
controller.start()
@@ -34,6 +36,7 @@ describe('connection lifecycle', () => {
api.pushMux(subscribedFrame())
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
expect(api.callsOf('host.describe')).toHaveLength(1)
expect(descriptions).toEqual(['0-fake'])
} finally {
controller.stop()
}
@@ -83,6 +86,35 @@ describe('connection lifecycle', () => {
}
})
it('treats a host.describe business error as generation failure', async () => {
const api = new FakeApiClient()
let describeCalls = 0
api.onDescribe = () => {
describeCalls += 1
if (describeCalls === 1) {
return Promise.resolve({
rpcId: 'bad-describe' as never,
result: {
ok: false as const,
error: { code: 'internal' as const, message: 'not ready', details: {} },
},
})
}
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
}
let connected = 0
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
warnSpy.mockRestore()
}
})
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
const api = new FakeApiClient()
const muxSeen: string[] = []

View File

@@ -207,6 +207,31 @@ describe('createFixtureApi', () => {
})
})
it('accounts for every base64 padding form and reports a missing fixture attachment', async () => {
const api = createFixtureApi()
const created = await api.sessions.create(req({}))
if (!created.result.ok) throw new Error('create failed')
const sessionId = created.result.value.sessionId
const prompted = await api.sessions.prompt(req({
sessionId,
mode: 'queue' as const,
content: ['YQ==', 'YWI=', 'YWJj'].map(data => ({
type: 'image' as const,
mediaType: 'image/png' as const,
data,
})),
}))
expect(prompted.result.ok).toBe(true)
const missing = await api.sessions.attachment(req({
sessionId,
attachmentId: 'fixture:missing' as never,
}))
expect(missing.result).toMatchObject({
ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
})
await api.sessions.cancel(req({ sessionId }))
})
it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => {
vi.useFakeTimers()
try {

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-runtime
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry, and the latest successful host capability description), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4.
## Model Experience

View File

@@ -148,6 +148,7 @@ export function apply(ctx: Context): void {
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onDescription: (description) => { sessions.handleDescription(description) },
onConnected: () => { sessions.manager.handleConnected() },
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')

View File

@@ -14,7 +14,7 @@
* read-only view).
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostDescription, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
@@ -101,6 +101,7 @@ export class SessionsService {
private watched: SessionId | undefined
/** Removed-while-watched sessions whose teardown waits for the watch to move away. */
private readonly deferredRemovals = new Set<SessionId>()
private description: HostDescription | undefined
/**
* @param ctx - client root context (scope fibers mount under it).
@@ -118,6 +119,22 @@ export class SessionsService {
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Store the latest successful connection-generation host description.
* @param description - capability and deployment snapshot from `host.describe`.
*/
handleDescription(description: HostDescription): void {
this.description = description
}
/**
* Read the latest host capability snapshot.
* @returns the last successful description, or undefined before connection.
*/
hostDescription(): HostDescription | undefined {
return this.description
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere (the sole selection write path).

View File

@@ -53,6 +53,8 @@ describe('runtime client apply', () => {
expect((sessions as { list: { getSnapshot(): { ids: string[] } } }).list.getSnapshot().ids).toContain('s-new')
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 })
expect(sessions?.hostDescription()).toEqual({ version: '0', cwd: '/f', attachedSessions: 0 })
bench.sinks?.onConnected?.()
})

View File

@@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to both the conversation and details registrations, so the two session slots share one instance per session (selection written by conversation, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, view registry read face, startSession chain).
Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience

View File

@@ -94,15 +94,21 @@ export function apply(ctx: Context): void {
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
addImages: (files) => {
const images = conversation.createDraftImages(files)
actions.addImages(images.map(image => image.id))
addImages: (files, current) => {
try {
const images = conversation.createDraftImages(files, current)
actions.addImages(images.map(image => image.id))
return null
} catch (error: unknown) {
return error instanceof Error ? error.message : String(error)
}
},
removeImage: (id) => {
conversation.releaseDraftImage(id)
actions.removeImage(id)
},
draftImages: ids => conversation.draftImages(ids),
releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
send: (text, images: readonly ComposerAttachment[], mode) => {
const trimmed = text.trim()
if (trimmed === '' && images.length === 0) return
@@ -140,6 +146,9 @@ export function apply(ctx: Context): void {
slots.register({
name: 'conversation.empty',
inject: (): EmptyStateInjected => ({
createDraftImages: (files, current) => conversation.createDraftImages(files, current, true),
releaseDraftImage: (id) => { conversation.releaseDraftImage(id) },
releaseDraftImages: (attachments) => { conversation.releaseDraftImages(attachments) },
startSession: opts => conversation.startSession(opts),
}),
}, EmptyState)

View File

@@ -40,15 +40,13 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) {
const last = blocks.length - 1
const images = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'image' }> => block.kind === 'image')
return (
<div className={css.root} data-streaming={streaming || undefined}>
<ImageGallery images={images} load={loadImage} align="start" />
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MessageText key={i} text={block.text} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
case 'image': return null
case 'image': return <ImageGallery key={i} images={[block]} load={loadImage} align="start" />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />

View File

@@ -14,6 +14,7 @@ import type { SelectionTarget, ViewEntry } from './views.ts'
/** Browser-owned image that has not crossed the durable host boundary. */
export interface ComposerAttachment {
kind: 'image'
id: string
file: File
previewUrl: string
@@ -37,11 +38,13 @@ export interface ConversationInjected {
version(): number
}
/** Create browser previews and append their ids through the declared store action. */
addImages(files: readonly File[]): void
addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null
/** Release one browser preview and remove its id through the declared store action. */
removeImage(id: string): void
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
/** Release historical image URLs when this rendered session scope unmounts. */
releaseSessionImages(sessionId: SessionId): void
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
@@ -72,6 +75,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** Create service-owned image previews after host-capability preflight. */
createDraftImages(files: readonly File[], current: readonly ComposerAttachment[]): readonly ComposerAttachment[]
/** Release one service-owned image preview. */
releaseDraftImage(id: string): void
/** Release all service-owned image previews held by the empty state. */
releaseDraftImages(attachments: readonly ComposerAttachment[]): void
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: {
cwd?: string

View File

@@ -29,6 +29,7 @@ import type { ComposerAttachment } from './contract/slots.ts'
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
class BrowserDraftAttachment implements ComposerAttachment {
readonly kind = 'image' as const
readonly id: string
readonly previewUrl: string
readonly #file: File
@@ -53,10 +54,17 @@ interface ViewsState {
listeners: Set<() => void>
}
interface ImageUrlEntry {
readonly sessionId: SessionId
readonly generation: number
readonly pending: Promise<string>
}
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
private readonly imageUrls = new Map<string, Promise<string>>()
private readonly imageUrls = new Map<string, ImageUrlEntry>()
private readonly imageGenerations = new Map<SessionId, number>()
private readonly createdImageUrls = new Set<string>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
@@ -73,6 +81,7 @@ export class ConversationService extends Service {
this.createdImageUrls.clear()
this.draftAttachments.clear()
this.imageUrls.clear()
this.imageGenerations.clear()
}, 'conversation attachment URL cache')
}
@@ -86,6 +95,7 @@ export class ConversationService extends Service {
*/
async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise<void> {
const session = this.scopedSession('send')
this.validateImages(images, [])
const uploaded = await Promise.all(images.map(async file => ({
type: 'image' as const,
mediaType: imageMediaType(file.type),
@@ -100,9 +110,16 @@ export class ConversationService extends Service {
/**
* Create runtime-only draft attachments and their object URLs.
* @param files - browser-owned image files.
* @param current - images already present in the same composer.
* @param checkDefaultModel - whether to apply `host.describe`'s default-model capability, used only before a session exists.
* @returns ordered attachment descriptors whose ids may enter the chat store.
*/
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
createDraftImages(
files: readonly File[],
current: readonly ComposerAttachment[] = [],
checkDefaultModel = false,
): readonly ComposerAttachment[] {
this.validateImages(files, current, checkDefaultModel)
return files.map((file) => {
const attachment = new BrowserDraftAttachment(file)
this.draftAttachments.set(attachment.id, attachment)
@@ -154,7 +171,8 @@ export class ConversationService extends Service {
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
const key = `${sessionId}:${attachment.attachmentId}`
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached
if (cached !== undefined) return cached.pending
const generation = this.imageGenerations.get(sessionId) ?? 0
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
@@ -163,17 +181,39 @@ export class ConversationService extends Service {
}
const bytes = Uint8Array.from(result.value.data)
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
revokePreview(url)
throw new Error('historical image scope was released before loading completed')
}
this.createdImageUrls.add(url)
return url
})
.catch((error: unknown) => {
this.imageUrls.delete(key)
if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key)
throw error
})
this.imageUrls.set(key, pending)
this.imageUrls.set(key, { sessionId, generation, pending })
return pending
}
/**
* Release every historical image URL owned by one rendered session.
* @param sessionId - session whose rendered image scope is ending.
*/
releaseSessionImages(sessionId: SessionId): void {
this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1)
for (const [key, entry] of this.imageUrls) {
if (entry.sessionId !== sessionId) continue
this.imageUrls.delete(key)
void entry.pending.then((url) => {
if (!this.createdImageUrls.delete(url)) return
revokePreview(url)
}, () => {
// A failed or generation-invalidated load owns no cached object URL.
})
}
}
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
async cancel(): Promise<void> {
const session = this.scopedSession('cancel')
@@ -284,6 +324,38 @@ export class ConversationService extends Service {
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
private validateImages(
files: readonly File[],
current: readonly ComposerAttachment[],
checkDefaultModel = false,
): void {
const description = this.requireSessions().hostDescription()
const modalities = description?.activeModel?.inputModalities
if (checkDefaultModel && modalities !== undefined && !modalities.includes('image')) {
throw new Error('当前模型不支持图片输入')
}
const limits = description?.imageLimits
const all = [...current.map(attachment => attachment.file), ...files]
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
}
let totalBytes = 0
for (const file of all) {
const mediaType = imageMediaType(file.type)
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
throw new Error(`当前部署不支持 ${mediaType} 图片`)
}
if (limits !== undefined && file.size > limits.maxImageBytes) {
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
}
totalBytes += file.size
}
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
throw new Error('图片总大小超过单条消息限制')
}
}
}
function bumpViews(state: ViewsState): void {

View File

@@ -36,7 +36,8 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
export function ConversationRoot({
sessionId, useSession, useSessions, useStore, actions,
views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open,
views, addImages, removeImage, draftImages, releaseSessionImages,
send, stop, openDetails, loadOlder, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
@@ -63,6 +64,10 @@ export function ConversationRoot({
}
}, [actions, attachments, imageIds])
useEffect(() => () => {
releaseSessionImages(sessionId)
}, [releaseSessionImages, sessionId])
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}${promptError.error.code}` }
@@ -145,7 +150,7 @@ export function ConversationRoot({
error={error}
variant="composer"
onDraftChange={actions.setDraft}
onAddImages={addImages}
onAddImages={files => addImages(files, attachments)}
onRemoveAttachment={removeImage}
onSend={(mode) => { send(draft, attachments, mode) }}
onStop={stop}

View File

@@ -30,7 +30,13 @@ function deriveCwds(state: SessionListState): readonly string[] {
return [...seen]
}
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
export function EmptyState({
useSessions,
createDraftImages,
releaseDraftImage,
releaseDraftImages,
startSession,
}: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
@@ -67,21 +73,22 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
}
useEffect(() => () => {
for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl)
}, [])
releaseDraftImages(attachmentsRef.current)
}, [releaseDraftImages])
const addImages = (files: readonly File[]): void => {
setAttachments(current => [...current, ...files.map(file => ({
id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file),
}))])
const addImages = (files: readonly File[]): string | null => {
try {
const added = createDraftImages(files, attachments)
setAttachments(current => [...current, ...added])
return null
} catch (reason: unknown) {
return reason instanceof Error ? reason.message : String(reason)
}
}
const removeImage = (id: string): void => {
setAttachments((current) => {
const removed = current.find(item => item.id === id)
if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl)
return current.filter(item => item.id !== id)
})
releaseDraftImage(id)
setAttachments(current => current.filter(item => item.id !== id))
}
const picker = (

View File

@@ -12,12 +12,6 @@ import type { ComposerAttachment } from '../contract/slots.ts'
import { ImageLightbox } from './ImageLightbox.tsx'
import css from './InputBar.module.css'
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
function supportedImages(files: Iterable<File>): File[] {
return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type))
}
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -36,7 +30,7 @@ export interface InputBarProps {
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
onDraftChange: (text: string) => void
onAddImages?: (files: readonly File[]) => void
onAddImages?: (files: readonly File[]) => string | null
onRemoveAttachment?: (id: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
@@ -44,7 +38,7 @@ export interface InputBarProps {
export function InputBar({
draft, attachments = [], running, disabled, error, variant, placeholder, accessory,
onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop,
onDraftChange, onAddImages = () => null, onRemoveAttachment = () => {}, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
@@ -90,13 +84,12 @@ export function InputBar({
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => {
const files = [...event.clipboardData.items]
.filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type))
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length === 0) return
event.preventDefault()
setDropError(null)
onAddImages(files)
if (event.clipboardData.getData('text/plain') === '') event.preventDefault()
setDropError(onAddImages(files))
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
@@ -127,13 +120,8 @@ export function InputBar({
setDragActive(false)
if (locked) return
const dropped = [...event.dataTransfer.files]
const images = supportedImages(dropped)
if (images.length === 0) {
setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片')
return
}
setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件')
onAddImages(images)
if (dropped.length === 0) return
setDropError(onAddImages(dropped))
}
const closePreview = useCallback(() => { setPreview(null) }, [])
@@ -187,7 +175,10 @@ export function InputBar({
type="button"
className={css.remove}
aria-label={`移除图片 ${attachment.file.name || ''}`}
onClick={() => { onRemoveAttachment(attachment.id) }}
onClick={() => {
setDropError(null)
onRemoveAttachment(attachment.id)
}}
>×</button>
</div>
))}
@@ -204,7 +195,10 @@ export function InputBar({
disabled={locked}
placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息Enter 发送Shift+Enter 换行')}
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onChange={(e) => {
setDropError(null)
onDraftChange(e.target.value)
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
onCompositionStart={onCompositionStart}

View File

@@ -74,6 +74,7 @@ async function bench() {
manager: { get: () => sessionFake },
scope: (id: SessionId) => mint(id),
cell: () => undefined,
hostDescription: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
@@ -200,12 +201,17 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
it('empty injects draft-image lifecycle and the startSession chain without a store', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
expect(Object.keys(injected)).toEqual([
'createDraftImages',
'releaseDraftImage',
'releaseDraftImages',
'startSession',
])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)

View File

@@ -132,8 +132,9 @@ describe('error strip and variants', () => {
describe('image draft rail', () => {
it('collects supported clipboard images and leaves non-image clipboard data to the browser', () => {
const onAddImages = vi.fn()
const { textarea } = setup({ draft: '', onAddImages })
const onAddImages = vi.fn((files: readonly File[]) =>
files.some(file => file.type === 'video/mp4') ? '不支持的图片格式video/mp4' : null)
const { view, textarea } = setup({ draft: '', onAddImages })
const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' })
const prevented = fireEvent.paste(textarea, {
clipboardData: {
@@ -141,19 +142,25 @@ describe('image draft rail', () => {
{ kind: 'string', type: 'text/plain', getAsFile: () => null },
{ kind: 'file', type: 'image/png', getAsFile: () => image },
],
getData: () => '同时粘贴的文字',
},
})
expect(prevented).toBe(false)
expect(prevented).toBe(true)
expect(onAddImages).toHaveBeenCalledWith([image])
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
fireEvent.paste(textarea, {
clipboardData: { items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => image }] },
clipboardData: {
items: [{ kind: 'file', type: 'video/mp4', getAsFile: () => video }],
getData: () => '',
},
})
expect(onAddImages).toHaveBeenCalledTimes(1)
expect(onAddImages).toHaveBeenCalledTimes(2)
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
})
it('accepts supported image drops, highlights the target, and prevents browser navigation', () => {
const onAddImages = vi.fn()
const onAddImages = vi.fn(() => null)
const { view } = setup({ draft: '', onAddImages })
const card = view.container.querySelector('[class*="card"]')!
const image = new File([Uint8Array.of(1, 2, 3)], 'dropped.png', { type: 'image/png' })
@@ -172,15 +179,16 @@ describe('image draft rail', () => {
})
it('ignores unsupported dropped files and refuses drops while locked', () => {
const onAddImages = vi.fn()
const onAddImages = vi.fn((files: readonly File[]) =>
files.some(file => file.type === 'text/plain') ? '不支持的图片格式text/plain' : null)
const { view } = setup({ draft: '', onAddImages })
const card = view.container.querySelector('[class*="card"]')!
const documentFile = new File(['hello'], 'notes.txt', { type: 'text/plain' })
fireEvent.drop(card, {
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
})
expect(view.getByText(/暂仅支持 PNG/)).toBeTruthy()
expect(onAddImages).not.toHaveBeenCalled()
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
expect(onAddImages).toHaveBeenCalledWith([documentFile])
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
const locked = setup({ draft: '', disabled: true, onAddImages })
@@ -191,12 +199,12 @@ describe('image draft rail', () => {
fireEvent.dragOver(lockedCard, { dataTransfer })
expect(dataTransfer.dropEffect).toBe('none')
fireEvent.drop(lockedCard, { dataTransfer })
expect(onAddImages).not.toHaveBeenCalled()
expect(onAddImages).toHaveBeenCalledTimes(1)
})
it('allows image-only send, removes a thumbnail, and opens original preview on double-click', () => {
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = { id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const attachment = { kind: 'image' as const, id: 'draft-1', file, previewUrl: 'blob:draft-1' }
const onRemoveAttachment = vi.fn()
const { view, textarea, props } = setup({
draft: '', attachments: [attachment], onRemoveAttachment,

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import { MessageImage } from '../src/client/chat/MessageImage.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
afterEach(cleanup)
@@ -41,4 +42,23 @@ describe('MessageImage', () => {
await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
expect(load).toHaveBeenCalledTimes(2)
})
it('keeps assistant images at their original position between text blocks', async () => {
const view = render(
<AssistantMarkdown
blocks={[
{ kind: 'text', text: 'before' },
{ kind: 'image', attachment, alt: 'middle' },
{ kind: 'text', text: 'after' },
]}
streaming={false}
loadImage={() => Promise.resolve('blob:middle')}
/>,
)
const image = await view.findByAltText('middle')
const before = view.getByText('before')
const after = view.getByText('after')
expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
})
})

View File

@@ -7,7 +7,9 @@
* for the declared chat store (chat-store.spec.ts / selection-survival.spec.ts).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -32,9 +34,17 @@ const SCOPE_TAG: symbol = (() => {
interface SessionDouble {
prompt: ReturnType<typeof vi.fn>
cancel: ReturnType<typeof vi.fn>
readAttachment: ReturnType<typeof vi.fn>
}
async function bench(opts?: { sessions?: boolean }) {
afterEach(() => {
vi.unstubAllGlobals()
})
async function bench(opts?: {
sessions?: boolean
description?: ReturnType<SessionsService['hostDescription']>
}) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -57,6 +67,7 @@ async function bench(opts?: { sessions?: boolean }) {
s = {
prompt: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn(() => Promise.resolve({ ok: true, value: { accepted: true } })),
readAttachment: vi.fn(() => Promise.reject(new Error('attachment response not configured'))),
}
sessionDoubles.set(id, s)
}
@@ -66,6 +77,7 @@ async function bench(opts?: { sessions?: boolean }) {
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
hostDescription: () => opts?.description,
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
@@ -133,6 +145,127 @@ describe('send / cancel', () => {
})
})
describe('image admission and URL lifecycle', () => {
const description: NonNullable<ReturnType<SessionsService['hostDescription']>> = {
version: '0',
cwd: '/f',
attachedSessions: 0,
activeModel: {
provider: 'anthropic',
id: 'claude-opus-4-8',
name: 'Opus',
inputModalities: ['text', 'image'],
outputModalities: ['text'],
},
imageLimits: {
maxImageBytes: 3,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 100,
mediaTypes: ['image/png'],
},
}
it('preflights host limits before allocating previews and releases draft URLs', async () => {
const createObjectURL = vi.fn(() => 'blob:draft')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench({ description })
const first = new File([Uint8Array.of(1, 2, 3)], 'first.png', { type: 'image/png' })
const second = new File([Uint8Array.of(4, 5)], 'second.png', { type: 'image/png' })
const attachments = b.svc.createDraftImages([first])
expect(attachments[0]).toMatchObject({ kind: 'image', file: first, previewUrl: 'blob:draft' })
expect(() => b.svc.createDraftImages([second], attachments)).toThrow(/总大小/)
expect(createObjectURL).toHaveBeenCalledTimes(1)
b.svc.releaseDraftImages(attachments)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:draft')
})
it('rejects unsupported model capability, media type, count, and per-image bytes', async () => {
const createObjectURL = vi.fn(() => 'blob:unexpected')
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL: vi.fn() })
const textOnly = await bench({
description: {
...description,
activeModel: { ...description.activeModel!, inputModalities: ['text'] },
},
})
const png = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
expect(() => textOnly.svc.createDraftImages([png], [], true)).toThrow(/当前模型不支持图片/)
const b = await bench({ description })
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
expect(() => b.svc.createDraftImages([video])).toThrow(/不支持的图片格式/)
const large = new File([Uint8Array.of(1, 2, 3, 4)], 'large.png', { type: 'image/png' })
expect(() => b.svc.createDraftImages([large])).toThrow(/单张大小限制/)
const existing = b.svc.createDraftImages([png, png])
expect(() => b.svc.createDraftImages([png], existing)).toThrow(/最多添加 2 张/)
expect(createObjectURL).toHaveBeenCalledTimes(2)
})
it('deduplicates historical loads and revokes their URLs when the session scope ends', async () => {
const createObjectURL = vi.fn()
.mockReturnValueOnce('blob:history-1')
.mockReturnValueOnce('blob:history-2')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
b.sessionsFake.manager.get(sid('s1'))
const session = b.sessionDoubles.get(sid('s1'))!
session.readAttachment.mockResolvedValue({
ok: true,
value: { attachment: ref, data: [1] },
})
await expect(Promise.all([
b.svc.resolveImage(sid('s1'), ref),
b.svc.resolveImage(sid('s1'), ref),
])).resolves.toEqual(['blob:history-1', 'blob:history-1'])
expect(session.readAttachment).toHaveBeenCalledTimes(1)
b.svc.releaseSessionImages(sid('s1'))
await vi.waitFor(() => { expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-1') })
await expect(b.svc.resolveImage(sid('s1'), ref)).resolves.toBe('blob:history-2')
expect(session.readAttachment).toHaveBeenCalledTimes(2)
})
it('revokes a historical URL whose load completes after its session scope was released', async () => {
const createObjectURL = vi.fn(() => 'blob:late')
const revokeObjectURL = vi.fn()
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
const b = await bench()
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
}
const response = Promise.withResolvers<{
ok: true
value: { attachment: ImageAttachmentRef; data: number[] }
}>()
b.sessionsFake.manager.get(sid('s1'))
b.sessionDoubles.get(sid('s1'))!.readAttachment.mockReturnValue(response.promise)
const pending = b.svc.resolveImage(sid('s1'), ref)
b.svc.releaseSessionImages(sid('s1'))
response.resolve({ ok: true, value: { attachment: ref, data: [1] } })
await expect(pending).rejects.toThrow(/scope was released/)
expect(revokeObjectURL).toHaveBeenCalledWith('blob:late')
})
})
describe('startSession chain', () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()

View File

@@ -71,9 +71,10 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
@@ -132,9 +133,10 @@ describe('ConversationRoot branches', () => {
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
@@ -240,7 +242,13 @@ describe('EmptyState branches', () => {
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
<EmptyState
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -252,7 +260,13 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState useSessions={listHook([])} startSession={startSession} />,
<EmptyState
useSessions={listHook([])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -268,6 +282,9 @@ describe('EmptyState branches', () => {
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)

View File

@@ -68,7 +68,15 @@ describe('EmptyState', () => {
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
/>,
)
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
@@ -87,12 +95,59 @@ describe('EmptyState', () => {
it('new-directory option swaps the select for a free-form input', () => {
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={() => Promise.resolve()}
/>,
)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
})
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
const { useSessions } = fakeSessions([])
const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' })
const attachment = {
kind: 'image' as const,
id: 'draft-1',
file,
previewUrl: 'blob:draft-1',
}
const createDraftImages = vi.fn()
.mockReturnValueOnce([attachment])
.mockImplementationOnce(() => { throw new Error('图片过大') })
const releaseDraftImage = vi.fn()
const releaseDraftImages = vi.fn()
const view = render(
<EmptyState
useSessions={useSessions}
createDraftImages={createDraftImages}
releaseDraftImage={releaseDraftImage}
releaseDraftImages={releaseDraftImages}
startSession={() => Promise.resolve()}
/>,
)
const textarea = view.container.querySelector('textarea')!
const clipboardData = {
items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }],
getData: () => '',
}
fireEvent.paste(textarea, { clipboardData })
expect(createDraftImages).toHaveBeenCalledWith([file], [])
fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' }))
expect(releaseDraftImage).toHaveBeenCalledWith('draft-1')
fireEvent.paste(textarea, { clipboardData })
expect(view.getByText('图片过大')).toBeTruthy()
view.unmount()
expect(releaseDraftImages).toHaveBeenCalledWith([])
})
})
describe('ConversationRoot', () => {
@@ -121,9 +176,10 @@ describe('ConversationRoot', () => {
subscribe: () => () => {},
version: () => 1,
}}
addImages={vi.fn()}
addImages={vi.fn(() => null)}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={send}
stop={stop}
openDetails={openDetails}

View File

@@ -99,6 +99,7 @@ function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] =
addImages={vi.fn()}
removeImage={vi.fn()}
draftImages={() => []}
releaseSessionImages={vi.fn()}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}