fix: preserve multi-image prompt batches

This commit is contained in:
Tianyi Cui
2026-07-30 00:58:35 +08:00
parent 60c8f5ea69
commit 6312e43a11
35 changed files with 336 additions and 149 deletions

View File

@@ -6,13 +6,17 @@ 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 } from './store.ts'
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
export { detectImage } from './image.ts'
export { readImageFile, saveImageFile } from './store.ts'
export { readImageFile, saveImageFile, validateImageFile } 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
@@ -22,6 +26,10 @@ 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
}
@@ -31,6 +39,8 @@ 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),
})
@@ -43,11 +53,17 @@ 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,6 +52,15 @@ 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,4 +1,5 @@
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'
@@ -6,6 +7,8 @@ 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', () => {
@@ -13,6 +16,8 @@ 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'],
})
@@ -32,4 +37,21 @@ 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()
expect(existsSync(service.root)).toBe(false)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
})

View File

@@ -28,6 +28,8 @@ 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: b25ef7bcf89b3b85600ed02ab12eb0cdd1117244
README.zh.md: f45933c2a011978b31a306a1de80d7f031838c8e
README.md: 4f450316294e554396adb9a8454051a08d9befd3
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890

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` 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.
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `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` 在提交消息或提交结构化提供方输出时校验并提交一张图片,且发生在发布任何模型可见的会话事件之前。`readImage` 根据已记录的元数据校验内容寻址对象。
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
## 模型体验

View File

@@ -33,6 +33,13 @@ export abstract class AttachmentStore extends Service {
/** Deployment-resolved image policy used by authoritative and fast-path validation. */
abstract readonly imageLimits: ImageAttachmentLimits
/**
* Validate one image without persisting it.
* Batch callers validate every member before saving any member.
* @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,6 +36,8 @@ 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[]
}

View File

@@ -1262,6 +1262,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
model: 'fx-vision',
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 10,
maxMessageImageBytes: 20 * 1024 * 1024,
maxImagePixels: 40_000_000,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
},

View File

@@ -52,7 +52,7 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const maxRequestBodyBytes = Math.ceil(
ctx.attachments.imageLimits.maxImageBytes * 4 / 3,
ctx.attachments.imageLimits.maxMessageImageBytes * 4 / 3,
) + REQUEST_ENVELOPE_HEADROOM_BYTES
const route: WebRoute = {
kind: 'prefix',

View File

@@ -23,7 +23,7 @@ function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register'
/** Structural attachments fake: the plugin only reads imageLimits for the body cap. */
function fakeAttachments(): AttachmentStore {
return { imageLimits: { maxImageBytes: 5 * 1024 * 1024 } } as AttachmentStore
return { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 } } as AttachmentStore
}
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */

View File

@@ -283,11 +283,14 @@ export class ConversationService extends Service implements IConversation {
): void {
if (files.length === 0 && current.length === 0) return
// Model capability is checked only by the host against the session's
// current target; the client owns deployment limits and the one-image UI.
// current target; the client owns deployment upload limits.
const description = this.requireSessions().hostDescription()
const limits = description?.imageLimits
const all = [...current.map(attachment => attachment.file), ...files]
if (all.length > 1) throw new Error('每条消息最多添加 1 张图片')
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
}
let totalBytes = 0
for (const file of all) {
const mediaType = imageMediaType(file.type)
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
@@ -296,6 +299,10 @@ export class ConversationService extends Service implements IConversation {
if (limits !== undefined && file.size > limits.maxImageBytes) {
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
}
totalBytes += file.size
}
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
throw new Error('图片总大小超过单条消息限制')
}
}

View File

@@ -29,25 +29,6 @@ async function bench() {
}
describe('ConversationService', () => {
it('keeps the browser draft to one image', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-one')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const [first] = b.root.createDraftImages([new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' })])
if (first === undefined) throw new Error('draft attachment missing')
expect(() => b.root.createDraftImages(
[new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' })],
[first],
)).toThrow('每条消息最多添加 1 张图片')
expect(created).toHaveBeenCalledOnce()
} finally {
created.mockRestore()
revoked.mockRestore()
}
await b.runtime.dispose()
})
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
@@ -68,6 +49,47 @@ describe('ConversationService', () => {
await b.runtime.dispose()
})
it('accepts ordered batches and preflights their advertised count and aggregate limits', async () => {
const b = await bench()
const described = vi.spyOn(b.runtime.sessions, 'hostDescription').mockReturnValue({
version: 'test',
cwd: '/tmp',
imageLimits: {
maxImageBytes: 3,
maxImagesPerMessage: 2,
maxMessageImageBytes: 3,
maxImagePixels: 4,
mediaTypes: ['image/png'],
},
attachedSessions: 1,
})
const created = vi.spyOn(URL, 'createObjectURL')
.mockReturnValueOnce('blob:first')
.mockReturnValueOnce('blob:second')
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
try {
const attachments = b.root.createDraftImages([
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
])
expect(attachments.map(attachment => attachment.file.name)).toEqual(['first.png', 'second.png'])
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(3)], 'third.png', { type: 'image/png' }),
], attachments)).toThrow('每条消息最多添加 2 张图片')
const first = attachments[0]
if (first === undefined) throw new Error('first draft attachment missing')
expect(() => b.root.createDraftImages([
new File([Uint8Array.of(3, 4, 5)], 'large.png', { type: 'image/png' }),
], [first])).toThrow('图片总大小超过单条消息限制')
expect(created).toHaveBeenCalledTimes(2)
} finally {
await b.runtime.dispose()
described.mockRestore()
created.mockRestore()
revoked.mockRestore()
}
})
it('releases draft images when the session scope is disposed', async () => {
const b = await bench()
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')

View File

@@ -160,6 +160,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'attachments',
summary: 'Immutable binary attachment service.',
methods: [
{
signature: 'abstract validateImage(input: SaveImageAttachment): void',
jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n */',
},
{
signature: 'abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>',
jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */',

View File

@@ -79,23 +79,34 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
if (content.filter(part => part.type === 'image').length > 1) {
throw new AttachmentError('A prompt may contain at most one image.', 'TOO_MANY_IMAGES')
const limits = ctx.attachments.imageLimits
if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const durable: ContentBlock[] = []
for (const part of content) {
if (part.type === 'text') {
durable.push({ type: 'text', text: part.text })
continue
}
const attachment = await ctx.attachments.saveImage({
data: decodeBase64(part.data),
mediaType: part.mediaType,
...part.name === undefined ? {} : { name: part.name },
const prepared = content.map(part => part.type === 'text'
? part
: { part, data: decodeBase64(part.data) })
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
if (totalBytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
for (const image of images) {
ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
durable.push({ type: 'image', attachment })
}
return durable
return Promise.all(prepared.map(async (item): Promise<ContentBlock> => {
if (!('data' in item)) return { type: 'text', text: item.text }
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
return { type: 'image', attachment }
}))
}
/**

View File

@@ -19,6 +19,8 @@ export const hostDescribeValueSchema = z.object({
model: z.string().optional(),
imageLimits: z.object({
maxImageBytes: z.number().int().positive(),
maxImagesPerMessage: z.number().int().positive(),
maxMessageImageBytes: z.number().int().positive(),
maxImagePixels: z.number().int().positive(),
mediaTypes: z.array(imageMediaTypeSchema),
}).optional(),

View File

@@ -4,14 +4,14 @@
* models, and the prompt-assembly boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
LlmResolvedModelInfo, StreamChunk, UserMessage,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -118,23 +118,66 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
}
describe('Web session model selection', () => {
it('rejects a second prompt image before attachment persistence', async () => {
const { ctx, sessionId } = await harness()
it('accepts ordered multi-image prompts and rejects configured batch-limit excess before persistence', async () => {
const { ctx, agent, sessionId } = await harness()
const validateImage = vi.fn((_input: { data: Uint8Array }): void => {})
const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => {
return Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...(input.name === undefined ? {} : { name: input.name }),
})
})
const followup = vi.fn((_message: UserMessage): void => {})
Object.assign(agent, { followup })
ctx.provide('attachments', {
imageLimits: { maxImageBytes: 4, maxImagesPerMessage: 2, maxMessageImageBytes: 4, maxImagePixels: 4, mediaTypes: ['image/png'] },
validateImage,
saveImage,
} as never)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AA==' }
const response = await api.sessions.prompt(request({
const first = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }
const second = { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==', name: 'second.png' }
const accepted = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [image, image],
content: [first, { type: 'text' as const, text: 'compare' }, second],
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'attachment-error',
message: 'A prompt may contain at most one image.',
details: { reason: 'TOO_MANY_IMAGES' },
},
expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } })
expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]])
expect(followup.mock.calls[0]?.[0].content).toEqual([
{ type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png' } },
{ type: 'text', text: 'compare' },
{ type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'second.png' } },
])
const tooMany = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [first, second, first],
}))
expect(tooMany.result).toMatchObject({
ok: false, error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } },
})
const tooLarge = await api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [
{ ...first, data: 'AQID' },
{ ...second, data: 'BAUG' },
],
}))
expect(tooLarge.result).toMatchObject({
ok: false,
error: { code: 'attachment-error', details: { reason: 'IMAGES_TOO_LARGE' } },
})
expect(validateImage).toHaveBeenCalledTimes(2)
expect(saveImage).toHaveBeenCalledTimes(2)
expect(followup).toHaveBeenCalledTimes(1)
await ctx.fiber.dispose()
})

View File

@@ -250,10 +250,16 @@ describe('PiAiAdapter provider routing', () => {
class LateAttachmentStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = {
maxImageBytes: 1,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1,
maxImagePixels: 1,
mediaTypes: ['image/png'],
}
validateImage(_input: SaveImageAttachment): void {
throw new Error('not used')
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return Promise.reject(new Error('not used'))
}

View File

@@ -68,10 +68,16 @@ async function harness(image?: StoredImageAttachment): Promise<Context> {
class E2eAttachmentStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = {
maxImageBytes: fixture.data.byteLength,
maxImagesPerMessage: 1,
maxMessageImageBytes: fixture.data.byteLength,
maxImagePixels: fixture.ref.width * fixture.ref.height,
mediaTypes: [fixture.ref.mediaType],
}
validateImage(_input: SaveImageAttachment): void {
throw new Error('e2e attachment fixture is read-only')
}
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
return Promise.reject(new Error('e2e attachment fixture is read-only'))
}