fix(gui): harden multimodal image attachments
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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?.()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user