From bafa6ae11d089941d6bf7a884089c4e3b44abda7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 1 Sep 2026 15:38:31 +0800 Subject: [PATCH] feat(conversation): stream files through submission lifecycle --- .../src/client/contract/session.ts | 32 ++- .../src/client/contract/snapshot.ts | 10 +- .../session-controller/src/client/index.ts | 6 +- .../src/client/sessions/manager.ts | 3 + .../src/client/sessions/queue-mirror.ts | 6 +- .../src/client/sessions/remotes.ts | 4 +- .../src/client/sessions/service.ts | 3 + .../src/client/sessions/session.ts | 133 +++++++++++-- .../session-controller/tsconfig.client.json | 1 + .../src/client/background-upload.ts | 184 ++++++++++++++++++ .../client/connection/src/client/fixture.ts | 16 +- .../client/connection/src/client/index.ts | 28 ++- packages/client/connection/src/http-bridge.ts | 84 ++++---- packages/client/connection/src/index.ts | 1 + packages/client/connection/src/rpc-host.ts | 13 +- packages/client/connection/src/rpc.ts | 14 +- .../client/connection/tsconfig.client.json | 1 + .../client/ui-commands/src/client/locales.ts | 4 +- .../client/ui-commands/src/client/service.ts | 36 ++-- .../ui-input-trigger/src/client/index.ts | 2 +- packages/client/ui-input-trigger/src/types.ts | 6 +- .../webworker-runtime/src/client/client.ts | 7 +- .../webworker-runtime/src/transport/frames.ts | 6 +- .../src/transport/synthetic-http.ts | 11 +- .../src/client/api-catalog.ts | 36 +++- .../src/client/slot-catalog.ts | 49 ++--- .../client-runtime/src/sessions.ts | 8 + 27 files changed, 559 insertions(+), 145 deletions(-) create mode 100644 packages/client/connection/src/client/background-upload.ts diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 71cc72e0ab..256a3f6e67 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,22 +7,25 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +import type { FileUploadReceiptId, PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' +import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts' /** * Why a local submission echo left the snapshot: `observed` when its durable * `user/message` event or host queue occurrence arrived (with the admitted - * image references in prompt order), `failed` when the prompt was rejected, + * attachment references in prompt order), `failed` when the prompt was rejected, * threw, or was aborted before acceptance. */ export type PendingSubmissionRetirement = - | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { + readonly reason: 'observed' + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[] + } | { readonly reason: 'failed' } /** Input registering one local submission echo ahead of its prompt call. */ @@ -31,8 +34,8 @@ export interface BeginSubmissionInput { readonly mode: 'queue' | 'steer' /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string - /** Ordered image previews matching the upcoming prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] /** Settlement callback fired exactly once when the echo retires. */ readonly onRetire?: (retirement: PendingSubmissionRetirement) => void } @@ -86,6 +89,21 @@ export interface ISession { signal?: AbortSignal, requestId?: SessionRequestId, ): Promise> + /** + * Persist one browser file verbatim and stage it for a later prompt on this + * session. The returned opaque receipt is what a prompt file part cites. + * @param data - browser Blob or exact file bytes. + * @param name - optional display name; the host sanitizes the stored leaf name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background Blob uploads. + * @returns the staged-upload receipt and durable file reference, or the business error. + */ + uploadFile( + data: Blob | Uint8Array, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..3b4f449d69 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,5 +1,6 @@ /** Session-owned observable state excluding Conversation target data. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -30,6 +31,11 @@ export interface PendingSubmissionImage { readonly height?: number } +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + | ({ readonly type: 'image' } & PendingSubmissionImage) + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + /** Client surface selected when a local submission begins. */ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' @@ -48,8 +54,8 @@ export interface PendingSubmission { readonly time: number /** Prompt text exactly as it will be sent (one text block). */ readonly text: string - /** Ordered image previews matching the prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] } /** History-open lifecycle of a Session event window. */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 4bad2bb8c2..7df2341ebb 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -59,6 +60,7 @@ export type { export type { OpenState, PendingSubmission, + PendingSubmissionAttachment, PendingSubmissionImage, PendingSubmissionPlacement, PromptError, @@ -75,6 +77,7 @@ declare module '@deepseek-ai/cordis' { /** Required Remote and Context projection services. */ export const inject = [ + 'connection', 'typert', 'remote', 'remote.commands', @@ -87,8 +90,9 @@ export const inject = [ * @param ctx - Client Cordis context. */ export function apply(ctx: Context): void { + const connection = ctx.get('connection') as ConnectionHandle const remotes = ctx.remote as unknown as SessionRemotes - const sessions = new ClientSessions(ctx, remotes) + const sessions = new ClientSessions(ctx, remotes, connection.backgroundUploads) ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) ctx.remote.$on('api-session/status', (sessionId, running) => { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 47392c86f3..08a2448686 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,6 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' +import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' /** * List arrival lifecycle, orthogonal to the pull-activity `state` axis: @@ -149,6 +150,7 @@ export class SessionManager { private readonly remote: SessionRemotes, restoredSelection?: SessionId, restoredAddress?: SubagentAddress, + private readonly backgroundUploads?: BackgroundUploadTransport, ) { this.selected = restoredSelection if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) @@ -328,6 +330,7 @@ export class SessionManager { this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) }, projections: this.projectionStore(sessionId), + ...(this.backgroundUploads === undefined ? {} : { backgroundUploads: this.backgroundUploads }), }) } diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 985ad8a841..f0ed979c2b 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 -// Image blocks are excluded: queue presentation renders them as thumbnails -// from `content`, so the text preview covers only what has no visual form. +// Attachment blocks are excluded: queue presentation renders them from +// `content`, so the text preview covers only what has no visual form. function previewOf(content: readonly ContentBlock[]): string { const flat = content - .filter(block => block.type !== 'image') + .filter(block => block.type !== 'image' && block.type !== 'file') .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() const chars = Array.from(flat) diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index bec8709b90..c2e585514b 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes */ -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, @@ -19,7 +19,7 @@ export interface SessionCommandsRemote { execute( agentId: SessionId, line: string, - images: readonly EncodedImageAttachment[], + attachments: readonly CommandSubmitAttachment[], signal?: AbortSignal, ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 3e6c8bf6e0..285f7ad014 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -32,6 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts' import { createScope, scopeOf as scopeTagOf } from '../scope.ts' import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' +import type { BackgroundUploadTransport } from '@deepseek-ai/dsh-client-connection/client' import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts' import type { Session } from './session.ts' @@ -221,6 +222,7 @@ export class ClientSessions implements ISessions { constructor( private readonly rootCtx: Context, remote: SessionRemotes, + backgroundUploads?: BackgroundUploadTransport, ) { this.selection = createSnapshotStore( {}, @@ -230,6 +232,7 @@ export class ClientSessions implements ISessions { remote, restored.sessionId, restored.subagentAddress, + backgroundUploads, ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 6e33412cf9..d618dec7c7 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -1,8 +1,11 @@ // Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { + BackgroundUploadProgress, BackgroundUploadTransport, +} from '@deepseek-ai/dsh-client-connection/client' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -15,7 +18,9 @@ import type { SessionControlFrame, SessionQueuedItem, SessionRequestId, + SessionUploadFileValue, } from '../../types.ts' +import { SESSION_FILE_UPLOAD_PATH } from '../../file-upload-path.ts' import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' @@ -28,6 +33,7 @@ import type { } from '../contract/events.ts' import { Notifier } from './notifier.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' @@ -62,6 +68,8 @@ export interface SessionOptions { * private store (bare object-layer construction). */ projections?: ProjectionValueStore + /** Physical large-body carrier supplied by the active browser Connection. */ + backgroundUploads?: BackgroundUploadTransport } /** @@ -196,7 +204,7 @@ export class Session implements SessionFace { : 'transcript', time: Date.now(), text: input.text, - images: input.images, + attachments: input.attachments, }] this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) // The blank → engaging edge flips here, ahead of prompt(): the composer @@ -208,7 +216,7 @@ export class Session implements SessionFace { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - text plus browser-owned temporary image uploads. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. * @param mode - queue appends after the current turn; steer interrupts it. * @param signal - optional caller cancellation for the complete admission round-trip. * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. @@ -238,13 +246,25 @@ export class Session implements SessionFace { content, clientTimeZone, }, signal) + } else if (content.some(part => part.type === 'file')) { + result = { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent continuation does not accept files', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', - content, + content: routedContent, clientTimeZone: resolvedClientTimeZone(), }, signal) result = routed.ok ? { ok: true, value: { accepted: true } } : routed @@ -271,6 +291,52 @@ export class Session implements SessionFace { return result } + /** + * Persist one browser file verbatim and stage it for a later prompt on this + * session (ordinary sessions only; subagent conversations refuse). + * @param data - exact file bytes. + * @param name - optional display name; the host sanitizes the stored leaf name. + * @returns the staged-upload receipt and durable file reference, or the business error. + */ + async uploadFile( + data: Blob | Uint8Array, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: BackgroundUploadProgress) => void, + ): Promise> { + if (this.address !== undefined) { + return { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent conversations do not accept file uploads', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } + } + if (data instanceof Blob && this.options.backgroundUploads !== undefined) { + const query = new URLSearchParams({ sessionId: this.sessionId }) + if (name !== undefined) query.set('name', name) + const response = await this.options.backgroundUploads.post({ + path: `${SESSION_FILE_UPLOAD_PATH}?${query.toString()}`, + body: data, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return parseFileUploadResult(response.body) + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) + return this.remote.session.uploadFile({ + sessionId: this.sessionId, + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, signal) + } + /** * Resolve one image referenced by this session into browser-consumable bytes. * @param attachmentId - opaque id found in the folded session log. @@ -657,7 +723,7 @@ export class Session implements SessionFace { const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return - this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content)) } /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ @@ -665,7 +731,7 @@ export class Session implements SessionFace { if (this.submissionSettlements.size === 0) return for (const item of items) { if (item.rpcId !== undefined) { - this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content)) } } } @@ -678,7 +744,7 @@ export class Session implements SessionFace { */ private scheduleObservedRetirement( requestId: SessionRequestId, - attachments: readonly ImageAttachmentRef[], + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], ): void { const settlement = this.submissionSettlements.get(requestId) if (settlement === undefined || settlement.retiring) return @@ -750,21 +816,62 @@ export class Session implements SessionFace { } } +function parseFileUploadResult(body: string): RemoteResult { + const value = JSON.parse(body) as unknown + if (!isRecord(value) || typeof value.ok !== 'boolean') { + throw new TypeError('file upload transport returned an invalid result') + } + if (!value.ok) { + const error = value.error + if (!isRecord(error) || typeof error.code !== 'string' + || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('file upload transport returned an invalid failure') + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + } + } + const result = value.value + const file = isRecord(result) ? result.file : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' + || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new TypeError('file upload transport returned an invalid receipt') + } + return { + ok: true, + value: { + receiptId: result.receiptId as SessionUploadFileValue['receiptId'], + file: { + attachmentId: file.attachmentId as SessionUploadFileValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ function scheduleFrame(fn: () => void): void { if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) else setTimeout(fn, 0) } -/** Image attachment references in one structurally-read content block list, in block order. */ -function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { if (!Array.isArray(content)) return [] - const refs: ImageAttachmentRef[] = [] + const refs: Array = [] for (const block of content) { if (typeof block !== 'object' || block === null) continue const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } - if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { - refs.push(candidate.attachment as ImageAttachmentRef) + if ((candidate.type === 'image' || candidate.type === 'file') + && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef) } } return refs diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 030ed712d1..d88247a535 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -7,6 +7,7 @@ }, "include": [ "src/client/**/*.ts", + "src/file-upload-path.ts", "src/types.ts", "src/remote-events.ts" ], diff --git a/packages/client/connection/src/client/background-upload.ts b/packages/client/connection/src/client/background-upload.ts new file mode 100644 index 0000000000..7d6b87315d --- /dev/null +++ b/packages/client/connection/src/client/background-upload.ts @@ -0,0 +1,184 @@ +/** Background browser upload transport for large opaque request bodies. */ + +import type { RpcFetch } from './rpc.ts' + +/** Monotone byte progress reported by a browser upload carrier. */ +export interface BackgroundUploadProgress { + readonly loaded: number + readonly total?: number +} + +/** One background upload request. */ +export interface BackgroundUploadRequest { + readonly path: string + readonly body: Blob + readonly headers?: Readonly> + readonly signal?: AbortSignal + readonly onProgress?: (progress: BackgroundUploadProgress) => void +} + +/** Small response returned after the background carrier has sent the body. */ +export interface BackgroundUploadResponse { + readonly status: number + readonly body: string +} + +/** Browser carrier that keeps file reads and network submission off the page thread. */ +export interface BackgroundUploadTransport { + /** + * Post one Blob without materializing its bytes on the page thread. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: BackgroundUploadRequest): Promise +} + +interface UploadWorkerStart { + readonly url: string + readonly body: Blob + readonly headers: Readonly> +} + +type UploadWorkerOutput = + | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } + | { readonly kind: 'complete'; readonly status: number; readonly body: string } + | { readonly kind: 'error'; readonly message: string } + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: UploadWorkerOutput): void +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open(method: string, url: string): void + setRequestHeader(name: string, value: string): void + send(body: Blob): void +} + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for request and progress messages. + * @param createXhr - XMLHttpRequest factory; injectable for unit coverage. + */ +export function backgroundUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data + const xhr = createXhr() + xhr.open('POST', request.url) + xhr.withCredentials = true + for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: 'progress', + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput) + } + xhr.onload = () => { + scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) + } + xhr.onerror = () => { + scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) + } + xhr.send(request.body) + } +} + +/** + * Create a background body carrier. A custom fetch already targets a Host + * Worker, while the served Web path creates a dedicated upload Worker. + * @param customFetch - worker-hosted transport hook, when present. + * @returns the selected background carrier. + */ +export function createBackgroundUploadTransport(customFetch?: RpcFetch): BackgroundUploadTransport { + return customFetch === undefined ? workerTransport() : customTransport(customFetch) +} + +function customTransport(customFetch: RpcFetch): BackgroundUploadTransport { + return { + async post(request) { + const response = await customFetch(resolveUrl(request.path), { + method: 'POST', + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.signal === undefined ? {} : { signal: request.signal }), + }) + return { status: response.status, body: await response.text() } + }, + } +} + +function workerTransport(): BackgroundUploadTransport { + return { + post(request) { + if (typeof Worker !== 'function') { + return Promise.reject(new Error('background upload requires Web Worker support')) + } + const workerUrl = URL.createObjectURL(new Blob([ + `(${backgroundUploadWorker.toString()})()`, + ], { type: 'text/javascript' })) + const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) + URL.revokeObjectURL(workerUrl) + return new Promise((resolve, reject) => { + let settled = false + const abort = (): void => { + settled = true + worker.terminate() + request.signal?.removeEventListener('abort', abort) + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const finish = (settle: () => void): void => { + if (settled) return + settled = true + request.signal?.removeEventListener('abort', abort) + worker.terminate() + settle() + } + worker.onmessage = (event: MessageEvent) => { + const output = event.data + if (output.kind === 'progress') { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }) + } else if (output.kind === 'complete') { + finish(() => { resolve({ status: output.status, body: output.body }) }) + } else { + finish(() => { reject(new Error(output.message)) }) + } + } + worker.onerror = (event) => { + finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) + } + if (request.signal?.aborted === true) { + abort() + return + } + request.signal?.addEventListener('abort', abort, { once: true }) + worker.postMessage({ + url: resolveUrl(request.path).href, + body: request.body, + headers: request.headers ?? {}, + } satisfies UploadWorkerStart) + }) + }, + } +} + +function resolveUrl(path: string): URL { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + const origin = typeof pageLocation === 'object' && pageLocation !== null + && 'origin' in pageLocation && typeof pageLocation.origin === 'string' + ? pageLocation.origin + : undefined + return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index c79b8caf20..06a71610d2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2099,13 +2099,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], } }, - execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { + execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -2116,17 +2116,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Mirror the Host image policy AFTER command resolution, matching the // executor's order (an unknown name answers undefined and logs no // lifecycle): the declaration rejection covers every known command - // without `input.images`, and the two producer grammar rejections cover + // without `input.attachments`, and the two producer grammar rejections cover // the declaring commands' control-only lines. The fixture stores no // bytes, so an accepted batch is acknowledged and dropped. const known = ['permission', 'goal', 'compact', 'echo', 'plan'] - if (images.length > 0 && name !== undefined && known.includes(name)) { + if (attachments.length > 0 && name !== undefined && known.includes(name)) { const rejection = name !== 'goal' && name !== 'plan' - ? `/${name} does not accept image attachments` + ? `/${name} does not accept attachments` : name === 'goal' && args.trim() === '' - ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + ? 'Attachments only accompany a goal objective: /goal or /goal edit .' : name === 'plan' && args.trim() === 'off' - ? 'Image attachments cannot accompany /plan off.' + ? 'Attachments cannot accompany /plan off.' : undefined if (rejection !== undefined) { const commandId = `fx-cmd-${logOf(id).length}` as CommandId diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 482ef2db91..b70e40c59e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,7 +1,4 @@ -/** - * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets API Gateway own the connection loop. - */ +/** Browser wire client: Remote transport, connection generations, and background uploads. */ import type { Context } from '@deepseek-ai/cordis' import { ConnectionController, @@ -13,6 +10,10 @@ import { } from './connection.ts' import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' +import { + createBackgroundUploadTransport, + type BackgroundUploadTransport, +} from './background-upload.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -53,6 +54,12 @@ export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' +export type { + BackgroundUploadProgress, + BackgroundUploadRequest, + BackgroundUploadResponse, + BackgroundUploadTransport, +} from './background-upload.ts' /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { @@ -107,9 +114,8 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot controller - * starter. API Gateway supplies generation readiness and reset callbacks; - * Connection stays independent of downstream domain state. + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** @@ -124,6 +130,8 @@ export interface ConnectionHandle { readonly state: ConnectionStateSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc + /** Large-body carrier; absent for the in-page fixture transport. */ + readonly backgroundUploads?: BackgroundUploadTransport /** Reset retry progression and replace the current attempt immediately. */ reconnect(): void /** @@ -178,7 +186,7 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { } /** - * Client plugin body: pick the api by page mode and provide ctx.connection. + * Client plugin body: pick physical carriers by page mode and provide ctx.connection. * @param ctx - client cordis context. */ export function apply(ctx: Context): void { @@ -187,6 +195,9 @@ export function apply(ctx: Context): void { const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const backgroundUploads = fixtureRpc === undefined + ? createBackgroundUploadTransport(transport?.fetch) + : undefined let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 @@ -241,6 +252,7 @@ export function apply(ctx: Context): void { }, }, rpc, + ...(backgroundUploads === undefined ? {} : { backgroundUploads }), reconnect() { owner?.controller.reconnect() }, diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index b404e65103..ff6042d117 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -4,6 +4,8 @@ */ import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' +import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default * aggregate image limit (200 MiB) after base64 expansion plus envelope @@ -11,28 +13,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 -/** Transport-independent request handler consumed by the Host HTTP bridge. */ -export interface FetchHandler { - /** - * Handle one standard Fetch request. - * @param request - request produced by the active transport bridge. - * @returns complete or streaming Fetch response. - */ - fetch(request: Request): Promise -} - /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; response bodies stream out chunk by chunk). - * @param req - incoming node:http request (fully read before dispatch). + * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. - * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: ServerResponse, - apiHandler: FetchHandler, + apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -44,38 +36,57 @@ export async function bridge( res.on('close', () => { if (!res.writableEnded) abort.abort() }) - const declaredLength = req.headers['content-length'] - if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { - res.writeHead(413, { connection: 'close' }) - res.end() - req.destroy() - return - } - const chunks: Buffer[] = [] - let received = 0 - for await (const chunk of req) { - const buffer = chunk as Buffer - received += buffer.byteLength - if (received > maxRequestBodyBytes) { + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const method = req.method ?? 'GET' + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][], + ) + const bodyMode = apiHandler.requestBodyMode({ method, url }) + let request: Request + if (bodyMode === 'buffered') { + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { res.writeHead(413, { connection: 'close' }) res.end() req.destroy() return } - chunks.push(buffer) + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } + request = new Request(url, { + method, + headers, + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) } - /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server - requests; the fields are only optional on the client-side IncomingMessage type */ - const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + const requestUnread = bodyMode === 'streaming' && !req.readableEnded + const responseHeaders = Object.fromEntries(response.headers.entries()) + res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) if (response.body === null) { res.end() + if (requestUnread) req.destroy() return } for await (const chunk of response.body) { @@ -96,4 +107,5 @@ export async function bridge( } } res.end() + if (requestUnread) req.destroy() } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34cf79bd65..a274cef190 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -22,6 +22,7 @@ export type { ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, + ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a9bbc72954..d74734e132 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -8,7 +8,7 @@ import { type RpcId as RpcIdType, } from './rpc.ts' import { clientRequestSchema } from './rpc-schema.ts' -import { bridge, type FetchHandler } from './http-bridge.ts' +import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' @@ -34,11 +34,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher - readonly fetchHandler: FetchHandler + readonly fetchHandler: ConnectionFetchHandler } interface RegisteredFetchRoute { readonly methods: ReadonlySet + readonly requestBody: ConnectionFetchRoute['requestBody'] readonly fetch: ConnectionFetchRoute['fetch'] } @@ -117,6 +118,10 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: '/api', ): ConnectionFetchHandler { return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname) + return route?.methods.has(method) === true ? route.requestBody : 'buffered' + }, fetch: (request) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) @@ -138,6 +143,7 @@ export class HostConnectionService extends Service implements HostConnectionHand assertFetchRoute(route) const registered: RegisteredFetchRoute = { methods: new Set(route.methods), + requestBody: route.requestBody, fetch: route.fetch, } return owner.effect(() => { @@ -203,8 +209,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): FetchHandler { +): ConnectionFetchHandler { return { + requestBodyMode: () => 'buffered', async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 12c8f198be..9474d72c36 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -107,7 +107,10 @@ export type ConnectionRpcHandler = ( export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** HTTP methods supported by exact Fetch routes on the shared API channel. */ -export type ConnectionFetchMethod = 'GET' | 'HEAD' +export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST' + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = 'buffered' | 'streaming' /** One exact, transport-independent Fetch route owned by a Host feature. */ export interface ConnectionFetchRoute { @@ -115,6 +118,8 @@ export interface ConnectionFetchRoute { readonly path: string /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ readonly methods: readonly ConnectionFetchMethod[] + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode /** Handle one request after the physical carrier has applied its trust and authentication policy. */ readonly fetch: (request: Request) => Promise } @@ -196,6 +201,13 @@ export interface HostConnectionHandle { /** Transport-independent Fetch handler used by HTTP and worker carriers. */ export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode + /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 1ce037462f..a4549c4970 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -8,6 +8,7 @@ "files": [ "src/api-path.ts", "src/client/api.ts", + "src/client/background-upload.ts", "src/client/connection.ts", "src/client/fixture.ts", "src/client/index.ts", diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 1d04fe7eb7..384597a9b2 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,7 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', - 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'notice.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', } satisfies Record /** The command namespace key union. */ @@ -24,5 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', - 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..a8b1c86dad 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -19,7 +19,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, + SubmitAttachment, SubmitEnvelope, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -310,10 +310,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * bare host commands act on the bare token only; leadingInput claims * args-tolerant. * - * Envelope policy: an enter submission carrying images resolves only - * through a command declaring image acceptance. Every other command route — + * Envelope policy: an enter submission carrying attachments resolves only + * through a command declaring attachment acceptance. Every other command route — * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and images + * so the machine surfaces one composer notice and the draft and attachments * stay in place; nothing executes and nothing is dropped. */ private async matchEnter( @@ -329,13 +329,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined - const refuseImages = (): never => { - throw new Error(this.t('notice.imagesUnsupported', { command: name })) + const refuseAttachments = (): never => { + throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -347,17 +347,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } if (desc.input !== undefined) { - if (envelope.images > 0 && desc.input.images !== true) refuseImages() + if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() return { claim: this.leadingClaim(desc, session) } } if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -381,8 +381,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), - submit: (args, _actx, images) => this.execute(session, token + args, images), + ...(desc.input?.attachments === true ? { attachments: true } : {}), + submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), } } @@ -394,21 +394,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the - * composer keeps the submission (draft and images) for correction. + * composer keeps the draft and attachments for correction. * A refused call throws. */ private async execute( session: ClientSessionContext, line: string, - images: readonly SubmitImageAttachment[] = [], + attachments: readonly SubmitAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) - // An image-carrying submission consumed its images only on handler - // success; an error outcome keeps draft and images in the composer. - if (images.length > 0 && result.value.result.kind === 'error') { + // A submission consumes its attachments only after handler success; an + // error outcome keeps the draft and attachments in the composer. + if (attachments.length > 0 && result.value.result.kind === 'error') { return { kind: 'error', text: result.value.result.text } } return { kind: 'success' } diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 4860b5ce9d..9141210c0e 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -25,7 +25,7 @@ export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, - InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + InputTriggerSource, SubmitAttachment, SubmitEnvelope, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index bd862a71a1..acbe2a65a3 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -14,7 +14,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, - InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -95,8 +95,8 @@ export interface HeaderRequest { * presence to accept or refuse a whole submission. */ export interface SubmitEnvelope { - /** Number of image attachments accompanying the draft. */ - readonly images: number + /** Number of attachments accompanying the draft. */ + readonly attachments: number } /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 5bf98cc640..4d5a1d28e0 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -119,10 +119,11 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne } } -/** Normalize a RequestInit body to a transferable ArrayBuffer. */ -function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined { +/** Keep opaque Blobs clone-cheap; normalize text and typed arrays to transferable bytes. */ +function toTunnelBody(body: RequestInit['body']): ArrayBuffer | Blob | undefined { if (body === undefined || body === null) return undefined if (typeof body === 'string') return encoder.encode(body).buffer + if (body instanceof Blob) return body if (body instanceof ArrayBuffer) return body if (ArrayBuffer.isView(body)) { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) @@ -205,7 +206,7 @@ export class WorkerTunnel { headers: Object.fromEntries(new Headers(init?.headers).entries()), ...(init?.body === undefined || init.body === null ? {} - : { body: toBodyBuffer(init.body) }), + : { body: toTunnelBody(init.body) }), } const response = new Promise((resolve, reject) => { this.unary.set(id, { resolve, reject }) diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index a04a2cec5a..e591fac007 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -14,7 +14,7 @@ export interface TunnelRequestFrame { readonly method: string readonly url: string readonly headers: Readonly> - readonly body?: ArrayBuffer | undefined + readonly body?: ArrayBuffer | Blob | undefined } /** Open one Gateway Remote stream over the worker-local carrier. */ @@ -171,8 +171,8 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof value === 'string') headers[key.toLowerCase()] = value } const body = frame.body - if (body !== undefined && !(body instanceof ArrayBuffer)) { - throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`) + if (body !== undefined && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) { + throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer or Blob`) } return { t: 'req', id, method: frame.method, url: frame.url, headers, body } } diff --git a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts index a10634e493..29e5ea4926 100644 --- a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts +++ b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts @@ -66,7 +66,15 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons headers: frame.headers, destroy: (): void => { aborted = true }, async *[Symbol.asyncIterator](): AsyncGenerator { - if (frame.body === undefined || frame.body.byteLength === 0) return + if (frame.body === undefined) return + if (frame.body instanceof Blob) { + for await (const chunk of frame.body.stream()) { + if (aborted) return + if (chunk.byteLength > 0) yield chunk + } + return + } + if (aborted || frame.body.byteLength === 0) return yield new Uint8Array(frame.body) }, } @@ -133,6 +141,7 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons if (finished) return aborted = true finished = true + emit('aborted') emit('close') }, } diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 55c4f977ef..1be859503f 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -433,13 +433,29 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentContext', declaration: 'export type AgentContext = Omit & {\n readonly remote: ClientRemote & TypertRemoteScopeApi<\'agent\'>;\n};', }, + { + name: 'BackgroundUploadProgress', + declaration: 'export interface BackgroundUploadProgress {\n readonly loaded: number;\n readonly total?: number;\n}', + }, + { + name: 'BackgroundUploadRequest', + declaration: 'export interface BackgroundUploadRequest {\n readonly path: string;\n readonly body: Blob;\n readonly headers?: Readonly>;\n readonly signal?: AbortSignal;\n readonly onProgress?: (progress: BackgroundUploadProgress) => void;\n}', + }, + { + name: 'BackgroundUploadResponse', + declaration: 'export interface BackgroundUploadResponse {\n readonly status: number;\n readonly body: string;\n}', + }, + { + name: 'BackgroundUploadTransport', + declaration: 'export interface BackgroundUploadTransport {\n post(request: BackgroundUploadRequest): Promise;\n}', + }, { name: 'BakedActions', declaration: 'export type BakedActions> = {\n [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never;\n};', }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', @@ -499,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly state: ConnectionStateSource;\n readonly rpc: ClientConnectionRpc;\n readonly backgroundUploads?: BackgroundUploadTransport;\n reconnect(): void;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): ConnectionLoop;\n}', }, { name: 'ConnectionHostInfo', @@ -533,6 +549,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EntryKeyOf', declaration: 'export type EntryKeyOf = SlotMap[K] extends {\n kind: \'keyed\';\n keyProps: infer P extends object;\n} ? keyof P & string : string;', }, + { + name: 'FileUploadReceiptId', + declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', + }, { name: 'GlobalStandardProps', declaration: 'export interface GlobalStandardProps {\n}', @@ -559,7 +579,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n uploadFile(data: Blob | Uint8Array, name?: string, signal?: AbortSignal, onProgress?: (progress: {\n readonly loaded: number;\n readonly total?: number;\n }) => void): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: number): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', @@ -615,7 +635,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmission', - declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', + declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n}', + }, + { + name: 'PendingSubmissionAttachment', + declaration: 'export type PendingSubmissionAttachment = ({\n readonly type: \'image\';\n} & PendingSubmissionImage) | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', }, { name: 'PendingSubmissionImage', @@ -627,7 +651,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmissionRetirement', - declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};', + declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[];\n} | {\n readonly reason: \'failed\';\n};', }, { name: 'ProjectionsFace', @@ -635,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: FileUploadReceiptId;\n};', }, { name: 'PromptError', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 81890bfd98..f5a05c5dbf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -407,7 +407,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', }, { key: 'conversation.composer.bar', @@ -443,7 +443,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.details.tool', @@ -565,7 +565,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.hero.brand.mark', @@ -591,7 +591,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.hero.workspace', @@ -621,7 +621,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -656,15 +656,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail and drop target.', - doc: 'Optional draft-image rail and drop target.', + summary: 'Optional draft-attachment rail and drop target.', + doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', + 'DraftFileUploads', ], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', @@ -687,7 +688,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.input.dock', @@ -747,7 +748,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153', }, { key: 'conversation.input.left', @@ -803,7 +804,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.input.model', @@ -839,7 +840,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.overlay', @@ -893,7 +894,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', }, { key: 'conversation.input.plan', @@ -929,7 +930,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.input.right', @@ -985,7 +986,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.message.images', @@ -995,7 +996,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1059,7 +1060,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'conversation.session.header', @@ -1093,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header.actions', @@ -1151,7 +1152,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'conversation.session.header.lineage', @@ -1189,7 +1190,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.utilities', @@ -1244,7 +1245,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.trajectory.images', @@ -1254,7 +1255,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1342,7 +1343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143', }, { key: 'details', diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index 419e155af7..5c3b8b0250 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -120,6 +120,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `uploadFile` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + uploadFile(): never { + throw new Error(`test session "${this.sessionId}": uploadFile is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. * @returns never — always throws.