Files
deepseek-harness/packages/attachment/attachment/src/index.ts
T
CreatixChu 7bc30c4cba fix(attachments): 复用完整图片引用,取消 Session 历史扫描 (#7021)
* test(api): measure historical image read costs

* fix(test): resolve image benchmark paths from its config

* fix(attachments): read stored images without scanning sessions

* test(attachments): complete by-id fixtures and access assertions

* fix(docs): implement image-id read on catalog fixture

* docs(attachments): refresh generated read contracts

* test(web): verify stored images across history and forks

* docs(api): clarify image read metadata and permission scope

* test(api): type benchmark instrumentation without unsafe returns

* test(api): retire unknown-cast allowances with removed history fixtures

* test(attachments): isolate browser cases and document store-wide access

* test(web): intercept the by-id thumbnail read path

* docs(perf): run generated diagnostic from its owning package

* refactor(attachments): reuse reference reads for conversation images

* docs(api): refresh complete image reference client contract

* test(ui-chat): assert complete image reference forwarding

* test(perf): measure image reads against current Session observations
2026-10-25 09:40:53 +00:00

294 lines
12 KiB
TypeScript

/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */
import { Buffer } from 'node:buffer'
import { Context, Service } from '@deepseek-ai/cordis'
import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts'
import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts'
import type {
AdmittedPromptContentPart,
AttachmentAdmissionPart,
EncodedFileAttachment,
FileAttachmentRef,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageRequestTarget,
PromptContentPart,
RequestImageAttachment,
SaveFileAttachment,
SaveFileStreamAttachment,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
export { AttachmentId, ImageVariantId } from './brand.ts'
export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts'
export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts'
export { admitEncodedFile, admitEncodedImages } from './admission.ts'
export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts'
export type { ProjectedDimensions } from './request-projection.ts'
export type {
AttachmentId as AttachmentIdType,
AdmittedPromptContentPart,
AttachmentAdmissionPart,
EncodedFileAttachment,
EncodedImageAttachment,
FileAttachmentRef,
ImageAttachmentLimits,
ImageAttachmentRef,
ImageRequestTarget,
ImageMediaType,
PromptContentPart,
RequestImageAttachment,
SaveFileAttachment,
SaveFileStreamAttachment,
SaveImageAttachment,
StoredImageAttachment,
} from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
attachments: AttachmentStore
}
}
/** Whether one admission part still carries encoded image bytes. */
function isEncodedImage(part: AttachmentAdmissionPart): part is Extract<PromptContentPart, { readonly type: 'image' }> {
return part.type === 'image' && !('attachment' in part)
}
/** Whether one admission part references an image an earlier upload stored. */
function isStoredImage(
part: AttachmentAdmissionPart,
): part is Extract<AttachmentAdmissionPart, { readonly type: 'image'; readonly encodedBytes: number }> {
return part.type === 'image' && 'attachment' in part
}
/** Refuse one message whose images exceed the configured count or aggregate byte limit. */
function assertMessageImages(limits: ImageAttachmentLimits, count: number, bytes: number): void {
if (count > limits.maxImagesPerMessage) {
throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
if (bytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Image batch exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
}
/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */
export abstract class AttachmentStore extends Service {
constructor(ctx: Context) {
super(ctx, 'attachments')
}
/** 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.
* @returns completion after the encoded raster has been fully decoded.
*/
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.
*/
protected validateImageBatch(inputs: readonly SaveImageAttachment[]): void {
const limits = this.imageLimits
assertMessageImages(limits, inputs.length, inputs.reduce((sum, input) => sum + input.data.byteLength, 0))
for (const input of inputs) {
if (!limits.mediaTypes.includes(input.mediaType)) {
throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE')
}
}
}
/**
* Validate and durably commit one ordered image batch.
* @param inputs - encoded images in owning-message order.
* @returns durable normalized attachment references in the same order after every member succeeds.
*/
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]> {
this.validateImageBatch(inputs)
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
}
/**
* Admit one Host prompt and replace each encoded image with its durable reference.
* Text, durable file references, and already stored images pass through unchanged.
* Every image part counts toward `maxImagesPerMessage` and `maxMessageImageBytes`;
* a stored image counts the bytes of the upload that stored it. Refused content
* starts no writes. A prompt without encoded images performs no storage operation.
* @param content - prompt parts in message order after upload receipt resolution.
* @returns admitted prompt parts in the same order as `content`.
* @throws AttachmentError when the image batch is refused.
*/
async admitPromptContent(
content: readonly AttachmentAdmissionPart[],
): Promise<AdmittedPromptContentPart[]> {
const encoded = content.filter(isEncodedImage)
const stored = content.filter(isStoredImage)
// Each upload passed the limits alone; the encoded batch below checks only itself.
if (stored.length > 0) {
assertMessageImages(
this.imageLimits,
encoded.length + stored.length,
encoded.reduce((sum, part) => sum + Buffer.byteLength(part.data, 'base64'), 0)
+ stored.reduce((sum, part) => sum + part.encodedBytes, 0),
)
}
const refs = encoded.length === 0 ? [] : await admitEncodedImages(this, encoded)
let next = 0
return content.map((part) => {
if (part.type === 'text') return { type: 'text', text: part.text }
if (part.type === 'file') return { type: 'file', attachment: part.attachment }
if ('attachment' in part) return { type: 'image', attachment: part.attachment }
return { type: 'image', attachment: refs[next++] as ImageAttachmentRef }
})
}
/**
* Decode and durably commit one canonical base64 file upload.
* @param input - canonical base64 bytes and optional display name.
* @returns the durable content-addressed file reference.
* @throws AttachmentError when the encoding or storage operation is refused.
*/
admitEncodedFile(input: EncodedFileAttachment): Promise<FileAttachmentRef> {
return admitFileInput(this, input)
}
/**
* Identify a failure emitted by this attachment capability by its stable code.
* @param error - value caught from an attachment operation.
* @returns whether the value is an attachment failure.
*/
isAttachmentError(error: unknown): error is AttachmentError {
return matchesAttachmentError(error)
}
/**
* Validate and durably commit one image before its owning session event is appended.
* The returned reference describes the persisted normalized image. When
* normalization reduces the raster, its `originalDimensions` records the
* orientation-applied input dimensions.
* @param input - encoded bytes, declared media type, and optional display name.
* @returns the durable content-addressed normalized image reference.
*/
abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
/**
* Read one image and verify its ID, format, byte length, and dimensions against the supplied reference.
* @param ref - supplied image reference; name and originalDimensions are preserved without file verification.
* @param signal - optional cancellation for backend read and verification work.
* @returns the verified bytes and normalized attachment reference.
* @throws the signal reason when aborted, or a storage error when verification fails.
*/
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
/**
* Locate the provider-owned normalized object in the harness host filesystem.
* @param ref - durable normalized attachment reference.
* @returns an absolute host path, or undefined when this backend is not host-file-backed.
* @throws an AttachmentError when the durable reference is invalid.
*/
imageHostPath(ref: ImageAttachmentRef): string | undefined {
void ref
return undefined
}
/**
* Durably commit one file byte-for-byte before its owning session event is
* appended. Files carry no admission limits: any byte content and length is
* accepted, and the stored object is the exact submitted bytes. Backends
* without verbatim file storage keep this default rejection.
* @param input - exact bytes and optional display name.
* @returns the durable content-addressed file reference.
*/
saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef> {
void input
return Promise.reject(new AttachmentError(
'The mounted attachment provider cannot store verbatim files.',
'ATTACHMENT_FILES_UNSUPPORTED',
))
}
/**
* Durably commit one file byte-for-byte from bounded chunks. Providers must
* apply backpressure and must not collect the complete file in memory.
* Backends without streamed verbatim storage keep this default rejection.
* @param input - ordered exact bytes, optional cancellation, and display name.
* @returns the durable content-addressed file reference.
*/
saveFileStream(input: SaveFileStreamAttachment): Promise<FileAttachmentRef> {
void input
return Promise.reject(new AttachmentError(
'The mounted attachment provider cannot stream verbatim files.',
'ATTACHMENT_FILES_UNSUPPORTED',
))
}
/**
* Read and verify one verbatim stored file as bounded chunks. Providers must
* not collect the complete file in memory. Backends without verbatim file
* reads keep this default rejection.
* @param ref - durable file reference from the session log.
* @param signal - optional cancellation for backend reads and verification work.
* @returns exact file bytes in order; integrity failures reject the iteration.
*/
async *readFileStream(
ref: FileAttachmentRef,
signal?: AbortSignal,
): AsyncIterable<Uint8Array> {
signal?.throwIfAborted()
void ref
await Promise.reject(new AttachmentError(
'The mounted attachment provider cannot read verbatim files.',
'ATTACHMENT_FILES_UNSUPPORTED',
))
}
/**
* Locate the verbatim stored file object in the harness host filesystem.
* @param ref - durable file reference.
* @returns an absolute host path, or undefined when this backend is not host-file-backed.
* @throws an AttachmentError when the durable reference is invalid.
*/
fileHostPath(ref: FileAttachmentRef): string | undefined {
void ref
return undefined
}
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
* @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.
* @param signal - optional cancellation.
* @returns request bytes and the cache/upload identity covering every transform input.
*/
readImageRequest(
ref: ImageAttachmentRef,
target: ImageRequestTarget,
signal?: AbortSignal,
): Promise<RequestImageAttachment> {
signal?.throwIfAborted()
void ref
void target
return Promise.reject(new AttachmentError(
'The mounted attachment provider cannot derive model-request images.',
'ATTACHMENT_PROJECTION_UNSUPPORTED',
))
}
}
export default AttachmentStore