fix: harden Web image admission
This commit is contained in:
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user