feat(attachment): add ordered image batch admission
This commit is contained in:
@@ -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: baeeca0cf939f1a3d4608769b362d532507b90f5
|
||||
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
|
||||
README.md: c0a86d324da8c27ec386103f40ac50534c2483d7
|
||||
README.zh.md: 562c8af0df20634ac2072c4a8d422b4e0b4b47dd
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
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.
|
||||
The durable attachment seam. `ctx.attachments` validates and durably 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. `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. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { AttachmentError } from './error.ts'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
@@ -42,6 +43,35 @@ export abstract class AttachmentStore extends Service {
|
||||
*/
|
||||
abstract validateImage(input: SaveImageAttachment): Promise<void>
|
||||
|
||||
/**
|
||||
* Validate one ordered image batch before committing any member.
|
||||
* Validation failures start no writes; storage failures return no partial
|
||||
* references, although already published content-addressed objects may stay
|
||||
* unreachable until a future retention policy collects them.
|
||||
* @param inputs - encoded images in their owning message order.
|
||||
* @returns durable references in the exact input order.
|
||||
*/
|
||||
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]> {
|
||||
const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits
|
||||
if (inputs.length > maxImagesPerMessage) {
|
||||
throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
|
||||
}
|
||||
const totalBytes = inputs.reduce((sum, input) => sum + input.data.byteLength, 0)
|
||||
if (totalBytes > maxMessageImageBytes) {
|
||||
throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
|
||||
}
|
||||
for (const input of inputs) {
|
||||
if (!mediaTypes.includes(input.mediaType)) {
|
||||
throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE')
|
||||
}
|
||||
}
|
||||
for (const input of inputs) await this.validateImage(input)
|
||||
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
for (const input of inputs) refs.push(await this.saveImage(input))
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and durably commit one image before its owning session event is appended.
|
||||
* @param input - encoded bytes, declared media type, and optional display name.
|
||||
|
||||
95
packages/attachment/attachment/tests/index.spec.ts
Normal file
95
packages/attachment/attachment/tests/index.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import AttachmentStore, {
|
||||
AttachmentId,
|
||||
type ImageAttachmentRef,
|
||||
type ImageMediaType,
|
||||
type SaveImageAttachment,
|
||||
type StoredImageAttachment,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const LIMITS = {
|
||||
maxImageBytes: 4,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 5,
|
||||
maxImagePixels: 4,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
|
||||
class RecordingStore extends AttachmentStore {
|
||||
readonly imageLimits = LIMITS
|
||||
readonly calls: string[] = []
|
||||
rejectValidationAt: number | undefined
|
||||
rejectSaveAt: number | undefined
|
||||
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
const value = input.data[0] ?? 0
|
||||
this.calls.push(`validate:${value}`)
|
||||
if (value === this.rejectValidationAt) throw new Error(`invalid:${value}`)
|
||||
}
|
||||
|
||||
async saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
const value = input.data[0] ?? 0
|
||||
this.calls.push(`save:${value}`)
|
||||
if (value === this.rejectSaveAt) throw new Error(`write:${value}`)
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${String(value).padStart(64, '0')}`),
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 1,
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
}
|
||||
}
|
||||
|
||||
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
|
||||
return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
|
||||
}
|
||||
|
||||
describe('AttachmentStore.saveImages', () => {
|
||||
it('validates the complete batch before saving in input order', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
|
||||
const refs = await store.saveImages([image(1), image(2)])
|
||||
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
|
||||
expect(refs.map(ref => ref.name)).toEqual(['1.png', '2.png'])
|
||||
})
|
||||
|
||||
it('rejects count, aggregate bytes, and deployment media types before validation', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
|
||||
await expect(store.saveImages([image(1), image(2), image(3)]))
|
||||
.rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' })
|
||||
await expect(store.saveImages([
|
||||
{ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' },
|
||||
{ data: Uint8Array.of(4, 5, 6), mediaType: 'image/png' },
|
||||
])).rejects.toMatchObject({ code: 'IMAGES_TOO_LARGE' })
|
||||
await expect(store.saveImages([image(1, 'image/jpeg')]))
|
||||
.rejects.toMatchObject({ code: 'UNSUPPORTED_IMAGE_TYPE' })
|
||||
expect(store.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('starts no writes when any member fails validation', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
store.rejectValidationAt = 2
|
||||
|
||||
await expect(store.saveImages([image(1), image(2)]))
|
||||
.rejects.toThrow('invalid:2')
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2'])
|
||||
})
|
||||
|
||||
it('returns no partial references when storage fails after an earlier commit', async () => {
|
||||
const store = new RecordingStore(new Context())
|
||||
store.rejectSaveAt = 2
|
||||
|
||||
await expect(store.saveImages([image(1), image(2)]))
|
||||
.rejects.toThrow('write:2')
|
||||
expect(store.calls).toEqual(['validate:1', 'validate:2', 'save:1', 'save:2'])
|
||||
})
|
||||
})
|
||||
@@ -145,36 +145,25 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten
|
||||
if (content.every(part => part.type === 'text')) {
|
||||
return content.map(part => ({ type: 'text', text: part.text }))
|
||||
}
|
||||
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 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) {
|
||||
await ctx.attachments.validateImage({
|
||||
data: image.data,
|
||||
mediaType: image.part.mediaType,
|
||||
...image.part.name === undefined ? {} : { name: image.part.name },
|
||||
})
|
||||
}
|
||||
const refs = await ctx.attachments.saveImages(images.map(image => ({
|
||||
data: image.data,
|
||||
mediaType: image.part.mediaType,
|
||||
...image.part.name === undefined ? {} : { name: image.part.name },
|
||||
})))
|
||||
const blocks: ContentBlock[] = []
|
||||
let imageIndex = 0
|
||||
for (const item of prepared) {
|
||||
if (!('data' in item)) {
|
||||
blocks.push({ type: 'text', text: item.text })
|
||||
continue
|
||||
}
|
||||
const attachment = await ctx.attachments.saveImage({
|
||||
data: item.data,
|
||||
mediaType: item.part.mediaType,
|
||||
...item.part.name === undefined ? {} : { name: item.part.name },
|
||||
})
|
||||
const attachment = refs[imageIndex++]
|
||||
/* v8 ignore next -- each prepared image supplied exactly one saveImages input and therefore one ordered ref. */
|
||||
if (attachment === undefined) throw new Error('attachment batch result did not preserve input cardinality')
|
||||
blocks.push({ type: 'image', attachment })
|
||||
}
|
||||
return blocks
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AttachmentStore from '@deepseek-ai/dsh-attachment'
|
||||
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
|
||||
@@ -140,7 +141,7 @@ describe('Web session model selection', () => {
|
||||
height: 1,
|
||||
...input.name === undefined ? {} : { name: input.name },
|
||||
}))
|
||||
ctx.provide('attachments', {
|
||||
const attachments = {
|
||||
imageLimits: {
|
||||
maxImageBytes: 4,
|
||||
maxImagesPerMessage: 2,
|
||||
@@ -150,6 +151,12 @@ describe('Web session model selection', () => {
|
||||
},
|
||||
validateImage,
|
||||
saveImage,
|
||||
}
|
||||
ctx.provide('attachments', {
|
||||
...attachments,
|
||||
saveImages(inputs: readonly Parameters<typeof saveImage>[0][]) {
|
||||
return AttachmentStore.prototype.saveImages.call(attachments, inputs)
|
||||
},
|
||||
} as never)
|
||||
const followup = vi.fn()
|
||||
Object.assign(agent, { followup })
|
||||
|
||||
@@ -232,6 +232,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
signature: 'abstract validateImage(input: SaveImageAttachment): Promise<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 * @returns completion after the encoded raster has been fully decoded.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>',
|
||||
jsDoc: '/**\n * Validate one ordered image batch before committing any member.\n * Validation failures start no writes; storage failures return no partial\n * references, although already published content-addressed objects may stay\n * unreachable until a future retention policy collects them.\n * @param inputs - encoded images in their owning message order.\n * @returns durable references in the exact input order.\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 */',
|
||||
|
||||
Reference in New Issue
Block a user