20 KiB
Agent Note: Web multimodal image input and durable attachments
Status: implemented
English | 中文
Problem
Before this change, the Web composer accepted only text: InputBar received a string draft, ConversationService.send() created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history.
This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. The previous image-block removal rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content.
The Web client architecture keeps components pure and per-session composer state in ctx.conversation; the GUI layering and RPC protocol makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle.
Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as /var/folders/.../codex-clipboard-*.png are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them.
Decision
Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral ImageBlock references.
Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups.
Product behavior
- Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target.
- The rail is shared by the empty-state and resident composers, is hidden when empty, and scrolls horizontally instead of widening the composer.
- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click.
- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally.
- A failed send restores the complete text and image draft. Removal, successful send, empty-state disposal, rendered-session disposal, and application disposal revoke the object URLs they own.
- Historical user and assistant images use one
MessageImagecontrol. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. - Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus.
- Version one does not override the browser context menu and provides no explicit image-copy action.
Storage lifecycle and ownership
The persistence boundary is message acceptance, not paste:
| State | Allowed representation | Durability and ordering |
|---|---|---|
| Unsent user draft | Browser File plus object URL; a native client may use an OS temporary file such as /var/... |
Temporary and client-owned. It may disappear on reload or process exit and never appears in a session event. |
| Accepted user image | Immutable object below DSH_HOME plus ImageAttachmentRef |
The host commits every image before agent.send() or agent.steer() can append the owning user event. |
| Structured model image output | Immutable object below DSH_HOME plus ImageAttachmentRef |
The provider adapter commits the bytes before it emits a completed image block or assistant message event. Temporary URLs, paths, and base64 are forbidden in the event. |
The framework-owned chat store keeps the per-session draft text and ordered attachment identifiers. ConversationService owns the corresponding browser-only File and object-URL registry:
export {}
interface ChatStoreState {
selection: object | null
draft: string
imageIds: string[]
view: string | null
}
interface ComposerAttachment {
kind: 'image'
id: string
file: File
previewUrl: string
}
This split uses the slots framework's store seat and bound actions as the single subscription path for UI state while keeping non-serializable browser objects out of persisted JSON. Draft text and ordered image identifiers continue to use localStorage; after a reload, ConversationRoot prunes identifiers whose runtime objects no longer exist. Unsent images therefore do not survive reload because browser File and object URLs are not durable. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance.
The local attachment backend resolves an explicit dshHome, then $DSH_HOME, then ~/.dsh. It stores content-addressed objects below $DSH_HOME/attachments/v1/objects/<prefix>/<sha256> with owner-only directory and file permissions. A temporary file is written, synchronized, and atomically published before the service returns a reference. The content digest is encoded in the opaque sha256:<digest> identifier, and every read verifies the digest, media type, byte length, width, and height.
The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history.
Durable content and prompt wire
The attachment seam exposes immutable image write and verified read operations. The canonical metadata is deliberately narrower than a generic file record:
import type { Branded } from '@deepseek-ai/dsh-brand'
type AttachmentId = Branded<'AttachmentId'>
interface ImageAttachmentRef {
attachmentId: AttachmentId
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
bytes: number
width: number
height: number
name?: string
}
interface ImageBlock {
type: 'image'
attachment: ImageAttachmentRef
}
ImageBlock joins the merge-extensible core ContentBlockMap and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. The LLM vocabulary therefore has a type-only dependency on the attachment seam; provider runtime dependencies remain adapter-specific.
The browser cannot mint a durable reference, so session.prompt accepts a narrow intake union rather than canonical ContentBlock[]:
export {}
type PromptInputPart =
| { type: 'text'; text: string }
| {
type: 'image'
mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
data: string
name?: string
}
Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, magic-byte MIME, intrinsic dimensions, and decoded-pixel count. Only after every image succeeds does it call the agent with normalized text and durable image blocks. A failure appends no user event and exposes no attachment path or raw bytes.
session.attachment is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and invalidates late loads so an unmounted session cannot repopulate the cache.
Model capabilities and provider behavior
Model catalog entries gain optional merge-extensible input and output modality declarations. A missing declaration means unknown; a present list without image is an explicit negative capability.
The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. host.describe projects the default model and image limits into SessionsService; both composers use the limits before allocating object URLs or base64, while only the no-session composer uses the default model for early explicit text-only feedback. Decoded-pixel validation and every resident session's actual route remain authoritative on the host.
The Pi-AI adapter is the first visual-input route: it resolves ctx.attachments at request time, then resolves each durable reference and emits native image content only for models that declare image input. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed UNSUPPORTED_CONTENT for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image.
Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic ImageBlock. A URL in assistant Markdown remains text and is never downloaded automatically.
Token estimation accounts for image dimensions without counting base64 or attachment locators as text. Provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block.
Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and compact-basic rejects image summary output with UNSUPPORTED_CONTENT.
History rendering and original preview
History folding preserves ImageBlock in both user and assistant messages. User images align to the trailing edge above their text; assistant images remain in their original content-block position in the leading narration flow. MessageImage derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses object-fit: contain, and turns a missing or corrupt object into a retryable error control.
Composer thumbnails and each MessageImage own ephemeral original-preview state and invoke the same pure ImageLightbox. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed.
Limits and trust boundaries
Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and are projected to the client for fast-path guidance; host validation remains authoritative. The Web carrier independently caps buffered API request bodies, with dsh web deriving its default from the aggregate image limit plus base64/envelope expansion and allowing an explicit override.
Malformed base64, unsupported or mismatched media, truncated headers, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser.
Package and surface changes
| Surface | Responsibility |
|---|---|
packages/attachment/attachment |
Opaque attachment identifier, image reference, limits, failures, and ctx.attachments service. |
packages/attachment/attachment-local |
Private content-addressed storage, image-header validation, integrity verification, and configuration. |
packages/llm/llm and packages/llm/token-meter |
Role-neutral ImageBlock, modality metadata, and image cost estimation. |
packages/llm/llm-pi-ai |
Resolve durable supported image input into native provider content. |
packages/llm/llm-deepseek |
Reject image content explicitly. |
packages/compact/compact-basic |
Preserve images in summary input and reject non-text checkpoint output explicitly. |
packages/host/apiproxy and packages/host/runtime |
Narrow upload wire, persist-before-event ordering, session-authorized reads, limits, and model preflight. |
packages/host/webserver |
Bound buffered API request bodies before the fetch carrier. |
packages/client/connection and packages/client/runtime |
Wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. |
packages/client/ui-conversation |
Per-session draft images, attachment rail, user and assistant image controls, and original preview. |
packages/ui/acp |
Explicit fallback rendering for image blocks. |
The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in agent-loop.
Implementation
The implemented slice includes the attachment seam, role-neutral image block, image-aware token estimation, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, host-capability projection, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage.
No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice.
Alternatives considered
Keep every intake image in /var or another temporary directory
Temporary storage is appropriate before send, including for a native client that receives clipboard files through the operating system. It is not appropriate after acceptance: cleanup is outside the harness's control, paths are host-specific, and resume or fork can outlive the file. The proposal permits temporary staging but copies accepted bytes into DSH_HOME before the event.
Persist immediately on paste or drop
Immediate persistence makes drafts reload-resistant but creates durable objects before a session or message owns them, which requires quota, orphan lifetime, and cleanup policy. Version one keeps the unsent draft temporary and makes send acceptance the durability boundary.
Inline base64 in messages and session logs
This duplicates binary data across RPC, events, history pages, forks, compaction, and browser storage, and invites token accounting to treat encoding text as model text. One immutable object plus small references keeps the durable representation bounded.
Use browser object URLs, local paths, or provider URLs as canonical content
Object URLs expire with the document, local paths are not portable, and provider URLs may expire, track viewers, or expose credentials. They remain temporary transport or preview details only.
Use one generic AttachmentBlock for images, files, audio, and video
Composer presentation can use a generic attachment rail, but provider semantics are modality-specific. Images are native multimodal input; PDFs may be provider files or extracted text; video may be native, sampled, or unsupported. A specific ImageBlock forces every consumer to handle or reject the modality explicitly.
Rely on UI capability checks or silently filter images
UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback.
Testing
- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered.
- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, and bounded HTTP request bodies.
- Client unit and assembled Chromium tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, historical user and assistant images, original preview, ordering, and draft/session/application object-URL cleanup.
- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, nested tool-result images, preserved summary input, and explicit image-output rejection.
- A credentialed real-API test sends a PNG through the Anthropic
claude-opus-4-8route and requires the model to identify its QR code. - The current production adapter set declares text-only output; output-provider certification remains outside version one.
Consequences
- Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion.
- A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired.
- JSON-RPC base64 adds upload memory and roughly one-third encoding overhead. Version-one limits bound it; larger media needs streaming or a binary transport.
- Unsent images do not survive reload. Durable drafts need quota and orphan cleanup rather than reusing message storage implicitly.
- Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory.
- Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative.
- A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity.
- File picking, generic files/PDF, audio/video, durable draft staging, image copying, custom context menus, output-provider certification, and reference-aware garbage collection remain independent designs.