refactor: narrow web image input v1

This commit is contained in:
Tianyi Cui
2026-07-29 23:18:57 +08:00
parent 8348a409aa
commit 3a4c436d83
69 changed files with 280 additions and 613 deletions

View File

@@ -6,19 +6,13 @@ import z from 'schemastery'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
import { readImageFile, saveImageFile } from './store.ts'
export { detectImage } from './image.ts'
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
export { AttachmentError } from '@deepseek-ai/dsh-attachment'
export type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
export { readImageFile, saveImageFile } from './store.ts'
/** Default maximum encoded bytes for one image. */
export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024
/** Default maximum images in one prompt. */
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10
/** Default maximum aggregate image bytes in one prompt. */
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024
/** Default maximum intrinsic pixels for one image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
@@ -28,10 +22,6 @@ export interface Config {
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
maxImagePixels?: number
}
@@ -41,8 +31,6 @@ export class LocalAttachmentStore extends AttachmentStore {
static Config: z<Config> = z.object({
dshHome: z.string(),
maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES),
maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE),
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
})
@@ -55,17 +43,11 @@ export class LocalAttachmentStore extends AttachmentStore {
this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1'))
this.imageLimits = Object.freeze({
maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
})
}
validateImage(input: SaveImageAttachment): void {
validateImageFile(input, this.imageLimits)
}
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return saveImageFile(this.root, input, this.imageLimits)
}

View File

@@ -52,15 +52,6 @@ function validateAdmission(metadata: Omit<ImageAttachmentRef, 'attachmentId' | '
}
}
/**
* Run the full admission policy for one image without touching storage.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
*/
export function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): void {
validateAdmission(inspectMetadata(input.data, input.mediaType), limits)
}
/**
* Make a directory's entries durable (fsync on a read-only directory handle).
* A synced file alone does not survive a crash when its directory entry never

View File

@@ -1,5 +1,4 @@
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -7,8 +6,6 @@ import { describe, expect, it } from 'vitest'
import LocalAttachmentStore, {
DEFAULT_MAX_IMAGE_BYTES,
DEFAULT_MAX_IMAGE_PIXELS,
DEFAULT_MAX_IMAGES_PER_MESSAGE,
DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
} from '../src/index.ts'
describe('local attachment service', () => {
@@ -16,8 +13,6 @@ describe('local attachment service', () => {
const service = new LocalAttachmentStore(new Context(), {})
expect(service.imageLimits).toEqual({
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES,
maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
@@ -37,22 +32,4 @@ describe('local attachment service', () => {
await rm(dshHome, { recursive: true, force: true })
}
})
it('validates without persisting: a rejected image leaves no storage root behind', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
expect(() => { service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }) })
.toThrow(/Unsupported or malformed image data/)
const valid = Uint8Array.from(Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
expect(() => { service.validateImage({ data: valid, mediaType: 'image/png' }) }).not.toThrow()
// Validation is storage-free: nothing below the root may exist yet.
expect(existsSync(service.root)).toBe(false)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
})

View File

@@ -28,8 +28,6 @@ const PNG = Uint8Array.from(Buffer.from(
const LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 2,
maxMessageImageBytes: 2048,
maxImagePixels: 16,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md
README.md: b8cee33b21caf5851f5c571ee7385fbd1b81dc97
README.zh.md: 53107bd0f57b73043073295a9698df927e457365
README.md: b25ef7bcf89b3b85600ed02ab12eb0cdd1117244
README.zh.md: f45933c2a011978b31a306a1de80d7f031838c8e

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
Unsent composer images remain browser-owned temporary drafts. `saveImage` is called only at message submission or while committing structured provider output, before any model-visible session event is published. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so one malformed member cannot strand earlier members as unreferenced objects (there is no garbage collection). `readImage` verifies the content-addressed object against its logged metadata.
Unsent composer images remain browser-owned temporary drafts. `saveImage` validates and commits one image at message submission or while committing structured provider output, before any model-visible session event is published. `readImage` verifies the content-addressed object against its logged metadata.
## Model Experience

View File

@@ -4,7 +4,7 @@
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
未发送的输入区图片仍是由浏览器持有的临时草稿。只有在提交消息或提交结构化提供方输出时,才会调用 `saveImage`,并且必须先于任何模型可见的会话事件发布。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象(系统不提供垃圾回收)`readImage` 根据已记录的元数据校验内容寻址对象。
未发送的输入区图片仍是由浏览器持有的临时草稿。`saveImage` 在提交消息或提交结构化提供方输出时校验并提交一张图片,且发生在发布任何模型可见的会话事件之前`readImage` 根据已记录的元数据校验内容寻址对象。
## 模型体验

View File

@@ -33,14 +33,6 @@ export abstract class AttachmentStore extends Service {
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
abstract readonly imageLimits: ImageAttachmentLimits
/**
* Validate one image against the deployment policy without persisting anything.
* Callers persisting a multi-image batch validate every member first so a
* malformed member cannot leave earlier members as unreferenced objects.
* @param input - encoded bytes, declared media type, and optional display name.
*/
abstract validateImage(input: SaveImageAttachment): void
/**
* Validate and durably commit one image before its owning session event is appended.
* @param input - encoded bytes, declared media type, and optional display name.

View File

@@ -36,8 +36,6 @@ export interface ImageAttachmentRef {
/** Deployment-resolved limits shared by upload consumers and UI preflight. */
export interface ImageAttachmentLimits {
maxImageBytes: number
maxImagesPerMessage: number
maxMessageImageBytes: number
maxImagePixels: number
mediaTypes: readonly ImageMediaType[]
}