fix: harden Web image admission

This commit is contained in:
Tianyi Cui
2026-07-30 01:58:36 +08:00
parent d6c82001b3
commit 515d48875e
52 changed files with 999 additions and 444 deletions
@@ -6,14 +6,14 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, DraftAttachmentId, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
/** Browser-owned image that has not crossed the durable host boundary. */
export interface ComposerAttachment {
kind: 'image'
id: string
id: DraftAttachmentId
file: File
previewUrl: string
}
@@ -275,9 +275,9 @@ export interface ComposerBarInjected {
/** Create browser previews and append their ids to the session input state. */
addImages: (files: readonly File[]) => string | null
/** Release one browser preview and remove its id from the session input state. */
removeImage: (id: string) => void
removeImage: (id: DraftAttachmentId) => void
/** Resolve ordered input-state ids to browser-owned draft attachments. */
draftImages: (ids: readonly string[]) => readonly ComposerAttachment[]
draftImages: (ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]
/** Cancel the in-flight turn. */
stop: () => void
/**
@@ -4,8 +4,8 @@
* owns their slot assembly.
*/
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export type { IConversation } from './service.ts'
export type { DraftAttachmentId } from './input/contract.ts'
export type {
CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -6,11 +6,15 @@
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
/** Browser-runtime identity of one unsent image draft. */
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
/**
* The scoped-event application verbs: the hub's bail listeners call these,
* and the boolean answer IS the event's bail value (true ⟺ the machine
@@ -28,11 +32,11 @@ export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly string[]): void
addImages(ids: readonly DraftAttachmentId[]): void
/** Remove one browser-owned draft attachment id. */
removeImage(id: string): void
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly string[]): void
pruneImages(ids: readonly DraftAttachmentId[]): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
/**
@@ -65,11 +69,11 @@ export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly string[]): void
addImages(ids: readonly DraftAttachmentId[]): void
/** Remove one browser-owned draft attachment id. */
removeImage(id: string): void
removeImage(id: DraftAttachmentId): void
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly string[]): void
pruneImages(ids: readonly DraftAttachmentId[]): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
}
@@ -198,7 +202,7 @@ export interface InputMachineOptions {
export interface InputState {
readonly draft: string
/** Ordered runtime-only image ids; bytes and object URLs stay in ConversationService. */
readonly imageIds: readonly string[]
readonly imageIds: readonly DraftAttachmentId[]
/** Monotonic draft revision (span CAS compares against this). */
readonly draftRev: number
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
@@ -13,7 +13,7 @@ import type {
ReferenceInsert, SlashController, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type {
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import { InputMachine } from './machine.ts'
@@ -39,7 +39,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly string[]): void
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void
}
/** Guard tier from the machine phase. */
@@ -79,7 +79,7 @@ export class SessionInputShell implements SessionInput {
private readonly core = new InputMachine({ now: () => Date.now() })
private noticeSeq = 0
private lastDraft = ''
private imageIds: readonly string[] = []
private imageIds: readonly DraftAttachmentId[] = []
private disposed = false
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
private mirrorFn: ((text: string) => void) | undefined
@@ -102,14 +102,14 @@ export class SessionInputShell implements SessionInput {
}
/** Append ordered browser-owned draft attachment ids. */
addImages(ids: readonly string[]): void {
addImages(ids: readonly DraftAttachmentId[]): void {
if (ids.length === 0 || this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return
this.imageIds = [...this.imageIds, ...ids]
this.publish()
}
/** Remove one browser-owned draft attachment id. */
removeImage(id: string): void {
removeImage(id: DraftAttachmentId): void {
const next = this.imageIds.filter(candidate => candidate !== id)
if (next.length === this.imageIds.length) return
this.imageIds = next
@@ -120,7 +120,7 @@ export class SessionInputShell implements SessionInput {
* Drop ids whose browser objects no longer exist.
* @param available - ids that still resolve through the browser attachment registry.
*/
pruneImages(available: readonly string[]): void {
pruneImages(available: readonly DraftAttachmentId[]): void {
const keep = new Set(available)
const next = this.imageIds.filter(id => keep.has(id))
if (next.length === this.imageIds.length) return
@@ -132,7 +132,7 @@ export class SessionInputShell implements SessionInput {
* Restore a failed attempt's ids before any images added after submission.
* @param ids - ordered identifiers captured by the failed attempt.
*/
restoreImages(ids: readonly string[]): void {
restoreImages(ids: readonly DraftAttachmentId[]): void {
const current = new Set(this.imageIds)
this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds]
this.publish()
@@ -144,7 +144,7 @@ export class SessionInputShell implements SessionInput {
* (the command path gets the same discipline from submit-settled success).
* @param imageIds - identifiers included in the committed attempt.
*/
commitSend(imageIds: readonly string[]): void {
commitSend(imageIds: readonly DraftAttachmentId[]): void {
const submitted = new Set(imageIds)
this.imageIds = this.imageIds.filter(id => !submitted.has(id))
this.run(this.core.dispatch({ type: 'send-committed' }))
@@ -11,7 +11,7 @@
import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
@@ -26,9 +26,9 @@ interface ConversationAttachmentFace {
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly string[],
imageIds: readonly DraftAttachmentId[],
): Promise<void>
releaseDraftImage(id: string): void
releaseDraftImage(id: DraftAttachmentId): void
}
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
@@ -138,7 +138,7 @@ export class InputHub implements InputService {
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly string[],
imageIds: readonly DraftAttachmentId[],
): void {
if (text === '' && imageIds.length === 0) return
const shell = this.shells.get(session.sessionId)
@@ -15,7 +15,7 @@ import type { Context } from 'cordis'
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { ComposerAttachment } from './contract/slots.ts'
import type { InputService } from './input/contract.ts'
import type { DraftAttachmentId, InputService } from './input/contract.ts'
/**
* The outward conversation face (`ctx.conversation`): the scope-addressed
@@ -46,7 +46,7 @@ export interface IConversation {
/** Create one browser-only draft descriptor; only its id enters input state. */
function browserDraftAttachment(file: File): ComposerAttachment {
return { kind: 'image', id: crypto.randomUUID(), previewUrl: URL.createObjectURL(file), file }
return { kind: 'image', id: crypto.randomUUID() as DraftAttachmentId, previewUrl: URL.createObjectURL(file), file }
}
interface ImageUrlEntry {
@@ -59,10 +59,11 @@ interface ImageUrlEntry {
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputService
private readonly draftAttachments = new Map<string, ComposerAttachment>()
private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
private readonly imageUrls = new Map<string, ImageUrlEntry>()
private readonly imageGenerations = new Map<SessionId, number>()
private readonly createdImageUrls = new Set<string>()
private disposed = false
/**
* @param ctx - owning root context (the plugin apply context; the service
@@ -74,6 +75,7 @@ export class ConversationService extends Service implements IConversation {
super(ctx, 'conversation')
this.input = config.input
ctx.effect(() => () => {
this.disposed = true
for (const url of this.createdImageUrls) URL.revokeObjectURL(url)
this.createdImageUrls.clear()
this.draftAttachments.clear()
@@ -107,7 +109,7 @@ export class ConversationService extends Service implements IConversation {
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly string[],
imageIds: readonly DraftAttachmentId[],
): Promise<void> {
const attachments = this.draftImages(imageIds)
if (attachments.length !== imageIds.length) {
@@ -149,7 +151,7 @@ export class ConversationService extends Service implements IConversation {
* @param ids - ordered ids from the per-session input state.
* @returns attachments still available in this browser runtime.
*/
draftImages(ids: readonly string[]): readonly ComposerAttachment[] {
draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] {
const attachments: ComposerAttachment[] = []
for (const id of ids) {
const attachment = this.draftAttachments.get(id)
@@ -162,7 +164,7 @@ export class ConversationService extends Service implements IConversation {
* Release one draft attachment preview.
* @param id - draft-local attachment id.
*/
releaseDraftImage(id: string): void {
releaseDraftImage(id: DraftAttachmentId): void {
const attachment = this.draftAttachments.get(id)
if (attachment === undefined) return
this.draftAttachments.delete(id)
@@ -185,6 +187,7 @@ export class ConversationService extends Service implements IConversation {
* @returns a browser URL for inline and original-size display.
*/
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed'))
const key = `${sessionId}:${attachment.attachmentId}`
const cached = this.imageUrls.get(key)
if (cached !== undefined) return cached.pending
@@ -194,6 +197,10 @@ export class ConversationService extends Service implements IConversation {
const pending = session.readAttachment(attachment.attachmentId)
.then((result) => {
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed')
if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) {
throw new Error('historical image scope was released before loading completed')
}
if (typeof URL.createObjectURL !== 'function') {
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
}
@@ -201,10 +208,6 @@ export class ConversationService extends Service implements IConversation {
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
})