@sparkelf/dsh-patch-document-attachments 0.1.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2396 @@
1
+ diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts
2
+ index 815cf89c40e404a32dd1ec8c20568fc15995a73d..9e03894a8d895bc73fd5ea8ac853b0073b7c70dd 100644
3
+ --- a/packages/api/session-controller/src/client/sessions/session.ts
4
+ +++ b/packages/api/session-controller/src/client/sessions/session.ts
5
+ @@ -215,27 +215,19 @@ export class Session implements SessionFace {
6
+ signal?: AbortSignal,
7
+ requestId?: SessionRequestId,
8
+ ): Promise<ClientResult<{ accepted: true }>> {
9
+ - this.promptError = null
10
+ - this.lastAgentError = null
11
+ - // Synchronous, before the first await: the blank → engaging edge must be
12
+ - // visible on the session area's very first frame when a caller sends
13
+ - // ahead of navigation (first-send flow).
14
+ - this.promptAttempted = true
15
+ - if (this.blankBit) this.firstPromptPendingTurn = true
16
+ - this.notifier.markDirty()
17
+ - let result: ClientResult<{ accepted: true }>
18
+ - try {
19
+ + return this.settlePrompt(requestId, async () => {
20
+ if (this.address === undefined) {
21
+ const clientTimeZone = resolvedClientTimeZone()
22
+ - result = toSessionResult(await this.remote.session.prompt({
23
+ + return toSessionResult(await this.remote.session.prompt({
24
+ requestId: requestId ?? randomUUID() as SessionRequestId,
25
+ sessionId: this.sessionId,
26
+ mode,
27
+ content,
28
+ clientTimeZone,
29
+ }, signal))
30
+ - } else if (this.address.mode === 'one-shot') {
31
+ - result = {
32
+ + }
33
+ + if (this.address.mode === 'one-shot') {
34
+ + return {
35
+ ok: false,
36
+ error: {
37
+ code: 'subagent-not-resumable',
38
+ @@ -243,30 +235,64 @@ export class Session implements SessionFace {
39
+ details: { childSessionId: this.address.childSessionId },
40
+ },
41
+ }
42
+ - } else {
43
+ - if (content.some(part => part.type === 'image')) {
44
+ - result = {
45
+ - ok: false,
46
+ - error: {
47
+ - code: 'attachment-error',
48
+ - message: 'Image input is unavailable for subagent continuations.',
49
+ - details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
50
+ - },
51
+ - }
52
+ - } else {
53
+ - const routed = toSessionResult(await this.remote.subagents.prompt({
54
+ - requestId: randomUUID() as SessionRequestId,
55
+ - parentSessionId: this.address.parentSessionId,
56
+ - childSessionId: this.address.childSessionId,
57
+ - mode: this.address.mode,
58
+ - content: content.flatMap(part => part.type === 'text'
59
+ - ? [{ type: 'text' as const, text: part.text }]
60
+ - : []),
61
+ - clientTimeZone: resolvedClientTimeZone(),
62
+ - }, signal))
63
+ - result = routed.ok ? { ok: true, value: { accepted: true } } : routed
64
+ + }
65
+ + if (content.some(part => part.type === 'image')) {
66
+ + return {
67
+ + ok: false,
68
+ + error: {
69
+ + code: 'attachment-error',
70
+ + message: 'Image input is unavailable for subagent continuations.',
71
+ + details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
72
+ + },
73
+ }
74
+ }
75
+ + const routed = toSessionResult(await this.remote.subagents.prompt({
76
+ + requestId: randomUUID() as SessionRequestId,
77
+ + parentSessionId: this.address.parentSessionId,
78
+ + childSessionId: this.address.childSessionId,
79
+ + mode: this.address.mode,
80
+ + content: content.flatMap(part => part.type === 'text' ? [{ type: 'text' as const, text: part.text }] : []),
81
+ + clientTimeZone: resolvedClientTimeZone(),
82
+ + }, signal))
83
+ + return routed.ok ? { ok: true, value: { accepted: true } } : routed
84
+ + })
85
+ + }
86
+ +
87
+ + /**
88
+ + * Run one capability-owned prompt transport through the Session's normal settlement policy.
89
+ + * @param requestId - identity minted by {@link beginSubmission}.
90
+ + * @param submit - authenticated transport operation returning a Session-compatible result.
91
+ + * @returns the settled prompt result.
92
+ + */
93
+ + promptPrepared(
94
+ + requestId: SessionRequestId,
95
+ + submit: () => Promise<ClientResult<{ accepted: true }>>,
96
+ + ): Promise<ClientResult<{ accepted: true }>> {
97
+ + if (this.address !== undefined) {
98
+ + return this.settlePrompt(requestId, async () => ({
99
+ + ok: false,
100
+ + error: {
101
+ + code: 'attachment-error',
102
+ + message: 'Document input is unavailable for subagent continuations.',
103
+ + details: { reason: 'SUBAGENT_DOCUMENT_UNSUPPORTED' },
104
+ + },
105
+ + }))
106
+ + }
107
+ + return this.settlePrompt(requestId, submit)
108
+ + }
109
+ +
110
+ + private async settlePrompt(
111
+ + requestId: SessionRequestId | undefined,
112
+ + submit: () => Promise<ClientResult<{ accepted: true }>>,
113
+ + ): Promise<ClientResult<{ accepted: true }>> {
114
+ + this.promptError = null
115
+ + this.lastAgentError = null
116
+ + this.promptAttempted = true
117
+ + if (this.blankBit) this.firstPromptPendingTurn = true
118
+ + this.notifier.markDirty()
119
+ + let result: ClientResult<{ accepted: true }>
120
+ + try {
121
+ + result = await submit()
122
+ } catch (error) {
123
+ result = transportResult(error)
124
+ }
125
+ @@ -276,14 +302,6 @@ export class Session implements SessionFace {
126
+ this.notifier.markDirty()
127
+ return result
128
+ }
129
+ - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
130
+ - // conversation's first turn on the host (the host criterion — a logged
131
+ - // turn/start — is fact, not optimism; standalone command and projection
132
+ - // events never flip it), while a rejected first prompt must keep the
133
+ - // session blank — the client-side blank mirror only ever lowers, so
134
+ - // flipping early on a failure would surface the session forever and
135
+ - // strip its connectWorkspace reuse eligibility against the host's
136
+ - // authority.
137
+ if (this.blankBit) {
138
+ this.blankBit = false
139
+ this.options.onEngaged?.(this)
140
+ diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
141
+ index 48c41f3aa143d420890a4ad244d5b7b75b7d3eb4..cae9940f2ae4d5b6b8f57b0d88d48629f8b33c0d 100644
142
+ --- a/packages/api/session-controller/src/commands.ts
143
+ +++ b/packages/api/session-controller/src/commands.ts
144
+ @@ -281,6 +281,34 @@ export class SessionCommandController {
145
+ * @returns acknowledgement that the Agent accepted the prompt.
146
+ */
147
+ async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
148
+ + const hasImage = request.content.some(part => part.type === 'image')
149
+ + try {
150
+ + return await this.promptPrepared(
151
+ + request,
152
+ + hasImage,
153
+ + () => durablePromptContent(this.ctx, request.content),
154
+ + )
155
+ + } catch (error) {
156
+ + if (error instanceof TypertRemoteFailure) throw error
157
+ + if (error instanceof AttachmentError) {
158
+ + reject('attachment-error', error.message, { reason: error.code })
159
+ + }
160
+ + reject('agent-busy', 'prompt rejected', { reason: String(error) })
161
+ + }
162
+ + }
163
+ +
164
+ + /**
165
+ + * Submit content prepared by a Host capability while preserving Session prompt policy.
166
+ + * @param request - Session identity, source id, mode, and optional client time zone.
167
+ + * @param hasImage - whether preparation will admit image bytes.
168
+ + * @param prepare - capability-owned validation and durable content preparation.
169
+ + * @returns acceptance after the Agent owns the resulting user message.
170
+ + */
171
+ + async promptPrepared(
172
+ + request: Omit<SessionPromptRequest, 'content'>,
173
+ + hasImage: boolean,
174
+ + prepare: () => Promise<ContentBlock[]>,
175
+ + ): Promise<SessionPromptValue> {
176
+ const clientTimeZone = request.clientTimeZone === undefined
177
+ ? undefined
178
+ : canonicalClientTimeZone(request.clientTimeZone)
179
+ @@ -305,31 +333,21 @@ export class SessionCommandController {
180
+ rpcId: request.requestId,
181
+ ...(clientTimeZone === undefined ? {} : { clientTimeZone }),
182
+ }
183
+ - const hasImage = request.content.some(part => part.type === 'image')
184
+ const admit = async (): Promise<SessionPromptValue> => {
185
+ - try {
186
+ - if (hasImage) {
187
+ - const current = this.agents.selectionFor(agent).current
188
+ - const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
189
+ - if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
190
+ - reject(
191
+ - 'attachment-error',
192
+ - `Model "${current.model}" does not support image input.`,
193
+ - { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
194
+ - )
195
+ - }
196
+ - }
197
+ - const content = await durablePromptContent(this.ctx, request.content)
198
+ - const message: UserMessage = createUserMessage({ content, source })
199
+ - if (request.mode === 'steer') agent.steer(message)
200
+ - else agent.followup(message)
201
+ - } catch (error) {
202
+ - if (error instanceof TypertRemoteFailure) throw error
203
+ - if (error instanceof AttachmentError) {
204
+ - reject('attachment-error', error.message, { reason: error.code })
205
+ + if (hasImage) {
206
+ + const current = this.agents.selectionFor(agent).current
207
+ + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
208
+ + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
209
+ + reject(
210
+ + 'attachment-error',
211
+ + `Model "${current.model}" does not support image input.`,
212
+ + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
213
+ + )
214
+ }
215
+ - reject('agent-busy', 'prompt rejected', { reason: String(error) })
216
+ }
217
+ + const message: UserMessage = createUserMessage({ content: await prepare(), source })
218
+ + if (request.mode === 'steer') agent.steer(message)
219
+ + else agent.followup(message)
220
+ return { accepted: true }
221
+ }
222
+ return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
223
+ diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
224
+ index 342dd977ebcb7b12e6c8e4c7f9cb93c72e23261c..f2a42d4e33a450a5874aed5a43bf9184677882a4 100644
225
+ --- a/packages/api/session-controller/src/index.ts
226
+ +++ b/packages/api/session-controller/src/index.ts
227
+ @@ -3,6 +3,7 @@
228
+ import { Context } from '@deepseek-ai/cordis'
229
+ import z from '@deepseek-ai/schemastery'
230
+ import { errorChain } from '@deepseek-ai/dsh-llm'
231
+ +import type { ContentBlock } from '@deepseek-ai/dsh-llm'
232
+ import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
233
+ import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
234
+ import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
235
+ @@ -328,6 +329,21 @@ export class SessionController extends TypertRemoteService {
236
+ return this.commands.prompt(request)
237
+ }
238
+
239
+ + /**
240
+ + * Submit content prepared by an authenticated Host capability without widening the Remote prompt schema.
241
+ + * @param request - Session identity, source id, mode, and optional client time zone.
242
+ + * @param hasImage - whether preparation will admit image bytes.
243
+ + * @param prepare - capability-owned validation and durable content preparation.
244
+ + * @returns acknowledgement that the Agent accepted the prompt.
245
+ + */
246
+ + promptPrepared(
247
+ + request: Omit<SessionPromptRequest, 'content'>,
248
+ + hasImage: boolean,
249
+ + prepare: () => Promise<ContentBlock[]>,
250
+ + ): Promise<SessionPromptValue> {
251
+ + return this.commands.promptPrepared(request, hasImage, prepare)
252
+ + }
253
+ +
254
+ /**
255
+ * Read one image proven reachable from the addressed Session log.
256
+ * @param request - Session and attachment identities used for authorization.
257
+ diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts
258
+ index c9a53a6bd0209669b3e7994d0cb1aa29dfeabf2f..f15912a035586dfa324bf220378c6407e6ebcf86 100644
259
+ --- a/packages/api/session-controller/src/list.ts
260
+ +++ b/packages/api/session-controller/src/list.ts
261
+ @@ -3,7 +3,7 @@
262
+ import { stat } from 'node:fs/promises'
263
+ import type { Context } from '@deepseek-ai/cordis'
264
+ import type {} from '@deepseek-ai/dsh-agent-presets'
265
+ -import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
266
+ +import type { DocumentAttachmentLimits, ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
267
+ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
268
+ import type {} from '@deepseek-ai/dsh-session-projection'
269
+ import type {} from '@deepseek-ai/dsh-session-projection-cache'
270
+ @@ -41,6 +41,13 @@ const imageLimitsSchema = z.object({
271
+ mediaTypes: z.array(z.string()),
272
+ }) as unknown as z.ZodType<ImageAttachmentLimits>
273
+
274
+ +const documentLimitsSchema = z.object({
275
+ + maxDocumentBytes: z.number().int().positive(),
276
+ + maxDocumentsPerMessage: z.number().int().positive(),
277
+ + maxMessageDocumentBytes: z.number().int().positive(),
278
+ + mediaTypes: z.array(z.string()),
279
+ +}) as unknown as z.ZodType<DocumentAttachmentLimits>
280
+ +
281
+ /**
282
+ * Advance the Session-list metadata projection by one committed event.
283
+ * @param state - metadata before the event.
284
+ @@ -109,6 +116,17 @@ export class ApiSessionList {
285
+ },
286
+ stateVersion: 1,
287
+ })
288
+ + projectionCtx.sessionProjections.register<'documentLimits', null>({
289
+ + key: 'documentLimits',
290
+ + stateSchema: z.null(),
291
+ + init: () => null,
292
+ + apply: state => state,
293
+ + wire: {
294
+ + viewSchema: documentLimitsSchema,
295
+ + view: () => projectionCtx.attachments.documentLimits,
296
+ + },
297
+ + stateVersion: 1,
298
+ + })
299
+ })
300
+ }
301
+
302
+ diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
303
+ index 167937e3d3317bdc20878662f337f88b85ea47f8..97fc8315afe139fe7c009e5295ee807ec2b542a5 100644
304
+ --- a/packages/api/session-controller/src/types.ts
305
+ +++ b/packages/api/session-controller/src/types.ts
306
+ @@ -1,7 +1,7 @@
307
+ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
308
+
309
+ import type {
310
+ - AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
311
+ + AttachmentIdType, DocumentAttachmentLimits, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
312
+ } from '@deepseek-ai/dsh-attachment'
313
+ import type { Branded } from '@deepseek-ai/dsh-brand'
314
+ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
315
+ @@ -18,6 +18,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
316
+ sessionListMetadata: SessionListMetadata
317
+ /** Host state for the boot-constant image-limit view. */
318
+ imageLimits: null
319
+ + /** Host state for the boot-constant document-limit view. */
320
+ + documentLimits: null
321
+ /** Durable model selection already used by a request and still pending for a later request. */
322
+ modelSelection: ModelSelectionProjectionState
323
+ }
324
+ @@ -26,6 +28,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
325
+ sessionListMetadata: SessionListMetadata
326
+ /** Image-intake limits enforced by the Session prompt endpoint. */
327
+ imageLimits: ImageAttachmentLimits
328
+ + /** Document-intake limits enforced by the Document capability. */
329
+ + documentLimits: DocumentAttachmentLimits
330
+ /** Durable model selection already used and selected for the next request. */
331
+ modelSelection: ModelSelectionProjection
332
+ }
333
+ diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts
334
+ index 16b963f2259d0fc7e7a0761afb9a73eb1603e7ad..c10fe04f5dd1cf9812c37d814a9ce7c41dc77dba 100644
335
+ --- a/packages/attachment/attachment-local/src/index.ts
336
+ +++ b/packages/attachment/attachment-local/src/index.ts
337
+ @@ -5,22 +5,26 @@ import { Context } from '@deepseek-ai/cordis'
338
+ import z from '@deepseek-ai/schemastery'
339
+ import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
340
+ import type {
341
+ + DocumentAttachmentLimits,
342
+ + FileAttachmentRef,
343
+ ImageAttachmentLimits,
344
+ ImageAttachmentRef,
345
+ ImageRequestPolicy,
346
+ RequestImageAttachment,
347
+ + SaveFileAttachment,
348
+ SaveImageAttachment,
349
+ + StoredFileAttachment,
350
+ StoredImageAttachment,
351
+ } from '@deepseek-ai/dsh-attachment'
352
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
353
+ import type { NormalizationPolicy } from './normalization.ts'
354
+ import { CompressionLimiter } from './compression-limiter.ts'
355
+ -import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts'
356
+ +import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readFileObject, readImageFile, saveFileObject, validateImageFile } from './store.ts'
357
+ import { readRequestImageFile, requestImageVariantId } from './request-image.ts'
358
+
359
+ export { canPassThroughNormalization, normalizeImage } from './normalization.ts'
360
+ export type { NormalizedImage, NormalizationPolicy } from './normalization.ts'
361
+ -export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts'
362
+ +export { commitPreparedImageFile, prepareImageFile, readFileObject, readImageFile, saveFileObject, saveImageFile, validateImageFile } from './store.ts'
363
+ export type { PreparedImageFile } from './store.ts'
364
+ export { readRequestImageFile, requestImageVariantId } from './request-image.ts'
365
+
366
+ @@ -34,6 +38,12 @@ export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 200 * 1024 * 1024
367
+ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000
368
+ /** Default per-side pixel cap for one submitted image. */
369
+ export const DEFAULT_MAX_IMAGE_DIMENSION = 8192
370
+ +/** Default maximum encoded bytes for one supported document. */
371
+ +export const DEFAULT_MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
372
+ +/** Default document count in one submitted message. */
373
+ +export const DEFAULT_MAX_DOCUMENTS_PER_MESSAGE = 4
374
+ +/** Default aggregate encoded document bytes in one submitted message. */
375
+ +export const DEFAULT_MAX_MESSAGE_DOCUMENT_BYTES = 32 * 1024 * 1024
376
+ /**
377
+ * Default total-pixel budget of the stored normalized image. A larger source
378
+ * is admitted and downscaled proportionally, so admission bounds what rides
379
+ @@ -65,6 +75,12 @@ export interface Config {
380
+ maxImagePixels?: number
381
+ /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */
382
+ maxImageDimension?: number
383
+ + /** Maximum encoded bytes accepted for one supported document. */
384
+ + maxDocumentBytes?: number
385
+ + /** Maximum document count accepted in one submitted message. */
386
+ + maxDocumentsPerMessage?: number
387
+ + /** Maximum aggregate document bytes accepted in one submitted message. */
388
+ + maxMessageDocumentBytes?: number
389
+ /** Total-pixel budget of the stored provider-independent normalized image. */
390
+ normalizedImageMaxPixels?: number
391
+ /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */
392
+ @@ -148,6 +164,9 @@ export class LocalAttachmentStore extends AttachmentStore {
393
+ maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
394
+ maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
395
+ maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION),
396
+ + maxDocumentBytes: z.number().step(1).min(1).default(DEFAULT_MAX_DOCUMENT_BYTES),
397
+ + maxDocumentsPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_DOCUMENTS_PER_MESSAGE),
398
+ + maxMessageDocumentBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_DOCUMENT_BYTES),
399
+ normalizedImageMaxPixels: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS),
400
+ normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION),
401
+ normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES),
402
+ @@ -158,6 +177,7 @@ export class LocalAttachmentStore extends AttachmentStore {
403
+ /** Absolute versioned storage root. */
404
+ readonly root: string
405
+ readonly imageLimits: ImageAttachmentLimits
406
+ + private readonly resolvedDocumentLimits: DocumentAttachmentLimits
407
+ /** Resolved provider-independent normalization policy. */
408
+ readonly normalizationPolicy: Readonly<NormalizationPolicy>
409
+ /** Resolved instance-level compression limit. */
410
+ @@ -176,6 +196,17 @@ export class LocalAttachmentStore extends AttachmentStore {
411
+ maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION,
412
+ mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
413
+ })
414
+ + this.resolvedDocumentLimits = Object.freeze({
415
+ + maxDocumentBytes: config.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES,
416
+ + maxDocumentsPerMessage: config.maxDocumentsPerMessage ?? DEFAULT_MAX_DOCUMENTS_PER_MESSAGE,
417
+ + maxMessageDocumentBytes: config.maxMessageDocumentBytes ?? DEFAULT_MAX_MESSAGE_DOCUMENT_BYTES,
418
+ + mediaTypes: Object.freeze([
419
+ + 'application/pdf',
420
+ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
421
+ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
422
+ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
423
+ + ] as const),
424
+ + })
425
+ this.normalizationPolicy = Object.freeze({
426
+ maxPixels: config.normalizedImageMaxPixels ?? DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS,
427
+ maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION,
428
+ @@ -193,6 +224,18 @@ export class LocalAttachmentStore extends AttachmentStore {
429
+ this.compression = new CompressionLimiter(compressionConcurrency)
430
+ }
431
+
432
+ + override get documentLimits(): DocumentAttachmentLimits {
433
+ + return this.resolvedDocumentLimits
434
+ + }
435
+ +
436
+ + override async saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef> {
437
+ + return saveFileObject(this.root, input)
438
+ + }
439
+ +
440
+ + override async readFile(ref: FileAttachmentRef, signal?: AbortSignal): Promise<StoredFileAttachment> {
441
+ + return readFileObject(this.root, ref, signal)
442
+ + }
443
+ +
444
+ async validateImage(input: SaveImageAttachment): Promise<void> {
445
+ await this.compression.run(() => validateImageFile(input, this.imageLimits, this.normalizationPolicy))
446
+ }
447
+ diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts
448
+ index e5a979aec14e91ae726b5aef414b307dc0780795..26f8e40e2a97809660f0df1dc7d03f812d2734bc 100644
449
+ --- a/packages/attachment/attachment-local/src/store.ts
450
+ +++ b/packages/attachment/attachment-local/src/store.ts
451
+ @@ -9,9 +9,12 @@ import {
452
+ AttachmentId,
453
+ } from '@deepseek-ai/dsh-attachment'
454
+ import type {
455
+ + FileAttachmentRef,
456
+ ImageAttachmentLimits,
457
+ ImageAttachmentRef,
458
+ + SaveFileAttachment,
459
+ SaveImageAttachment,
460
+ + StoredFileAttachment,
461
+ StoredImageAttachment,
462
+ } from '@deepseek-ai/dsh-attachment'
463
+ import { normalizeImage } from './normalization.ts'
464
+ @@ -36,12 +39,16 @@ function displayName(value: string | undefined): string | undefined {
465
+ return clean === '' ? undefined : clean
466
+ }
467
+
468
+ -function ensureReference(ref: ImageAttachmentRef): string {
469
+ +function ensureReference(ref: Pick<FileAttachmentRef, 'attachmentId'>): string {
470
+ const match = ID_PATTERN.exec(String(ref.attachmentId))
471
+ if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
472
+ return match[1]
473
+ }
474
+
475
+ +function objectPath(root: string, sha256: string): string {
476
+ + return join(root, 'objects', sha256.slice(0, 2), sha256)
477
+ +}
478
+ +
479
+ /**
480
+ * Derive the absolute immutable-object path for one normalized attachment.
481
+ * @param root - absolute `DSH_HOME/attachments/v1` root.
482
+ @@ -49,8 +56,7 @@ function ensureReference(ref: ImageAttachmentRef): string {
483
+ * @returns provider-local path without reading the object.
484
+ */
485
+ export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string {
486
+ - const sha256 = ensureReference(ref)
487
+ - return join(root, 'objects', sha256.slice(0, 2), sha256)
488
+ + return objectPath(root, ensureReference(ref))
489
+ }
490
+
491
+ async function inspectMetadata(
492
+ @@ -183,73 +189,120 @@ async function ensureDurableHome(path: string): Promise<string> {
493
+ }
494
+
495
+ /**
496
+ - * Publish one already verified normalized image below a versioned attachment root.
497
+ + * Publish one immutable object after its content digest is known.
498
+ + * 中文约束:返回前必须完成file与directory durability;Session只能记录已经返回的reference。
499
+ * @param root - absolute `DSH_HOME/attachments/v1` root.
500
+ - * @param prepared - deterministic normalized bytes and reference.
501
+ - * @returns durable content-addressed normalized image reference.
502
+ + * @param data - exact immutable bytes.
503
+ + * @param sha256 - expected lowercase content digest.
504
+ + * @param failureMessage - caller-specific storage failure text.
505
+ */
506
+ -export async function commitPreparedImageFile(
507
+ - root: string,
508
+ - prepared: PreparedImageFile,
509
+ -): Promise<ImageAttachmentRef> {
510
+ - const normalized = prepared.data
511
+ - const sha256 = ensureReference(prepared.ref)
512
+ - if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) {
513
+ - throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT')
514
+ - }
515
+ +async function publishObject(root: string, data: Uint8Array, sha256: string, failureMessage: string): Promise<void> {
516
+ const bucket = join(root, 'objects', sha256.slice(0, 2))
517
+ const staging = join(root, 'tmp')
518
+ - // Establish DSH_HOME itself against the filesystem root once per process.
519
+ - // Every process performs that proof independently, so observing a directory
520
+ - // another process created can never be mistaken for durable publication.
521
+ const boundary = await ensureDurableHome(dirname(dirname(resolve(root))))
522
+ await ensureDurableDirectory(bucket, boundary)
523
+ await ensureDurableDirectory(staging, boundary)
524
+ const temporary = join(staging, randomUUID())
525
+ - const target = normalizedImagePath(root, prepared.ref)
526
+ + const target = objectPath(root, sha256)
527
+ let handle
528
+ try {
529
+ handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
530
+ - await handle.writeFile(normalized)
531
+ + await handle.writeFile(data)
532
+ await handle.sync()
533
+ await handle.close()
534
+ handle = undefined
535
+ try {
536
+ await link(temporary, target)
537
+ } catch (error) {
538
+ - /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
539
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
540
+ const existing = new Uint8Array(await readFile(target))
541
+ if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
542
+ }
543
+ - // Windows shares the read-only attribute across hard links and refuses to
544
+ - // unlink either name once it is set, so discard the staging name first.
545
+ await unlink(temporary)
546
+ - // The target remains the sole link for a new object; this also restores
547
+ - // read-only mode when the deduplication path observes an existing object.
548
+ await chmod(target, 0o400)
549
+ - // Persist the target entry and close a concurrent bucket-creation window
550
+ - // before the reference can reach a session checkpoint. The dedup path
551
+ - // repeats both syncs because it may observe another writer's link before
552
+ - // that writer reaches its own durability boundary.
553
+ await syncDirectory(bucket)
554
+ await syncDirectory(join(root, 'objects'))
555
+ } catch (error) {
556
+ - /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
557
+ - if (handle !== undefined) await handle.close().catch(
558
+ - /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
559
+ - () => {},
560
+ - )
561
+ - await unlink(temporary).catch(
562
+ - /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
563
+ - (cleanupError: unknown) => {
564
+ - /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
565
+ - if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
566
+ - },
567
+ - )
568
+ + console.error('attachment-local: immutable object publication failed', error)
569
+ + if (handle !== undefined) await handle.close().catch((cleanupError: unknown) => {
570
+ + console.error('attachment-local: staging descriptor cleanup failed', cleanupError)
571
+ + })
572
+ + await unlink(temporary).catch((cleanupError: unknown) => {
573
+ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) {
574
+ + console.error('attachment-local: staging file cleanup failed', cleanupError)
575
+ + }
576
+ + })
577
+ if (error instanceof AttachmentError) throw error
578
+ - throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
579
+ + throw new AttachmentError(failureMessage, 'ATTACHMENT_WRITE_FAILED', { cause: error })
580
+ + }
581
+ +}
582
+ +
583
+ +/**
584
+ + * Persist format-agnostic immutable bytes for documents and parser artifacts.
585
+ + * @param root - absolute `DSH_HOME/attachments/v1` root.
586
+ + * @param input - already-admitted bytes and metadata.
587
+ + * @returns durable content-addressed reference.
588
+ + */
589
+ +export async function saveFileObject(root: string, input: SaveFileAttachment): Promise<FileAttachmentRef> {
590
+ + const sha256 = digest(input.data)
591
+ + await publishObject(root, input.data, sha256, 'Unable to persist attachment.')
592
+ + const name = displayName(input.name)
593
+ + return {
594
+ + attachmentId: AttachmentId(`sha256:${sha256}`),
595
+ + mediaType: input.mediaType,
596
+ + bytes: input.data.byteLength,
597
+ + ...(name !== undefined ? { name } : {}),
598
+ + }
599
+ +}
600
+ +
601
+ +/**
602
+ + * Read one generic immutable object and verify digest and encoded length.
603
+ + * @param root - absolute `DSH_HOME/attachments/v1` root.
604
+ + * @param ref - durable reference recorded by a Document block.
605
+ + * @param signal - optional filesystem cancellation.
606
+ + * @returns verified immutable bytes and canonical reference.
607
+ + */
608
+ +export async function readFileObject(
609
+ + root: string,
610
+ + ref: FileAttachmentRef,
611
+ + signal?: AbortSignal,
612
+ +): Promise<StoredFileAttachment> {
613
+ + signal?.throwIfAborted()
614
+ + const sha256 = ensureReference(ref)
615
+ + let data: Uint8Array
616
+ + try {
617
+ + data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
618
+ + } catch (error) {
619
+ + console.error('attachment-local: immutable file read failed', error)
620
+ + signal?.throwIfAborted()
621
+ + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
622
+ + throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND', { cause: error })
623
+ + }
624
+ + throw new AttachmentError('Unable to read attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
625
+ + }
626
+ + signal?.throwIfAborted()
627
+ + if (digest(data) !== sha256 || data.byteLength !== ref.bytes) {
628
+ + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
629
+ + }
630
+ + return { ref, data }
631
+ +}
632
+ +
633
+ +/**
634
+ + * Publish one already verified normalized image below a versioned attachment root.
635
+ + * @param root - absolute `DSH_HOME/attachments/v1` root.
636
+ + * @param prepared - deterministic normalized bytes and reference.
637
+ + * @returns durable content-addressed normalized image reference.
638
+ + */
639
+ +export async function commitPreparedImageFile(
640
+ + root: string,
641
+ + prepared: PreparedImageFile,
642
+ +): Promise<ImageAttachmentRef> {
643
+ + const normalized = prepared.data
644
+ + const sha256 = ensureReference(prepared.ref)
645
+ + if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) {
646
+ + throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT')
647
+ }
648
+ + await publishObject(root, normalized, sha256, 'Unable to persist image attachment.')
649
+ return prepared.ref
650
+ }
651
+
652
+ diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts
653
+ index c19229872b976dc7e20b32220cd07bcc7d380395..67f9852511845c38e0a8359d85da24b9abeda7d4 100644
654
+ --- a/packages/attachment/attachment/src/error.ts
655
+ +++ b/packages/attachment/attachment/src/error.ts
656
+ @@ -24,6 +24,7 @@ export type AttachmentErrorCode =
657
+ | 'ATTACHMENT_NOT_FOUND'
658
+ | 'ATTACHMENT_READ_FAILED'
659
+ | 'ATTACHMENT_PROJECTION_UNSUPPORTED'
660
+ + | 'FILE_ATTACHMENTS_UNSUPPORTED'
661
+
662
+ /** Runtime membership for structurally compatible errors crossing package boundaries. */
663
+ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet<string> = new Set(IMAGE_ADMISSION_ERROR_CODES)
664
+ diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts
665
+ index 4ee001b86c55aaa1da788543477ca670a24f8a5d..9f96b4bf5cdac35afb804e378013cb63eec154ec 100644
666
+ --- a/packages/attachment/attachment/src/index.ts
667
+ +++ b/packages/attachment/attachment/src/index.ts
668
+ @@ -3,11 +3,15 @@
669
+ import { Context, Service } from '@deepseek-ai/cordis'
670
+ import { AttachmentError } from './error.ts'
671
+ import type {
672
+ + DocumentAttachmentLimits,
673
+ + FileAttachmentRef,
674
+ ImageAttachmentLimits,
675
+ ImageAttachmentRef,
676
+ ImageRequestPolicy,
677
+ RequestImageAttachment,
678
+ + SaveFileAttachment,
679
+ SaveImageAttachment,
680
+ + StoredFileAttachment,
681
+ StoredImageAttachment,
682
+ } from './types.ts'
683
+
684
+ @@ -18,13 +22,20 @@ export { admitEncodedImages } from './admission.ts'
685
+ export { requestImageDimensions } from './request-projection.ts'
686
+ export type {
687
+ AttachmentId as AttachmentIdType,
688
+ + DocumentAttachmentLimits,
689
+ + DocumentAttachmentRef,
690
+ + DocumentMediaType,
691
+ EncodedImageAttachment,
692
+ + FileAttachmentRef,
693
+ ImageAttachmentLimits,
694
+ ImageAttachmentRef,
695
+ ImageRequestPolicy,
696
+ ImageMediaType,
697
+ + ParsedDocumentRef,
698
+ RequestImageAttachment,
699
+ + SaveFileAttachment,
700
+ SaveImageAttachment,
701
+ + StoredFileAttachment,
702
+ StoredImageAttachment,
703
+ } from './types.ts'
704
+
705
+ @@ -43,6 +54,42 @@ export abstract class AttachmentStore extends Service {
706
+ /** Deployment-resolved image policy used by authoritative and fast-path validation. */
707
+ abstract readonly imageLimits: ImageAttachmentLimits
708
+
709
+ + /** Deployment-resolved document admission policy. Image-only providers fail explicitly. */
710
+ + get documentLimits(): DocumentAttachmentLimits {
711
+ + throw new AttachmentError(
712
+ + 'The mounted attachment provider does not support generic file attachments.',
713
+ + 'FILE_ATTACHMENTS_UNSUPPORTED',
714
+ + )
715
+ + }
716
+ +
717
+ + /**
718
+ + * Persist already-admitted immutable bytes for original documents and parser artifacts.
719
+ + * @param input - exact bytes and caller-owned metadata.
720
+ + * @returns the durable content-addressed reference.
721
+ + */
722
+ + saveFile(input: SaveFileAttachment): Promise<FileAttachmentRef> {
723
+ + void input
724
+ + return Promise.reject(new AttachmentError(
725
+ + 'The mounted attachment provider does not support generic file attachments.',
726
+ + 'FILE_ATTACHMENTS_UNSUPPORTED',
727
+ + ))
728
+ + }
729
+ +
730
+ + /**
731
+ + * Read one generic object and verify its digest, byte length, and recorded metadata.
732
+ + * @param ref - durable reference from a Session document block.
733
+ + * @param signal - optional cancellation for backend read work.
734
+ + * @returns verified immutable bytes and the canonical reference.
735
+ + */
736
+ + readFile(ref: FileAttachmentRef, signal?: AbortSignal): Promise<StoredFileAttachment> {
737
+ + signal?.throwIfAborted()
738
+ + void ref
739
+ + return Promise.reject(new AttachmentError(
740
+ + 'The mounted attachment provider does not support generic file attachments.',
741
+ + 'FILE_ATTACHMENTS_UNSUPPORTED',
742
+ + ))
743
+ + }
744
+ +
745
+ /**
746
+ * Validate one image without persisting it.
747
+ * Batch callers validate every member before saving any member.
748
+ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts
749
+ index 046444cd76551380a8d1d924f27db9a002d8a93f..146fe3d501b038c8affc61a59c16d727354b9bb8 100644
750
+ --- a/packages/attachment/attachment/src/types.ts
751
+ +++ b/packages/attachment/attachment/src/types.ts
752
+ @@ -7,6 +7,47 @@ export type { AttachmentId } from './brand.ts'
753
+ /** Raster image formats accepted by the version-one attachment path. */
754
+ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
755
+
756
+ +/** Human-document formats accepted by the durable document path. */
757
+ +export type DocumentMediaType =
758
+ + | 'application/pdf'
759
+ + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
760
+ + | 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
761
+ + | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
762
+ +
763
+ +/** Generic immutable file-object metadata for original documents and parser artifacts. */
764
+ +export interface FileAttachmentRef {
765
+ + /** Opaque storage identifier; never a filesystem path or bearer URL. */
766
+ + attachmentId: AttachmentId
767
+ + /** Caller-owned media type for this immutable object. */
768
+ + mediaType: string
769
+ + /** Exact stored byte length. */
770
+ + bytes: number
771
+ + /** Optional display name; storage providers never interpret it as a path. */
772
+ + name?: string
773
+ +}
774
+ +
775
+ +/** Durable parser outputs associated with one original document. */
776
+ +export interface ParsedDocumentRef {
777
+ + /** Provider id that produced this immutable parse bundle. */
778
+ + parser: string
779
+ + /** Complete parsed Markdown retained for document tools. */
780
+ + markdown: FileAttachmentRef
781
+ + /** Complete model-visible text, including attachment delimiters and metadata. */
782
+ + modelText: FileAttachmentRef
783
+ + /** Complete parser reading-order content list. */
784
+ + contentList: FileAttachmentRef
785
+ + /** Extracted raster images in parser output order. */
786
+ + images: ImageAttachmentRef[]
787
+ +}
788
+ +
789
+ +/** Durable metadata for one supported user-authored document. */
790
+ +export interface DocumentAttachmentRef extends Omit<FileAttachmentRef, 'mediaType' | 'name'> {
791
+ + /** Exact supported document media type admitted with the original bytes. */
792
+ + mediaType: DocumentMediaType
793
+ + /** Display name after path and control-character removal. */
794
+ + name: string
795
+ +}
796
+ +
797
+ /** Durable, serializable reference to one immutable normalized image. */
798
+ export interface ImageAttachmentRef {
799
+ /** Opaque storage identifier; never a filesystem path or bearer URL. */
800
+ @@ -42,6 +83,14 @@ export interface ImageAttachmentLimits {
801
+ mediaTypes: readonly ImageMediaType[]
802
+ }
803
+
804
+ +/** Deployment-resolved limits used by document upload admission. */
805
+ +export interface DocumentAttachmentLimits {
806
+ + maxDocumentBytes: number
807
+ + maxDocumentsPerMessage: number
808
+ + maxMessageDocumentBytes: number
809
+ + mediaTypes: readonly DocumentMediaType[]
810
+ +}
811
+ +
812
+ /** Base64-encoded image upload accompanying one wire request. */
813
+ export interface EncodedImageAttachment {
814
+ /** Declared media type, verified against the decoded bytes during admission. */
815
+ @@ -52,6 +101,14 @@ export interface EncodedImageAttachment {
816
+ name?: string
817
+ }
818
+
819
+ +/** Generic immutable bytes to commit to the shared content-addressed store. */
820
+ +export interface SaveFileAttachment {
821
+ + data: Uint8Array
822
+ + mediaType: string
823
+ + /** Optional display name; storage providers never interpret it as a path. */
824
+ + name?: string
825
+ +}
826
+ +
827
+ /** Request to validate and durably commit one image. */
828
+ export interface SaveImageAttachment {
829
+ data: Uint8Array
830
+ @@ -61,6 +118,12 @@ export interface SaveImageAttachment {
831
+ name?: string
832
+ }
833
+
834
+ +/** Stored generic file bytes returned after reference and digest verification. */
835
+ +export interface StoredFileAttachment {
836
+ + ref: FileAttachmentRef
837
+ + data: Uint8Array
838
+ +}
839
+ +
840
+ /** Stored image bytes returned after reference and digest verification. */
841
+ export interface StoredImageAttachment {
842
+ ref: ImageAttachmentRef
843
+ diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css
844
+ index 770a64cef5dba2baded4adb7afe63ae88c7fcffc..1cdfd8c3f5b1138b76ce294db6e0198c2226353d 100644
845
+ --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css
846
+ +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css
847
+ @@ -1,3 +1,34 @@
848
+ +.picker {
849
+ + display: flex;
850
+ + padding: 4px 12px 0;
851
+ +}
852
+ +
853
+ +.fileInput {
854
+ + display: none;
855
+ +}
856
+ +
857
+ +.add {
858
+ + display: grid;
859
+ + place-items: center;
860
+ + width: 28px;
861
+ + height: 28px;
862
+ + padding: 0;
863
+ + border: none;
864
+ + border-radius: 999px;
865
+ + background: var(--dsw-specific-selector);
866
+ + color: var(--dsw-alias-label-primary);
867
+ + cursor: pointer;
868
+ +}
869
+ +
870
+ +.add:hover:not(:disabled) {
871
+ + background: var(--dsw-alias-interactive-bg-hover-solid);
872
+ +}
873
+ +
874
+ +.add:disabled {
875
+ + opacity: 0.5;
876
+ + cursor: default;
877
+ +}
878
+ +
879
+ .rail {
880
+ min-width: 0;
881
+ padding: 4px 12px 0;
882
+ diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx
883
+ index 1e45dc6230ac024154822ddca86509bf2850d791..553c6bc24acfb65d11358ebf9fa9f0824469e376 100644
884
+ --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx
885
+ +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx
886
+ @@ -1,7 +1,10 @@
887
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
888
+ +import type { ChangeEvent } from 'react'
889
+ import type {
890
+ ComposerAttachment, ComposerAttachmentsProps,
891
+ } from '@deepseek-ai/dsh-client-ui-conversation/client'
892
+ +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
893
+ +import { IconPaperclipOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
894
+ import { AttachmentRail } from '../AttachmentRail.tsx'
895
+ import type { AttachmentRailItem } from '../AttachmentRail.tsx'
896
+ import { DropOverlay } from '../DropOverlay.tsx'
897
+ @@ -15,12 +18,16 @@ interface ComposerRailItem extends AttachmentRailItem {
898
+ }
899
+
900
+ /** Draft-image rail, document drop target, and original-image preview slot entry. */
901
+ +type Props = ComposerAttachmentsProps & PropsRenderSlots<'conversation.input.attachments.documents'>
902
+ +
903
+ export function ComposerAttachments({
904
+ - attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t,
905
+ -}: ComposerAttachmentsProps) {
906
+ + attachments, documents, canAcceptDrop, onAddFiles, onRemoveImage, onRemoveDocument,
907
+ + fileDropLabels, dropLimits, t, renderSlot,
908
+ +}: Props) {
909
+ const [preview, setPreview] = useState<ComposerAttachment | null>(null)
910
+ const [dragActive, setDragActive] = useState(false)
911
+ const dragDepth = useRef(0)
912
+ + const fileInput = useRef<HTMLInputElement>(null)
913
+ const closePreview = useCallback(() => { setPreview(null) }, [])
914
+
915
+ useEffect(() => {
916
+ @@ -62,7 +69,7 @@ export function ComposerAttachments({
917
+ if (dataTransfer === null) return
918
+ event.preventDefault()
919
+ reset()
920
+ - if (canAcceptDrop) onAddImages([...dataTransfer.files])
921
+ + if (canAcceptDrop) onAddFiles([...dataTransfer.files])
922
+ }
923
+ document.addEventListener('dragenter', onDragEnter)
924
+ document.addEventListener('dragover', onDragOver)
925
+ @@ -76,7 +83,14 @@ export function ComposerAttachments({
926
+ document.removeEventListener('drop', onDrop)
927
+ window.removeEventListener('dragend', reset)
928
+ }
929
+ - }, [canAcceptDrop, onAddImages])
930
+ + }, [canAcceptDrop, onAddFiles])
931
+ +
932
+ + const chooseFiles = useCallback(() => { fileInput.current?.click() }, [])
933
+ + const onFilesChosen = useCallback((event: ChangeEvent<HTMLInputElement>) => {
934
+ + const files = event.currentTarget.files
935
+ + if (files !== null && files.length > 0) onAddFiles([...files])
936
+ + event.currentTarget.value = ''
937
+ + }, [onAddFiles])
938
+
939
+ const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
940
+ id: attachment.id,
941
+ @@ -91,9 +105,28 @@ export function ComposerAttachments({
942
+ {dragActive && (
943
+ <DropOverlay
944
+ disabled={!canAcceptDrop}
945
+ - labels={dropOverlayLabels(t, canAcceptDrop, dropLimits)}
946
+ + labels={canAcceptDrop && fileDropLabels !== undefined
947
+ + ? fileDropLabels
948
+ + : dropOverlayLabels(t, canAcceptDrop, dropLimits)}
949
+ />
950
+ )}
951
+ + <div className={css.picker}>
952
+ + <input
953
+ + ref={fileInput}
954
+ + className={css.fileInput}
955
+ + type="file"
956
+ + multiple
957
+ + accept="image/png,image/jpeg,image/webp,image/gif,.pdf,.docx,.pptx,.xlsx"
958
+ + disabled={!canAcceptDrop}
959
+ + onChange={onFilesChosen}
960
+ + />
961
+ + <Tooltip label={t('attachment.add')} side="top">
962
+ + <button type="button" className={css.add} aria-label={t('attachment.add')} disabled={!canAcceptDrop} onClick={chooseFiles}>
963
+ + <IconPaperclipOutline16 />
964
+ + </button>
965
+ + </Tooltip>
966
+ + </div>
967
+ + {renderSlot('conversation.input.attachments.documents', { documents, onRemoveDocument })}
968
+ {railItems.length > 0 && (
969
+ <div className={css.rail}>
970
+ <AttachmentRail
971
+ diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx
972
+ index c84914db543de23165732809bbe9c723b34dfa90..70b28eb66089203432df9efed6368923c4409754 100644
973
+ --- a/packages/client/ui-attachment/src/client/MessageImages.tsx
974
+ +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx
975
+ @@ -1,8 +1,30 @@
976
+ +import type { ReactNode } from 'react'
977
+ import type { MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client'
978
+ +import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
979
+ +import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
980
+ +import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
981
+ import { ImageGallery } from '../MessageImage.tsx'
982
+ import { messageImageLabels } from './labels.ts'
983
+
984
+ -/** Historical message-image slot entry. */
985
+ -export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) {
986
+ - return <ImageGallery images={images} load={loadImage} align={align} labels={messageImageLabels(t)} />
987
+ +function AttachmentGallery({ images, loadImage, align, t, documentCards }: Omit<MessageImagesOwnerProps, 'documents'> & PropsLocale<'conversation'> & { documentCards: ReactNode }) {
988
+ + return (
989
+ + <>
990
+ + {documentCards}
991
+ + <ImageGallery images={images} load={loadImage} align={align} labels={messageImageLabels(t)} />
992
+ + </>
993
+ + )
994
+ +}
995
+ +
996
+ +/** Chat attachment slot entry. */
997
+ +export function MessageImages({ images, documents = [], loadImage, align, t, renderSlot }: MessageImagesProps) {
998
+ + return <AttachmentGallery images={images} loadImage={loadImage} align={align} t={t} documentCards={renderSlot('conversation.message.images.documents', { documents })} />
999
+ +}
1000
+ +
1001
+ +type TrajectoryProps = PropsRuntime<'conversation.trajectory.images'>
1002
+ + & PropsRenderSlots<'conversation.trajectory.images.documents'>
1003
+ + & PropsLocale<'conversation'>
1004
+ +
1005
+ +/** Trajectory attachment slot entry. */
1006
+ +export function TrajectoryImages({ images, documents = [], loadImage, align, t, renderSlot }: TrajectoryProps) {
1007
+ + return <AttachmentGallery images={images} loadImage={loadImage} align={align} t={t} documentCards={renderSlot('conversation.trajectory.images.documents', { documents })} />
1008
+ }
1009
+ diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts
1010
+ index 8fe94f64ee171798431716e3765e446ca95c935f..a045a3433ef7984df767ce00000ef52deaa9a5d6 100644
1011
+ --- a/packages/client/ui-attachment/src/client/index.ts
1012
+ +++ b/packages/client/ui-attachment/src/client/index.ts
1013
+ @@ -5,7 +5,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
1014
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
1015
+ import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
1016
+ import { ComposerAttachments } from './ComposerAttachments.tsx'
1017
+ -import { MessageImages } from './MessageImages.tsx'
1018
+ +import { MessageImages, TrajectoryImages } from './MessageImages.tsx'
1019
+
1020
+ /** Slot registry required by this presentation plugin. */
1021
+ export const inject = ['slots']
1022
+ @@ -15,13 +15,22 @@ export function apply(ctx: ClientContext): void {
1023
+ ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({
1024
+ name: 'conversation.input.attachments',
1025
+ locale: 'conversation',
1026
+ + children: {
1027
+ + 'conversation.input.attachments.documents': { kind: 'single', scope: 'session-maybe' },
1028
+ + },
1029
+ }, ComposerAttachments))
1030
+ ctx.slots.inject('conversation.message.images', () => ctx.slots.register({
1031
+ name: 'conversation.message.images',
1032
+ locale: 'conversation',
1033
+ + children: {
1034
+ + 'conversation.message.images.documents': { kind: 'single', scope: 'session' },
1035
+ + },
1036
+ }, MessageImages))
1037
+ ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({
1038
+ name: 'conversation.trajectory.images',
1039
+ locale: 'conversation',
1040
+ - }, MessageImages))
1041
+ + children: {
1042
+ + 'conversation.trajectory.images.documents': { kind: 'single', scope: 'session' },
1043
+ + },
1044
+ + }, TrajectoryImages))
1045
+ }
1046
+ diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx
1047
+ index ef77068f7e017845bd400c96bb63340aa0b3cc16..b5f9c55df4a2d75f99df8ab17755b3b20f345a2a 100644
1048
+ --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx
1049
+ +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx
1050
+ @@ -53,15 +53,16 @@ function attachment(id: string, name = `${id}.png`): ComposerAttachment {
1051
+ }
1052
+ }
1053
+
1054
+ -function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): ComposerAttachmentsProps {
1055
+ +function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): Parameters<typeof ComposerAttachments>[0] {
1056
+ return {
1057
+ attachments: [],
1058
+ canAcceptDrop: true,
1059
+ onAddImages: () => {},
1060
+ onRemoveImage: () => {},
1061
+ t,
1062
+ + renderSlot: () => null,
1063
+ ...overrides,
1064
+ - } as unknown as ComposerAttachmentsProps
1065
+ + } as unknown as Parameters<typeof ComposerAttachments>[0]
1066
+ }
1067
+
1068
+ describe('ComposerAttachments', () => {
1069
+ diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
1070
+ index 76a720b983be3c54d4af91435f5b82ecf746685c..62475e6924e1373611d7035b4319329d458d07e7 100644
1071
+ --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx
1072
+ +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx
1073
+ @@ -261,6 +261,7 @@ describe('ImageGallery', () => {
1074
+ }
1075
+ const props: MessageImagesProps = {
1076
+ sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
1077
+ + SessionProvider: ({ children }) => children,
1078
+ useSession,
1079
+ useSessions,
1080
+ useSessionPendingInteraction,
1081
+ @@ -281,6 +282,7 @@ describe('ImageGallery', () => {
1082
+ loadImage,
1083
+ align: 'end',
1084
+ t,
1085
+ + renderSlot: () => null,
1086
+ }
1087
+ const view = render(<MessageImages {...props} />)
1088
+ await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() })
1089
+ diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
1090
+ index 467e486ecb3b28ddf7aa99281ad231032a98a76c..d89c77009fb52bdf7992f8f35c14553bb5e798ed 100644
1091
+ --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx
1092
+ +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
1093
+ @@ -11,14 +11,17 @@ import { MessageIconActions } from './MessageIconActions.tsx'
1094
+ import css from './MessageItem.module.css'
1095
+
1096
+ type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
1097
+ +type UserDocument = Extract<UserMessageNode['content'][number], { type: 'document' }>
1098
+
1099
+ function contentParts(content: readonly unknown[]): {
1100
+ text: string
1101
+ images: { attachment: UserImage['attachment'] }[]
1102
+ + documents: { name: string; mediaType: string; bytes: number }[]
1103
+ rest: unknown[]
1104
+ } {
1105
+ const texts: string[] = []
1106
+ const images: { attachment: UserImage['attachment'] }[] = []
1107
+ + const documents: { name: string; mediaType: string; bytes: number }[] = []
1108
+ const rest: unknown[] = []
1109
+ for (const block of content) {
1110
+ const b = block as { type?: string; text?: string; attachment?: unknown }
1111
+ @@ -26,9 +29,13 @@ function contentParts(content: readonly unknown[]): {
1112
+ else if (b.type === 'image' && b.attachment !== undefined) {
1113
+ images.push({ attachment: (b as UserImage).attachment })
1114
+ }
1115
+ + else if (b.type === 'document' && b.attachment !== undefined) {
1116
+ + const attachment = (b as UserDocument).attachment
1117
+ + documents.push({ name: attachment.name, mediaType: attachment.mediaType, bytes: attachment.bytes })
1118
+ + }
1119
+ else rest.push(block)
1120
+ }
1121
+ - return { text: texts.join(''), images, rest }
1122
+ + return { text: texts.join(''), images, documents, rest }
1123
+ }
1124
+
1125
+ function retrySeconds(milliseconds: number): number {
1126
+ @@ -164,7 +171,7 @@ function UserStyleBubble({
1127
+ previewImages?: readonly MessageImageSource[]
1128
+ t: ChatViewSlotProps['t']
1129
+ }): ReactNode {
1130
+ - const { text, images: contentImages, rest } = contentParts(content)
1131
+ + const { text, images: contentImages, documents, rest } = contentParts(content)
1132
+ const images = previewImages ?? contentImages
1133
+ const truncated = (total: number): string => t('json.truncated', { total })
1134
+ const showBubble = text !== '' || rest.length > 0
1135
+ @@ -176,7 +183,7 @@ function UserStyleBubble({
1136
+ data-time-hover-root
1137
+ >
1138
+ <div className={css.userStack}>
1139
+ - {renderMessageImages({ images, align: 'end' })}
1140
+ + {renderMessageImages({ images, documents, align: 'end' })}
1141
+ {showBubble && <div className={css.bubble}>
1142
+ {projectUserText(text, referenceLabels)}
1143
+ {rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
1144
+ diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
1145
+ index 307d8f703b013962d19212618c522ef35feec0d2..0183aede0996a91d4de2f17bdf54e0b333708eac 100644
1146
+ --- a/packages/client/ui-chat/src/client/contract/slots.ts
1147
+ +++ b/packages/client/ui-chat/src/client/contract/slots.ts
1148
+ @@ -1,7 +1,7 @@
1149
+ /** Chat-owned Slot declarations and composed component props. */
1150
+ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
1151
+ import type {
1152
+ - ConversationTurnDataMap, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
1153
+ + ConversationTurnDataMap, MessageDocumentSource, MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
1154
+ } from '@deepseek-ai/dsh-client-ui-conversation/client'
1155
+ import type {
1156
+ InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
1157
+ @@ -136,7 +136,9 @@ export type ChatViewSlotProps =
1158
+ & PropsLocale<'chat'>
1159
+
1160
+ /** Full props of the durable-message image renderer. */
1161
+ -export type MessageImagesProps = PropsRuntime<'conversation.message.images'> & PropsLocale<'conversation'>
1162
+ +export type MessageImagesProps = PropsRuntime<'conversation.message.images'>
1163
+ + & PropsRenderSlots<'conversation.message.images.documents'>
1164
+ + & PropsLocale<'conversation'>
1165
+
1166
+ /** Details-panel callbacks. */
1167
+ export interface DetailsInjected {
1168
+ @@ -182,6 +184,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
1169
+ * registration replaces the shipped gallery; without one, images are omitted.
1170
+ */
1171
+ 'conversation.message.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
1172
+ + 'conversation.message.images.documents': { kind: 'single'; scope: 'session'; owner: { documents: readonly MessageDocumentSource[] } }
1173
+ /**
1174
+ * Command row keyed by the command name. The component receives the folded
1175
+ * command lifecycle and linked compaction when present. Reusing a key
1176
+ diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
1177
+ index b154e73f4abc7034d93cac9b4634c65483731a08..8c9c1a2c7a2a7d2a3ea02e7931959065589d19dc 100644
1178
+ --- a/packages/client/ui-conversation/src/client/apply.ts
1179
+ +++ b/packages/client/ui-conversation/src/client/apply.ts
1180
+ @@ -254,6 +254,10 @@ export function apply(ctx: Context): void {
1181
+ addImages: undefined,
1182
+ removeImage: undefined,
1183
+ draftImages: undefined,
1184
+ + addFiles: undefined,
1185
+ + resolveDocumentDropLabels: undefined,
1186
+ + removeDocument: undefined,
1187
+ + draftDocuments: undefined,
1188
+ resolveSubmitMode: (running, gesture, steeringAvailable) =>
1189
+ submissionPolicy.resolve(running, gesture, steeringAvailable),
1190
+ toggleCommandMenu: undefined,
1191
+ @@ -269,25 +273,51 @@ export function apply(ctx: Context): void {
1192
+ const conversation = concreteConversation(ctx)
1193
+ const shell = inputHub.shell(sessionId)
1194
+ const inputTriggers = inputHub.inputTriggers(sessionId)
1195
+ + const documentPromptAvailable = ctx.get('documentPrompt') !== undefined
1196
+ + const addFiles: NonNullable<ComposerBarInjected['addFiles']> = (imageFiles, documentFiles, limits) => {
1197
+ + let images: ReturnType<ConversationController['createDraftImages']> = []
1198
+ + let documents: ReturnType<ConversationController['createDraftDocuments']> = []
1199
+ + try {
1200
+ + if (documentFiles.length > 0) {
1201
+ + if (limits === undefined) throw new Error('conversation.addFiles: document limits unavailable')
1202
+ + documents = conversation.createDraftDocuments(documentFiles, limits, shell.snapshot.imageIds)
1203
+ + }
1204
+ + images = conversation.createDraftImages(imageFiles)
1205
+ + const ids = [
1206
+ + ...images.map(image => image.id),
1207
+ + ...documents.map(document => document.id),
1208
+ + ]
1209
+ + if (!shell.addImages(ids)) {
1210
+ + conversation.releaseDraftImages(images)
1211
+ + for (const document of documents) conversation.releaseDraftDocument(document.id)
1212
+ + }
1213
+ + return null
1214
+ + } catch (error: unknown) {
1215
+ + conversation.releaseDraftImages(images)
1216
+ + for (const document of documents) conversation.releaseDraftDocument(document.id)
1217
+ + if (error instanceof UnsupportedImageMediaTypeError) return t('image.unsupportedType')
1218
+ + return error instanceof Error ? error.message : String(error)
1219
+ + }
1220
+ + }
1221
+ return {
1222
+ keyboard: shell,
1223
+ - addImages: (files) => {
1224
+ - try {
1225
+ - const images = conversation.createDraftImages(files)
1226
+ - if (!shell.addImages(images.map(image => image.id))) {
1227
+ - conversation.releaseDraftImages(images)
1228
+ - }
1229
+ - return null
1230
+ - } catch (error: unknown) {
1231
+ - if (error instanceof UnsupportedImageMediaTypeError) return t('image.unsupportedType')
1232
+ - return error instanceof Error ? error.message : String(error)
1233
+ - }
1234
+ - },
1235
+ + addImages: files => addFiles(files, [], undefined),
1236
+ + addFiles,
1237
+ removeImage: (id) => {
1238
+ conversation.releaseDraftImage(id)
1239
+ shell.removeImage(id)
1240
+ },
1241
+ draftImages: ids => conversation.draftImages(ids),
1242
+ + resolveDocumentDropLabels: documentPromptAvailable
1243
+ + ? limits => conversation.documentDropLabels(limits)
1244
+ + : undefined,
1245
+ + removeDocument: documentPromptAvailable
1246
+ + ? (id) => {
1247
+ + conversation.releaseDraftDocument(id)
1248
+ + shell.removeImage(id)
1249
+ + }
1250
+ + : undefined,
1251
+ + draftDocuments: documentPromptAvailable ? ids => conversation.draftDocuments(ids) : undefined,
1252
+ resolveSubmitMode: (running, gesture, steeringAvailable) =>
1253
+ submissionPolicy.resolve(running, gesture, steeringAvailable),
1254
+ toggleCommandMenu: inputTriggers === undefined
1255
+ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
1256
+ index 377a75ee0019ac0b791bf659ac390df1bd46d81a..9b8cc42deb4c58fc8e00fc54aa244b829f9968f8 100644
1257
+ --- a/packages/client/ui-conversation/src/client/contract/slots.ts
1258
+ +++ b/packages/client/ui-conversation/src/client/contract/slots.ts
1259
+ @@ -34,17 +34,45 @@ export interface ComposerAttachment {
1260
+ height?: number
1261
+ }
1262
+
1263
+ +/** Document intake limits projected from the mounted Host attachment provider. */
1264
+ +export interface ComposerDocumentLimits {
1265
+ + readonly maxDocumentBytes: number
1266
+ + readonly maxDocumentsPerMessage: number
1267
+ + readonly maxMessageDocumentBytes: number
1268
+ + readonly mediaTypes: readonly string[]
1269
+ +}
1270
+ +
1271
+ +/** Browser-owned document that has not crossed the durable Host boundary. */
1272
+ +export interface ComposerDocumentAttachment {
1273
+ + kind: 'document'
1274
+ + id: DraftAttachmentId
1275
+ + file: File
1276
+ +}
1277
+ +
1278
+ /** Input state handed to the optional attachment presentation plugin. */
1279
+ +export interface ComposerDocumentsOwnerProps {
1280
+ + documents: readonly ComposerDocumentAttachment[]
1281
+ + onRemoveDocument: (id: DraftAttachmentId) => void
1282
+ +}
1283
+ +
1284
+ export interface ComposerAttachmentsOwnerProps {
1285
+ /** Browser-owned draft images in input order. */
1286
+ attachments: readonly ComposerAttachment[]
1287
+ - /** Whether a document-level file drop may add images now. */
1288
+ + /** Browser-owned draft documents in input order. */
1289
+ + documents: readonly ComposerDocumentAttachment[]
1290
+ + /** Whether a document-level file drop may add attachments now. */
1291
+ canAcceptDrop: boolean
1292
+ /** Add one dropped batch through the composer's validation path. */
1293
+ onAddImages: (files: readonly File[]) => void
1294
+ + /** Add one dropped or pasted file batch as a single draft transaction. */
1295
+ + onAddFiles: (files: readonly File[]) => void
1296
+ /** Remove one draft image through the Conversation service. */
1297
+ onRemoveImage: (id: DraftAttachmentId) => void
1298
+ - /** Display-ready limits for the drop invitation. */
1299
+ + /** Remove one draft document through the Conversation service. */
1300
+ + onRemoveDocument: (id: DraftAttachmentId) => void
1301
+ + /** Capability-owned localized copy for mixed file drops. */
1302
+ + fileDropLabels?: { readonly title: string; readonly desc?: string } | undefined
1303
+ + /** Display-ready limits for the image drop invitation. */
1304
+ dropLimits?: { readonly count: number; readonly size: string } | undefined
1305
+ }
1306
+
1307
+ @@ -71,10 +99,19 @@ export type MessageImageLoader = ((attachment: ImageAttachmentRef) => Promise<st
1308
+ peek?: (attachment: ImageAttachmentRef) => string | undefined
1309
+ }
1310
+
1311
+ -/** Message image group handed to the optional attachment presentation plugin. */
1312
+ +/** Display metadata for one durable Document history card. */
1313
+ +export interface MessageDocumentSource {
1314
+ + readonly name: string
1315
+ + readonly mediaType: string
1316
+ + readonly bytes: number
1317
+ +}
1318
+ +
1319
+ +/** Message attachment group handed to the optional attachment presentation plugin. */
1320
+ export interface MessageImagesOwnerProps {
1321
+ /** Durable references or submission-echo previews in source order. */
1322
+ images: readonly MessageImageSource[]
1323
+ + /** Durable Document cards associated with this user message. */
1324
+ + documents?: readonly MessageDocumentSource[]
1325
+ /** Session-authorized image URL loader for the durable arm. */
1326
+ loadImage: MessageImageLoader
1327
+ /** Horizontal placement inside the owning record. */
1328
+ @@ -141,6 +178,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
1329
+ scope: 'session-maybe'
1330
+ owner: ComposerAttachmentsOwnerProps
1331
+ }
1332
+ + /** Document cards rendered inside the shared attachment rail. */
1333
+ + 'conversation.input.attachments.documents': {
1334
+ + kind: 'single'
1335
+ + scope: 'session-maybe'
1336
+ + owner: ComposerDocumentsOwnerProps
1337
+ + }
1338
+ /** Plan control inside the composer tool row. */
1339
+ 'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
1340
+ /** Model selector inside the composer tool row. */
1341
+ @@ -267,6 +310,14 @@ export interface ComposerBarInjected {
1342
+ addImages: ((files: readonly File[]) => string | null) | undefined
1343
+ removeImage: ((id: DraftAttachmentId) => void) | undefined
1344
+ draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
1345
+ + addFiles: ((
1346
+ + imageFiles: readonly File[],
1347
+ + documentFiles: readonly File[],
1348
+ + documentLimits?: ComposerDocumentLimits,
1349
+ + ) => string | null) | undefined
1350
+ + resolveDocumentDropLabels: ((limits: ComposerDocumentLimits) => { readonly title: string; readonly desc: string }) | undefined
1351
+ + removeDocument: ((id: DraftAttachmentId) => void) | undefined
1352
+ + draftDocuments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerDocumentAttachment[]) | undefined
1353
+ resolveSubmitMode: (
1354
+ running: boolean,
1355
+ gesture: ComposerSubmitGesture,
1356
+ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
1357
+ index 102b385e8cfedd49b769c45446b31e0b96dd1782..1b08839b96a6a937b3216da2092ca6f2d4981c29 100644
1358
+ --- a/packages/client/ui-conversation/src/client/index.ts
1359
+ +++ b/packages/client/ui-conversation/src/client/index.ts
1360
+ @@ -46,14 +46,14 @@ export { ConversationViewRegistry } from './conversation/view-registry.ts'
1361
+
1362
+ export type { ConversationKey } from './locales.ts'
1363
+ export type {
1364
+ - ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
1365
+ + ComposerAttachment, ComposerDocumentAttachment, ComposerDocumentLimits, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps,
1366
+ ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps,
1367
+ ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps,
1368
+ ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps,
1369
+ ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps,
1370
+ ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps,
1371
+ HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone,
1372
+ - MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,
1373
+ + MessageDocumentSource, MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation,
1374
+ UseConversationViews,
1375
+ } from './contract/slots.ts'
1376
+ export type {
1377
+ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
1378
+ index 9eaf2a82a02fad08032dfc50d2720b8c77d26c69..1cf4726092bea967269029a8bc9c2b335014b457 100644
1379
+ --- a/packages/client/ui-conversation/src/client/locales.ts
1380
+ +++ b/packages/client/ui-conversation/src/client/locales.ts
1381
+ @@ -27,6 +27,7 @@ export const zh = {
1382
+ 'image.dropDesc': '最多 {count} 张,每张 {size}',
1383
+ 'image.dropBlocked': '当前无法添加图片',
1384
+ 'image.pending': '待发送图片',
1385
+ + 'attachment.add': '添加文件',
1386
+ 'image.openOriginal': '查看原图',
1387
+ 'image.openOriginalLabel': '{label},点击查看原图',
1388
+ 'image.remove': '移除图片 {name}',
1389
+ @@ -175,6 +176,7 @@ export const en = {
1390
+ 'image.dropDesc': 'Up to {count} images, {size} each',
1391
+ 'image.dropBlocked': 'Images cannot be added right now',
1392
+ 'image.pending': 'Pending images',
1393
+ + 'attachment.add': 'Add files',
1394
+ 'image.openOriginal': 'View original',
1395
+ 'image.openOriginalLabel': '{label}, click to view original',
1396
+ 'image.remove': 'Remove image {name}',
1397
+ diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts
1398
+ index 2fb0c99b51092091815af04a172699de24cd867d..0321824f6ada49487995ec01f4487e2b51d5ef47 100644
1399
+ --- a/packages/client/ui-conversation/src/client/service.ts
1400
+ +++ b/packages/client/ui-conversation/src/client/service.ts
1401
+ @@ -18,7 +18,7 @@ import type {
1402
+ } from '@deepseek-ai/dsh-api-session-controller/client'
1403
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
1404
+ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
1405
+ -import type { ComposerAttachment } from './contract/slots.ts'
1406
+ +import type { ComposerAttachment, ComposerDocumentAttachment, ComposerDocumentLimits } from './contract/slots.ts'
1407
+ import type { QueueAction, QueueItemId } from './contract/queue.ts'
1408
+ import type { ComposerBlocks } from './contract/composer-blocks.ts'
1409
+ import type {
1410
+ @@ -26,6 +26,35 @@ import type {
1411
+ } from './contract/input.ts'
1412
+ import type { InputSubmitMode } from './contract/composer-submission.ts'
1413
+
1414
+ +type DocumentMediaType =
1415
+ + | 'application/pdf'
1416
+ + | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
1417
+ + | 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
1418
+ + | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
1419
+ +
1420
+ +type DocumentRequestId = ReturnType<SessionFace['beginSubmission']>['requestId']
1421
+ +
1422
+ +type SerializedDraftAttachment =
1423
+ + | ({ readonly type: 'image' } & SubmitImageAttachment)
1424
+ + | { readonly type: 'document'; readonly mediaType: DocumentMediaType; readonly data: string; readonly name: string }
1425
+ +
1426
+ +interface DocumentPromptClient {
1427
+ + commandUnsupported(): string
1428
+ + dropLabels(limits: ComposerDocumentLimits): { readonly title: string; readonly desc: string }
1429
+ + validateIntake(
1430
+ + files: readonly File[],
1431
+ + existing: readonly ComposerDocumentAttachment[],
1432
+ + limits: ComposerDocumentLimits,
1433
+ + ): string | null
1434
+ + submit(request: {
1435
+ + readonly sessionId: SessionId
1436
+ + readonly requestId: DocumentRequestId
1437
+ + readonly mode: InputSubmitMode
1438
+ + readonly content: readonly ({ readonly type: 'text'; readonly text: string } | SerializedDraftAttachment)[]
1439
+ + readonly signal?: AbortSignal
1440
+ + }): Promise<{ readonly ok: true; readonly value: { readonly accepted: true } } | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: Record<string, unknown> } }>
1441
+ +}
1442
+ +
1443
+ /**
1444
+ * The outward conversation face (`ctx.conversation`): the scope-addressed
1445
+ * verbs and the input registry other plugins may reach — and exactly what a
1446
+ @@ -64,7 +93,9 @@ export interface IConversation {
1447
+ loadOlder(): Promise<void>
1448
+ }
1449
+
1450
+ -/** Create one browser-only draft descriptor; only its id enters input state. */
1451
+ +type BrowserDraftAttachment = ComposerAttachment | ComposerDocumentAttachment
1452
+ +
1453
+ +/** Create one browser-only image descriptor; only its id enters input state. */
1454
+ function browserDraftAttachment(file: File): ComposerAttachment {
1455
+ return {
1456
+ kind: 'image',
1457
+ @@ -74,6 +105,10 @@ function browserDraftAttachment(file: File): ComposerAttachment {
1458
+ }
1459
+ }
1460
+
1461
+ +function browserDraftDocument(file: File): ComposerDocumentAttachment {
1462
+ + return { kind: 'document', id: randomUUID() as DraftAttachmentId, file }
1463
+ +}
1464
+ +
1465
+ /**
1466
+ * Fill the draft's intrinsic dimensions once the browser parses the image
1467
+ * header (a metadata read off the preview URL, not a full decode). Failures
1468
+ @@ -149,7 +184,7 @@ export class ConversationController extends Service implements IConversation {
1469
+ readonly input: SessionInputResolver
1470
+ /** The per-session composer-block registry. */
1471
+ readonly blocks: ComposerBlocks
1472
+ - private readonly draftAttachments = new Map<DraftAttachmentId, ComposerAttachment>()
1473
+ + private readonly draftAttachments = new Map<DraftAttachmentId, BrowserDraftAttachment>()
1474
+
1475
+ /**
1476
+ * @param ctx - owning root context (the plugin apply context; the service
1477
+ @@ -164,7 +199,7 @@ export class ConversationController extends Service implements IConversation {
1478
+ this.blocks = config.blocks
1479
+ ctx.effect(() => () => {
1480
+ for (const attachment of this.draftAttachments.values()) {
1481
+ - revokePreview(attachment.previewUrl)
1482
+ + if (attachment.kind === 'image') revokePreview(attachment.previewUrl)
1483
+ }
1484
+ this.draftAttachments.clear()
1485
+ }, 'conversation draft attachments')
1486
+ @@ -183,15 +218,12 @@ export class ConversationController extends Service implements IConversation {
1487
+ }
1488
+
1489
+ /**
1490
+ - * Submit ordered draft images with text through one host admission. A local
1491
+ - * submission echo enters the session snapshot synchronously; serialization
1492
+ - * and the prompt round-trip start after the browser can paint it. On the
1493
+ - * echo's observed retirement the draft images hand their preview URLs to
1494
+ - * the durable image cache and leave the registry; on failure they stay
1495
+ - * registered so the composer can restore them.
1496
+ + * Submit ordered draft attachments with text through one Host admission.
1497
+ + * Images and documents share the input machine's opaque id list, so failure
1498
+ + * restoration and relative order remain one transaction.
1499
+ * @param session - target session.
1500
+ * @param text - serialized prompt text.
1501
+ - * @param imageIds - ordered draft-local attachment ids.
1502
+ + * @param attachmentIds - ordered draft-local attachment ids.
1503
+ * @param mode - queue or steer delivery selected by composer policy.
1504
+ * @param signal - optional cancellation for the complete Host admission.
1505
+ * @returns the Host admission outcome; local attachment preparation failures reject.
1506
+ @@ -199,48 +231,75 @@ export class ConversationController extends Service implements IConversation {
1507
+ async sendSession(
1508
+ session: SessionFace,
1509
+ text: string,
1510
+ - imageIds: readonly DraftAttachmentId[],
1511
+ + attachmentIds: readonly DraftAttachmentId[],
1512
+ mode: InputSubmitMode,
1513
+ signal?: AbortSignal,
1514
+ ): Promise<SubmitOutcome> {
1515
+ - const attachments = this.draftImages(imageIds)
1516
+ - if (attachments.length !== imageIds.length) {
1517
+ - throw new Error('conversation.sendSession: one or more draft images are no longer available')
1518
+ + const attachments = this.draftAttachmentList(attachmentIds)
1519
+ + if (attachments.length !== attachmentIds.length) {
1520
+ + throw new Error('conversation.sendSession: one or more draft attachments are no longer available')
1521
+ + }
1522
+ + const images = attachments.filter((attachment): attachment is ComposerAttachment => attachment.kind === 'image')
1523
+ + const hasDocument = attachments.some(attachment => attachment.kind === 'document')
1524
+ + const content = async (): Promise<readonly ({ readonly type: 'text'; readonly text: string } | SerializedDraftAttachment)[]> => [
1525
+ + ...await this.serializeAttachments(attachments),
1526
+ + ...(text === '' ? [] : [{ type: 'text' as const, text }]),
1527
+ + ]
1528
+ + const submit = async (requestId: DocumentRequestId): Promise<{ readonly ok: true } | { readonly ok: false; readonly text?: string }> => {
1529
+ + if (!hasDocument) {
1530
+ + const prepared = await content()
1531
+ + const result = await session.prompt(
1532
+ + prepared as Parameters<SessionFace['prompt']>[0],
1533
+ + mode,
1534
+ + signal,
1535
+ + requestId,
1536
+ + )
1537
+ + return result.ok ? { ok: true } : { ok: false }
1538
+ + }
1539
+ + const result = await this.documentPrompt().submit({
1540
+ + sessionId: session.sessionId,
1541
+ + requestId,
1542
+ + mode,
1543
+ + content: await content(),
1544
+ + ...(signal === undefined ? {} : { signal }),
1545
+ + })
1546
+ + return result.ok ? { ok: true } : { ok: false, text: result.error.message }
1547
+ }
1548
+ +
1549
+ if (session.getSnapshot().subagent !== null) {
1550
+ - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
1551
+ - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
1552
+ - const result = await session.prompt(content, mode, signal)
1553
+ - return result.ok ? { kind: 'success' } : { kind: 'error' }
1554
+ + const result = await submit(randomUUID() as DocumentRequestId)
1555
+ + if (!result.ok) return { kind: 'error', ...(result.text === undefined ? {} : { text: result.text }) }
1556
+ + this.releaseDraftAttachments(attachments)
1557
+ + return { kind: 'success' }
1558
+ }
1559
+ +
1560
+ let finishRetirement: ((retirement: PendingSubmissionRetirement) => void) | undefined
1561
+ const retirement = attachments.length === 0
1562
+ ? undefined
1563
+ : new Promise<PendingSubmissionRetirement>((resolve) => { finishRetirement = resolve })
1564
+ const submission = session.beginSubmission({
1565
+ text,
1566
+ - images: attachments.map(attachment => ({
1567
+ + images: images.map(attachment => ({
1568
+ previewUrl: attachment.previewUrl,
1569
+ ...(attachment.file.name === '' ? {} : { name: attachment.file.name }),
1570
+ ...(attachment.width === undefined ? {} : { width: attachment.width }),
1571
+ ...(attachment.height === undefined ? {} : { height: attachment.height }),
1572
+ })),
1573
+ onRetire: (settlement) => {
1574
+ - this.settleSubmittedImages(session.sessionId, attachments, settlement)
1575
+ + this.settleSubmittedAttachments(session.sessionId, attachments, settlement)
1576
+ finishRetirement?.(settlement)
1577
+ },
1578
+ })
1579
+ - let content: Parameters<SessionFace['prompt']>[0]
1580
+ try {
1581
+ await nextPaint()
1582
+ - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file))
1583
+ - content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
1584
+ + const result = await submit(submission.requestId)
1585
+ + if (!result.ok) {
1586
+ + return { kind: 'error', ...(result.text === undefined ? {} : { text: result.text }) }
1587
+ + }
1588
+ } catch (error) {
1589
+ submission.abandon()
1590
+ throw error
1591
+ }
1592
+ - const result = await session.prompt(content, mode, signal, submission.requestId)
1593
+ - if (!result.ok) return { kind: 'error' }
1594
+ if (retirement !== undefined && (await retirement).reason !== 'observed') return { kind: 'error' }
1595
+ return { kind: 'success' }
1596
+ }
1597
+ @@ -260,6 +319,23 @@ export class ConversationController extends Service implements IConversation {
1598
+ })
1599
+ }
1600
+
1601
+ + /** Register supported documents without creating object URLs. */
1602
+ + createDraftDocuments(
1603
+ + files: readonly File[],
1604
+ + limits: ComposerDocumentLimits,
1605
+ + existingIds: readonly DraftAttachmentId[],
1606
+ + ): readonly ComposerDocumentAttachment[] {
1607
+ + const existing = this.draftDocuments(existingIds)
1608
+ + const rejected = this.documentPrompt().validateIntake(files, existing, limits)
1609
+ + if (rejected !== null) throw new Error(rejected)
1610
+ + for (const file of files) documentMediaType(file.type)
1611
+ + return files.map((file) => {
1612
+ + const attachment = browserDraftDocument(file)
1613
+ + this.draftAttachments.set(attachment.id, attachment)
1614
+ + return attachment
1615
+ + })
1616
+ + }
1617
+ +
1618
+ /**
1619
+ * Resolve ordered input-state ids to runtime-owned draft images.
1620
+ * @param ids - draft attachment ids.
1621
+ @@ -269,7 +345,7 @@ export class ConversationController extends Service implements IConversation {
1622
+ const attachments: ComposerAttachment[] = []
1623
+ for (const id of ids) {
1624
+ const attachment = this.draftAttachments.get(id)
1625
+ - if (attachment !== undefined) attachments.push(attachment)
1626
+ + if (attachment?.kind === 'image') attachments.push(attachment)
1627
+ }
1628
+ return attachments
1629
+ }
1630
+ @@ -281,34 +357,63 @@ export class ConversationController extends Service implements IConversation {
1631
+ * @param imageIds - ordered draft-local attachment ids.
1632
+ * @returns base64 payloads in id order.
1633
+ */
1634
+ + /** Resolve ordered input-state ids to document drafts only. */
1635
+ + draftDocuments(ids: readonly DraftAttachmentId[]): readonly ComposerDocumentAttachment[] {
1636
+ + const attachments: ComposerDocumentAttachment[] = []
1637
+ + for (const id of ids) {
1638
+ + const attachment = this.draftAttachments.get(id)
1639
+ + if (attachment?.kind === 'document') attachments.push(attachment)
1640
+ + }
1641
+ + return attachments
1642
+ + }
1643
+ +
1644
+ + /** Resolve every ordered mixed draft attachment; missing ids are omitted for caller detection. */
1645
+ + draftAttachmentList(ids: readonly DraftAttachmentId[]): readonly BrowserDraftAttachment[] {
1646
+ + const attachments: BrowserDraftAttachment[] = []
1647
+ + for (const id of ids) {
1648
+ + const attachment = this.draftAttachments.get(id)
1649
+ + if (attachment !== undefined) attachments.push(attachment)
1650
+ + }
1651
+ + return attachments
1652
+ + }
1653
+ +
1654
+ async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise<readonly SubmitImageAttachment[]> {
1655
+ const attachments = this.draftImages(imageIds)
1656
+ if (attachments.length !== imageIds.length) {
1657
+ - throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available')
1658
+ + throw new Error(this.documentPrompt().commandUnsupported())
1659
+ }
1660
+ - return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file)))
1661
+ + const serialized: SubmitImageAttachment[] = []
1662
+ + for (const attachment of attachments) serialized.push(await this.encodeImage(attachment.file))
1663
+ + return serialized
1664
+ }
1665
+
1666
+ - /**
1667
+ - * Release one browser-owned draft image and preview URL.
1668
+ - * @param id - draft attachment id.
1669
+ - */
1670
+ - releaseDraftImage(id: DraftAttachmentId): void {
1671
+ + /** Resolve capability-owned mixed file drop copy in the active locale. */
1672
+ + documentDropLabels(limits: ComposerDocumentLimits): { readonly title: string; readonly desc: string } {
1673
+ + return this.documentPrompt().dropLabels(limits)
1674
+ + }
1675
+ +
1676
+ + /** Release one browser-owned draft attachment. */
1677
+ + releaseDraftAttachment(id: DraftAttachmentId): void {
1678
+ const attachment = this.draftAttachments.get(id)
1679
+ if (attachment === undefined) return
1680
+ this.draftAttachments.delete(id)
1681
+ - revokePreview(attachment.previewUrl)
1682
+ + if (attachment.kind === 'image') revokePreview(attachment.previewUrl)
1683
+ }
1684
+
1685
+ - /**
1686
+ - * Release a set of browser-owned draft images.
1687
+ - * @param attachments - descriptors to release.
1688
+ - */
1689
+ - releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
1690
+ - for (const attachment of attachments) this.releaseDraftImage(attachment.id)
1691
+ + /** Release one existing image caller's draft attachment. */
1692
+ + releaseDraftImage(id: DraftAttachmentId): void {
1693
+ + this.releaseDraftAttachment(id)
1694
+ }
1695
+
1696
+ - /** Apply one operation to a pending queue occurrence. */
1697
+ + /** Release one document draft through the same registry owner. */
1698
+ + releaseDraftDocument(id: DraftAttachmentId): void {
1699
+ + this.releaseDraftAttachment(id)
1700
+ + }
1701
+ +
1702
+ + /** Release a set of image drafts. */
1703
+ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
1704
+ + for (const attachment of attachments) this.releaseDraftAttachment(attachment.id)
1705
+ + }
1706
+ async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
1707
+ const session = this.scopedSession('updateQueue')
1708
+ const result = await session.updateQueue(itemId, action)
1709
+ @@ -358,34 +463,50 @@ export class ConversationController extends Service implements IConversation {
1710
+ return sessions
1711
+ }
1712
+
1713
+ - /**
1714
+ - * Settle one submission's draft images when its echo retires. Observed:
1715
+ - * each image leaves the registry, handing its preview URL to the durable
1716
+ - * image cache (seeded under the admitted reference so the transcript node
1717
+ - * renders immediately while the cache reads canonical bytes) or revoking it
1718
+ - * when the cache already holds that reference. Failed: nothing changes;
1719
+ - * the ids stay registered for the composer's rail restore.
1720
+ - */
1721
+ - private settleSubmittedImages(
1722
+ + /** Settle one mixed submission after its durable Session event is observed. */
1723
+ + private settleSubmittedAttachments(
1724
+ sessionId: SessionId,
1725
+ - attachments: readonly ComposerAttachment[],
1726
+ + attachments: readonly BrowserDraftAttachment[],
1727
+ retirement: PendingSubmissionRetirement,
1728
+ ): void {
1729
+ if (retirement.reason !== 'observed') return
1730
+ const uiConversation = this.ctx.get('uiConversation')
1731
+ - attachments.forEach((attachment, index) => {
1732
+ - const live = this.draftAttachments.get(attachment.id)
1733
+ - if (live === undefined) return
1734
+ + let imageIndex = 0
1735
+ + for (const attachment of attachments) {
1736
+ + if (!this.draftAttachments.has(attachment.id)) continue
1737
+ this.draftAttachments.delete(attachment.id)
1738
+ - const ref = retirement.attachments[index]
1739
+ - if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return
1740
+ + if (attachment.kind === 'document') continue
1741
+ + const ref = retirement.attachments[imageIndex++]
1742
+ + if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue
1743
+ revokePreview(attachment.previewUrl)
1744
+ - })
1745
+ + }
1746
+ + }
1747
+ +
1748
+ + private releaseDraftAttachments(attachments: readonly BrowserDraftAttachment[]): void {
1749
+ + for (const attachment of attachments) this.releaseDraftAttachment(attachment.id)
1750
+ + }
1751
+ +
1752
+ + private documentPrompt(): DocumentPromptClient {
1753
+ + const service = this.ctx.get('documentPrompt') as unknown as DocumentPromptClient | undefined
1754
+ + if (service === undefined) throw new Error('Document prompt service is unavailable.')
1755
+ + return service
1756
+ }
1757
+
1758
+ - /** Convert browser files to canonical base64 prompt parts. */
1759
+ - private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
1760
+ - return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) })))
1761
+ + private async serializeAttachments(attachments: readonly BrowserDraftAttachment[]): Promise<SerializedDraftAttachment[]> {
1762
+ + const serialized: SerializedDraftAttachment[] = []
1763
+ + for (const attachment of attachments) {
1764
+ + if (attachment.kind === 'image') {
1765
+ + serialized.push({ type: 'image', ...await this.encodeImage(attachment.file) })
1766
+ + } else {
1767
+ + serialized.push({
1768
+ + type: 'document',
1769
+ + mediaType: documentMediaType(attachment.file.type),
1770
+ + data: await base64Of(attachment.file),
1771
+ + name: attachment.file.name,
1772
+ + })
1773
+ + }
1774
+ + }
1775
+ + return serialized
1776
+ }
1777
+
1778
+ /** Canonical base64 wire form of one browser image file. */
1779
+ @@ -410,6 +531,18 @@ function imageMediaType(value: string): ImageMediaType {
1780
+ }
1781
+ }
1782
+
1783
+ +function documentMediaType(value: string): DocumentMediaType {
1784
+ + switch (value) {
1785
+ + case 'application/pdf':
1786
+ + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
1787
+ + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
1788
+ + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
1789
+ + return value
1790
+ + default:
1791
+ + throw new Error(`Unsupported document media type: ${value}`)
1792
+ + }
1793
+ +}
1794
+ +
1795
+ function revokePreview(url: string): void {
1796
+ if (url.startsWith('blob:')) URL.revokeObjectURL(url)
1797
+ }
1798
+ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
1799
+ index d6126acdfd62b2b9be0af3fbb99c63b4435859b8..cf99cdac86bc2f308032de81baa89c040761ff5e 100644
1800
+ --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
1801
+ +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
1802
+ @@ -40,7 +40,8 @@ import css from './InputBar.module.css'
1803
+ export type InputBarProps = ComposerBarProps
1804
+
1805
+ export function InputBar({
1806
+ - useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
1807
+ + useSession, useInput, inputActions, keyboard, removeImage, draftImages,
1808
+ + addFiles, resolveDocumentDropLabels, removeDocument, draftDocuments,
1809
+ resolveSubmitMode, toggleCommandMenu, stop, command, t,
1810
+ renderSlot, useNotices, useLexicon, useMenuLauncher,
1811
+ useProjection, sessionId, variant, disabled: inert = false, blocked,
1812
+ @@ -69,7 +70,11 @@ export function InputBar({
1813
+ () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
1814
+ [draftImages, input?.imageIds],
1815
+ )
1816
+ - const empty = draft.trim() === '' && attachments.length === 0
1817
+ + const documents = useMemo(
1818
+ + () => input === undefined || draftDocuments === undefined ? [] : draftDocuments(input.imageIds),
1819
+ + [draftDocuments, input?.imageIds],
1820
+ + )
1821
+ + const empty = draft.trim() === '' && attachments.length === 0 && documents.length === 0
1822
+ // Transient error banner (machine notices, image-intake rejections, and
1823
+ // prompt failures): the seq keys the Toast so an identical repeated message
1824
+ // restarts the hold-then-fade cycle instead of reusing the faded one.
1825
+ @@ -83,6 +88,7 @@ export function InputBar({
1826
+ // The deployment's image-intake limits (absent while no attachment service
1827
+ // is composed — the pre-check below then defers entirely to the host).
1828
+ const imageLimits = useProjection('imageLimits')
1829
+ + const documentLimits = useProjection('documentLimits')
1830
+ // Prompt failures are ordinary failures (no create/attach transaction exists
1831
+ // anymore): the toast announces promptError, the draft stays in the machine,
1832
+ // and the user resubmits. A remount over a session whose machine still holds
1833
+ @@ -134,10 +140,13 @@ export function InputBar({
1834
+
1835
+ useEffect(() => {
1836
+ if (input === undefined || inputActions === undefined) return
1837
+ - if (attachments.length !== input.imageIds.length) {
1838
+ - inputActions.pruneImages(attachments.map(attachment => attachment.id))
1839
+ + if (attachments.length + documents.length !== input.imageIds.length) {
1840
+ + inputActions.pruneImages([
1841
+ + ...attachments.map(attachment => attachment.id),
1842
+ + ...documents.map(document => document.id),
1843
+ + ])
1844
+ }
1845
+ - }, [attachments, input?.imageIds, inputActions])
1846
+ + }, [attachments, documents, input?.imageIds, inputActions])
1847
+
1848
+ // Scroll the draft scrollport the minimum that brings the selection focus
1849
+ // into view — the browser's own behavior for typing, performed for the
1850
+ @@ -210,46 +219,55 @@ export function InputBar({
1851
+ return () => { el.removeEventListener('wheel', onWheel) }
1852
+ }, [])
1853
+
1854
+ - // Intake pre-check: an addition that would break
1855
+ - // a projected limit is refused as a whole batch, announced immediately, and
1856
+ - // never enters the rail — no more submit-time failure rolling the rail
1857
+ - // back. The host enforces the same limits at submit for callers that bypass
1858
+ - // this composer.
1859
+ - const intakeImages = useCallback((files: readonly File[]): void => {
1860
+ - if (addImages === undefined || files.length === 0) return
1861
+ - const rejected = ((): string | null => {
1862
+ - if (imageLimits !== undefined) {
1863
+ - // Format precedes limits: a batch with
1864
+ - // a non-image must announce the format problem, not a count or size
1865
+ - // it could never pass anyway — addImages rejects it authoritatively.
1866
+ - if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
1867
+ - return addImages(files)
1868
+ - }
1869
+ - if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
1870
+ - return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
1871
+ - }
1872
+ - if (files.some(file => file.size > imageLimits.maxImageBytes)) {
1873
+ - return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
1874
+ - }
1875
+ - const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
1876
+ - + files.reduce((sum, file) => sum + file.size, 0)
1877
+ - if (total > imageLimits.maxMessageImageBytes) {
1878
+ - return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
1879
+ - }
1880
+ - }
1881
+ - return addImages(files)
1882
+ - })()
1883
+ + // Intake pre-check: an addition that would break a projected image limit is
1884
+ + // refused before the single mixed draft transaction starts. The Host repeats
1885
+ + // every admission check for callers that bypass this composer.
1886
+ + const imageIntakeError = useCallback((files: readonly File[]): string | null => {
1887
+ + if (files.length === 0 || imageLimits === undefined) return null
1888
+ + if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
1889
+ + return t('image.unsupportedType')
1890
+ + }
1891
+ + if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
1892
+ + return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
1893
+ + }
1894
+ + if (files.some(file => file.size > imageLimits.maxImageBytes)) {
1895
+ + return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
1896
+ + }
1897
+ + const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
1898
+ + + files.reduce((sum, file) => sum + file.size, 0)
1899
+ + return total > imageLimits.maxMessageImageBytes
1900
+ + ? t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
1901
+ + : null
1902
+ + }, [attachments, imageLimits, t])
1903
+ +
1904
+ + const intakeFiles = useCallback((files: readonly File[]): void => {
1905
+ + if (addFiles === undefined || files.length === 0) return
1906
+ + const documentTypes = draftDocuments === undefined || documentLimits === undefined
1907
+ + ? []
1908
+ + : documentLimits.mediaTypes as readonly string[]
1909
+ + const documentFiles = files.filter(file => documentTypes.includes(file.type))
1910
+ + const imageFiles = files.filter(file => !documentTypes.includes(file.type))
1911
+ + const imageRejected = imageIntakeError(imageFiles)
1912
+ + if (imageRejected !== null) {
1913
+ + showToast(imageRejected)
1914
+ + return
1915
+ + }
1916
+ + const rejected = addFiles(imageFiles, documentFiles, documentLimits)
1917
+ if (rejected !== null) showToast(rejected)
1918
+ - }, [addImages, attachments, imageLimits, showToast, t])
1919
+ + }, [addFiles, documentLimits, draftDocuments, imageIntakeError, showToast])
1920
+ +
1921
+ + const intakeImages = useCallback((files: readonly File[]): void => {
1922
+ + intakeFiles(files)
1923
+ + }, [intakeFiles])
1924
+
1925
+ - const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
1926
+ + const canAcceptDrop = !locked && !machineBusy && addFiles !== undefined
1927
+
1928
+ // The keymap handlers read live bar state through this ref so the editor
1929
+ // registration survives re-renders without re-arming per keystroke.
1930
+ const gate = useRef({
1931
+ - locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages,
1932
+ + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeFiles,
1933
+ })
1934
+ - gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages }
1935
+ + gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeFiles }
1936
+
1937
+ useEffect(() => {
1938
+ if (editor === null || keyboard === undefined) return
1939
+ @@ -276,7 +294,7 @@ export function InputBar({
1940
+ g.subagent === null,
1941
+ ))
1942
+ },
1943
+ - intakeFiles: (files) => { gate.current.intakeImages(files) },
1944
+ + intakeFiles: (files) => { gate.current.intakeFiles(files) },
1945
+ pasteText: (text) => {
1946
+ if (gate.current.machineBusy || gate.current.locked) return
1947
+ keyboard.paste(text)
1948
+ @@ -393,9 +411,13 @@ export function InputBar({
1949
+ {accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
1950
+ {renderSlot('conversation.input.attachments', {
1951
+ attachments,
1952
+ + documents,
1953
+ canAcceptDrop,
1954
+ onAddImages: intakeImages,
1955
+ + onAddFiles: intakeFiles,
1956
+ + fileDropLabels: documentLimits === undefined ? undefined : resolveDocumentDropLabels?.(documentLimits),
1957
+ onRemoveImage: (id) => { removeImage?.(id) },
1958
+ + onRemoveDocument: (id) => { removeDocument?.(id) },
1959
+ dropLimits: imageLimits === undefined ? undefined : {
1960
+ count: imageLimits.maxImagesPerMessage,
1961
+ size: imageSizeText(imageLimits.maxImageBytes),
1962
+ diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
1963
+ index 9becd7f1095f135ecd3d24b6a06f52b956fe4834..b00b618d0bd1288a552cc4dcb46760c9e2198129 100644
1964
+ --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
1965
+ +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
1966
+ @@ -173,6 +173,10 @@ function bench(over?: BenchOptions) {
1967
+ const attachment = over?.attachments?.find(candidate => candidate.id === id)
1968
+ return attachment === undefined ? [] : [attachment]
1969
+ }),
1970
+ + addFiles: undefined,
1971
+ + resolveDocumentDropLabels: undefined,
1972
+ + removeDocument: undefined,
1973
+ + draftDocuments: undefined,
1974
+ resolveSubmitMode: (running, gesture, steeringAvailable) => {
1975
+ if (!running || !steeringAvailable) return 'queue'
1976
+ const preferred = over?.busyEnter ?? 'queue'
1977
+ diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
1978
+ index bdb878672b080e06ec84b065b5bfbfea04a951ee..dc155229a154a0e23384ad821135146b8bf74866 100644
1979
+ --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
1980
+ +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
1981
+ @@ -71,6 +71,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
1982
+ file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }),
1983
+ previewUrl: `blob:${id}`,
1984
+ })),
1985
+ + addFiles: undefined,
1986
+ + resolveDocumentDropLabels: undefined,
1987
+ + removeDocument: undefined,
1988
+ + draftDocuments: undefined,
1989
+ resolveSubmitMode: () => 'queue',
1990
+ toggleCommandMenu: vi.fn(),
1991
+ useNotices: bindSnapshotSelector(shell.notices),
1992
+ diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
1993
+ index ec7f586683f96d9374bc31cc0e6d8b39e4a4df43..266d2e15c742a9eb9dbbea670f98943d83b64198 100644
1994
+ --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
1995
+ +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
1996
+ @@ -160,6 +160,10 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
1997
+ file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }),
1998
+ previewUrl: `blob:${id}`,
1999
+ })),
2000
+ + addFiles: undefined,
2001
+ + resolveDocumentDropLabels: undefined,
2002
+ + removeDocument: undefined,
2003
+ + draftDocuments: undefined,
2004
+ resolveSubmitMode: () => 'queue',
2005
+ toggleCommandMenu: (selection) => {
2006
+ const snapshot = shell.snapshot
2007
+ diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
2008
+ index 40f6dfda6d1b6d04003b7c210cd600f6cbaa1d83..24632b55e94b1053793b761e047e03db40ddbe14 100644
2009
+ --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
2010
+ +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx
2011
+ @@ -250,6 +250,10 @@ function mount(
2012
+ addImages={() => null}
2013
+ removeImage={() => {}}
2014
+ draftImages={() => []}
2015
+ + addFiles={undefined}
2016
+ + resolveDocumentDropLabels={undefined}
2017
+ + removeDocument={undefined}
2018
+ + draftDocuments={undefined}
2019
+ resolveSubmitMode={() => 'queue'}
2020
+ toggleCommandMenu={vi.fn()}
2021
+ useNotices={bindSnapshotSelector(wiring.notices)}
2022
+ diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
2023
+ index 81a428aa35fada3286b0f7b025347dc6a51a3766..8f40cb75c119394dc32963ce6733775ad7996515 100644
2024
+ --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
2025
+ +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
2026
+ @@ -1164,18 +1164,26 @@ function SourceBlocks({
2027
+ gallery per image block, unlike the aggregated record gallery. */}
2028
+ {block.attachment !== undefined
2029
+ ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' })
2030
+ - : <pre className={css.sourceBlockContent}>{block.content}</pre>}
2031
+ + : block.document !== undefined
2032
+ + ? renderImages({ images: [], documents: [block.document], align: 'start' })
2033
+ + : <pre className={css.sourceBlockContent}>{block.content}</pre>}
2034
+ </section>
2035
+ ))}
2036
+ </div>
2037
+ )
2038
+ }
2039
+
2040
+ -function recordImages(
2041
+ - blocks: readonly TrajectorySourceBlock[] | undefined,
2042
+ -): { readonly attachment: ImageAttachmentRef }[] {
2043
+ - return (blocks ?? []).flatMap(block =>
2044
+ - block.attachment !== undefined ? [{ attachment: block.attachment }] : [])
2045
+ +function recordAttachments(blocks: readonly TrajectorySourceBlock[] | undefined): {
2046
+ + images: { readonly attachment: ImageAttachmentRef }[]
2047
+ + documents: NonNullable<TrajectorySourceBlock['document']>[]
2048
+ +} {
2049
+ + const images: { readonly attachment: ImageAttachmentRef }[] = []
2050
+ + const documents: NonNullable<TrajectorySourceBlock['document']>[] = []
2051
+ + for (const block of blocks ?? []) {
2052
+ + if (block.attachment !== undefined) images.push({ attachment: block.attachment })
2053
+ + if (block.document !== undefined) documents.push(block.document)
2054
+ + }
2055
+ + return { images, documents }
2056
+ }
2057
+
2058
+ function MessageImages({
2059
+ @@ -1187,11 +1195,11 @@ function MessageImages({
2060
+ preview: boolean
2061
+ renderImages: RenderMessageImages
2062
+ }) {
2063
+ - const images = recordImages(blocks)
2064
+ - if (images.length === 0) return null
2065
+ + const { images, documents } = recordAttachments(blocks)
2066
+ + if (images.length === 0 && documents.length === 0) return null
2067
+ return (
2068
+ <div className={preview ? `${css.messageImages} ${css.messageImagesPreview}` : css.messageImages}>
2069
+ - {renderImages({ images, align: 'start' })}
2070
+ + {renderImages({ images, documents, align: 'start' })}
2071
+ </div>
2072
+ )
2073
+ }
2074
+ @@ -1411,10 +1419,14 @@ function ToolOutputBlocks({
2075
+ {error && errorDetail !== undefined && errorDetail !== ''
2076
+ && <pre className={css.resultBlockText}>{errorDetail}</pre>}
2077
+ {blocks.map((block, index) => (
2078
+ - block.attachment !== undefined
2079
+ + block.attachment !== undefined || block.document !== undefined
2080
+ ? (
2081
+ <div className={css.messageImages} key={index}>
2082
+ - {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })}
2083
+ + {renderImages({
2084
+ + images: block.attachment === undefined ? [] : [{ attachment: block.attachment }],
2085
+ + ...(block.document === undefined ? {} : { documents: [block.document] }),
2086
+ + align: 'start',
2087
+ + })}
2088
+ </div>
2089
+ )
2090
+ : block.content !== ''
2091
+ @@ -1513,16 +1525,18 @@ function MarkdownRecordContent({
2092
+ )
2093
+ }
2094
+ const source = markdownSource(record)
2095
+ - const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true
2096
+ + const hasAttachments = record.cell.sourceBlocks?.some(
2097
+ + block => block.attachment !== undefined || block.document !== undefined,
2098
+ + ) === true
2099
+ const hasToolCalls = record.cell.kind === 'message'
2100
+ && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true
2101
+ - if (!source && !hasImages && !hasToolCalls) {
2102
+ + if (!source && !hasAttachments && !hasToolCalls) {
2103
+ const emptyLabel = isToolCallOnly(record.cell, t)
2104
+ ? t('record.toolCallOnly')
2105
+ : record.cell.text || t('record.noContent')
2106
+ return <p className={css.noPayload}>{emptyLabel}</p>
2107
+ }
2108
+ - if (!rendered || (!hasImages && !hasToolCalls)) {
2109
+ + if (!rendered || (!hasAttachments && !hasToolCalls)) {
2110
+ return <MarkdownFragment text={source ?? ''} rendered={rendered} preview={preview} t={t} />
2111
+ }
2112
+ return (
2113
+ diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts
2114
+ index a811e915f20d7a2247726dc6a750bb67e0697304..8676ea46dcf367243f96cf53ffa29b135e8ceffd 100644
2115
+ --- a/packages/client/ui-trajectory/src/client/layout.ts
2116
+ +++ b/packages/client/ui-trajectory/src/client/layout.ts
2117
+ @@ -841,6 +841,16 @@ function sourceBlock(value: unknown): TrajectorySourceBlock {
2118
+ // wire-shaped 'other' blocks with an unrelated `attachment` member out.
2119
+ return { type, content: '', attachment: block.attachment as ImageAttachmentRef }
2120
+ }
2121
+ + if (type === 'document' && typeof block.attachment === 'object' && block.attachment !== null) {
2122
+ + const attachment = block.attachment as Record<string, unknown>
2123
+ + if (typeof attachment.name === 'string' && typeof attachment.mediaType === 'string' && typeof attachment.bytes === 'number') {
2124
+ + return {
2125
+ + type,
2126
+ + content: '',
2127
+ + document: { name: attachment.name, mediaType: attachment.mediaType, bytes: attachment.bytes },
2128
+ + }
2129
+ + }
2130
+ + }
2131
+ return { type, content: stringifySourceValue(value) }
2132
+ }
2133
+
2134
+ diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts
2135
+ index 37571f2b83403944c2cb0e1e8a71b17972461994..1c326ebfca6e1b8636457d04c4517f39630e4d8b 100644
2136
+ --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts
2137
+ +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts
2138
+ @@ -1,6 +1,6 @@
2139
+ import type {
2140
+ AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot,
2141
+ - ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange,
2142
+ + ConversationViewNode, MessageDocumentSource, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange,
2143
+ RequestView, RunningToolCall, ToolCallBlock,
2144
+ } from '@deepseek-ai/dsh-client-ui-conversation/client'
2145
+ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
2146
+ @@ -93,5 +93,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
2147
+ * images are omitted.
2148
+ */
2149
+ 'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps }
2150
+ + 'conversation.trajectory.images.documents': { kind: 'single'; scope: 'session'; owner: { documents: readonly MessageDocumentSource[] } }
2151
+ }
2152
+ }
2153
+ diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts
2154
+ index da330f2ae43a55ca813bfffe867dbfc33a2c15a9..0bb73888c955883403d785e7bcc737c8cb0f7054 100644
2155
+ --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts
2156
+ +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts
2157
+ @@ -2,7 +2,7 @@
2158
+
2159
+ import type { HTMLAttributes } from 'react'
2160
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
2161
+ -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
2162
+ +import type { ConversationPromptSnapshot, MessageDocumentSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
2163
+ import type { TrajectoryTranslate } from './locales.ts'
2164
+
2165
+ /** Closed set of trajectory record kinds. */
2166
+ @@ -30,6 +30,7 @@ export interface TrajectorySourceBlock {
2167
+ type: string
2168
+ content: string
2169
+ attachment?: ImageAttachmentRef
2170
+ + document?: MessageDocumentSource
2171
+ callId?: string
2172
+ toolName?: string
2173
+ }
2174
+ diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts
2175
+ index e2116556331159ee348aacfb194d46e2ae7b5920..4e468b655b20fd8a7664334be3365be1d1438237 100644
2176
+ --- a/packages/llm/llm-deepseek/src/adapter.ts
2177
+ +++ b/packages/llm/llm-deepseek/src/adapter.ts
2178
+ @@ -8,7 +8,7 @@
2179
+ * @module dsh-llm-deepseek/adapter
2180
+ */
2181
+
2182
+ -import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
2183
+ +import { attributionHeaders, contentHasDocument, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, offloadedImageText, offloadRequestImagesWithPolicy, projectRequestDocumentsWithAttachments, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
2184
+ import type {
2185
+ ContentBlock,
2186
+ GenerateOptions,
2187
+ @@ -450,20 +450,23 @@ export class DeepSeekAdapter extends LlmAdapter {
2188
+ // never observes a configuration change and the next call re-resolves.
2189
+ // The key resolves *from this snapshot*, so an endpoint and the secret
2190
+ // sent to it can never come from different configuration generations.
2191
+ + const hasDocuments = options.messages.some(message => contentHasDocument(message.content))
2192
+ const hasImages = options.messages.some(message => contentHasImage(message.content))
2193
+ let attachments: AttachmentStore | undefined
2194
+ - if (hasImages) {
2195
+ - const model = connection.models.find(entry => entry.id === options.model)
2196
+ - if (model?.inputModalities?.includes('image') !== true) {
2197
+ + if (hasDocuments || hasImages) {
2198
+ + attachments = this.config.resolveAttachments?.()
2199
+ + if (attachments === undefined) {
2200
+ throw new LlmError(
2201
+ - `DeepSeek model "${options.model}" does not accept image input.`,
2202
+ + 'DeepSeek document and image conversion requires the durable attachment service.',
2203
+ 'UNSUPPORTED_CONTENT',
2204
+ )
2205
+ }
2206
+ - attachments = this.config.resolveAttachments?.()
2207
+ - if (attachments === undefined) {
2208
+ + }
2209
+ + if (hasImages) {
2210
+ + const model = connection.models.find(entry => entry.id === options.model)
2211
+ + if (model?.inputModalities?.includes('image') !== true) {
2212
+ throw new LlmError(
2213
+ - 'DeepSeek image conversion requires the durable attachment service.',
2214
+ + `DeepSeek model "${options.model}" does not accept image input.`,
2215
+ 'UNSUPPORTED_CONTENT',
2216
+ )
2217
+ }
2218
+ @@ -475,13 +478,16 @@ export class DeepSeekAdapter extends LlmAdapter {
2219
+ ? consumer.signal
2220
+ : AbortSignal.any([options.signal, consumer.signal])
2221
+ using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
2222
+ + const requestOptions = hasDocuments
2223
+ + ? { ...options, messages: await projectRequestDocumentsWithAttachments(options.messages, attachments as AttachmentStore, watchdog.signal) }
2224
+ + : options
2225
+ const iterator = this.request(
2226
+ - options,
2227
+ + requestOptions,
2228
+ watchdog.signal,
2229
+ connection,
2230
+ apiKey,
2231
+ userId,
2232
+ - attachments,
2233
+ + hasImages ? attachments : undefined,
2234
+ () => { watchdog.pulse() },
2235
+ )[Symbol.asyncIterator]()
2236
+ let exhausted = false
2237
+ diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts
2238
+ index e20b8e00720d52ef9cbb3712884661e8025f3dde..acd9dcda4fd57e0effe1f2bb6c3f406340219b93 100644
2239
+ --- a/packages/llm/llm-pi-ai/src/adapter.ts
2240
+ +++ b/packages/llm/llm-pi-ai/src/adapter.ts
2241
+ @@ -40,9 +40,11 @@ import type {
2242
+ } from '@earendil-works/pi-ai'
2243
+ import {
2244
+ attributionHeaders,
2245
+ + contentHasDocument,
2246
+ contentHasImage,
2247
+ LlmAdapter,
2248
+ LlmError,
2249
+ + projectRequestDocumentsWithAttachments,
2250
+ ReasoningEffortId,
2251
+ } from '@deepseek-ai/dsh-llm'
2252
+ import type {
2253
+ @@ -350,20 +352,24 @@ export class PiAiAdapter extends LlmAdapter {
2254
+ using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
2255
+
2256
+ try {
2257
+ + const containsDocument = options.messages.some(message => contentHasDocument(message.content))
2258
+ const containsImage = options.messages.some(message => contentHasImage(message.content))
2259
+ if (containsImage && !model.input.includes('image')) {
2260
+ throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
2261
+ }
2262
+ - const attachments = containsImage ? this.config.resolveAttachments?.() : undefined
2263
+ - if (containsImage && attachments === undefined) {
2264
+ - throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
2265
+ + const attachments = containsDocument || containsImage ? this.config.resolveAttachments?.() : undefined
2266
+ + if ((containsDocument || containsImage) && attachments === undefined) {
2267
+ + throw new LlmError('pi-ai document and image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
2268
+ }
2269
+ + const requestOptions = containsDocument
2270
+ + ? { ...options, messages: await projectRequestDocumentsWithAttachments(options.messages, attachments as AttachmentStore, watchdog.signal) }
2271
+ + : options
2272
+ const onReplayDegrade = (reason: string): void => {
2273
+ this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
2274
+ }
2275
+ const context = attachments === undefined
2276
+ - ? toPiContext(options, undefined, onReplayDegrade)
2277
+ - : await toPiContext({ ...options, signal: watchdog.signal }, {
2278
+ + ? toPiContext(requestOptions, undefined, onReplayDegrade)
2279
+ + : await toPiContext({ ...requestOptions, signal: watchdog.signal }, {
2280
+ attachments,
2281
+ resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref),
2282
+ maxRequestImageBytes: profile.maxRequestImageBytes,
2283
+ diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts
2284
+ index 43c392a64129981a1dd99f6f38fcae39df5e4228..96e9fece476649b8e8dfeb13979e40d456f0f903 100644
2285
+ --- a/packages/llm/llm/src/content.ts
2286
+ +++ b/packages/llm/llm/src/content.ts
2287
+ @@ -1,10 +1,71 @@
2288
+ /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
2289
+
2290
+ -import type { ContentBlock } from './types.ts'
2291
+ +import type { ContentBlock, DocumentBlock } from './types.ts'
2292
+ import type { Message } from './message.ts'
2293
+ import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
2294
+ import { assertNever } from './never.ts'
2295
+
2296
+ +/** Return whether typed model content contains a durable parsed document. */
2297
+ +export function contentHasDocument(content: readonly ContentBlock[]): boolean {
2298
+ + return content.some(block => block.type === 'document'
2299
+ + || (block.type === 'tool-result' && contentHasDocument(block.content)))
2300
+ +}
2301
+ +
2302
+ +async function parsedDocumentText(
2303
+ + block: DocumentBlock,
2304
+ + attachments: AttachmentStore,
2305
+ + signal?: AbortSignal,
2306
+ +): Promise<string> {
2307
+ + const stored = await attachments.readFile(block.parsed.modelText, signal)
2308
+ + return new TextDecoder('utf-8', { fatal: true }).decode(stored.data)
2309
+ +}
2310
+ +
2311
+ +/** 中文约束:只生成provider request的transient text,不修改Session中的durable reference blocks。 */
2312
+ +async function projectDocumentsWithAttachments(
2313
+ + blocks: readonly ContentBlock[],
2314
+ + attachments: AttachmentStore,
2315
+ + signal?: AbortSignal,
2316
+ +): Promise<ContentBlock[]> {
2317
+ + let next: ContentBlock[] | undefined
2318
+ + for (const [index, block] of blocks.entries()) {
2319
+ + if (block.type === 'document') {
2320
+ + next ??= blocks.slice(0, index)
2321
+ + next.push({ type: 'text', text: await parsedDocumentText(block, attachments, signal) })
2322
+ + continue
2323
+ + }
2324
+ + if (block.type === 'tool-result') {
2325
+ + const content = await projectDocumentsWithAttachments(block.content, attachments, signal)
2326
+ + if (content !== block.content) {
2327
+ + next ??= blocks.slice(0, index)
2328
+ + next.push({ ...block, content })
2329
+ + continue
2330
+ + }
2331
+ + }
2332
+ + next?.push(block)
2333
+ + }
2334
+ + return next ?? blocks as ContentBlock[]
2335
+ +}
2336
+ +
2337
+ +/**
2338
+ + * Resolve every durable parsed-document Markdown reference for one provider request.
2339
+ + * @param messages - durable ref-only request history.
2340
+ + * @param attachments - store resolving each complete Markdown object.
2341
+ + * @param signal - optional cancellation for durable object reads.
2342
+ + * @returns request-only messages with documents replaced by delimited Markdown.
2343
+ + */
2344
+ +export async function projectRequestDocumentsWithAttachments(
2345
+ + messages: readonly Message[],
2346
+ + attachments: AttachmentStore,
2347
+ + signal?: AbortSignal,
2348
+ +): Promise<Message[]> {
2349
+ + const projected: Message[] = []
2350
+ + for (const message of messages) {
2351
+ + const content = await projectDocumentsWithAttachments(message.content, attachments, signal)
2352
+ + projected.push(content === message.content ? message : { ...message, content })
2353
+ + }
2354
+ + return projected
2355
+ +}
2356
+ +
2357
+ /** Execution-world path that model tools can use to read one normalized attachment. */
2358
+ export interface ImageAttachmentAccess {
2359
+ /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
2360
+ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts
2361
+ index 438fcaa67baed51a6b1c8f867e802d2a6aa46023..f82ec614c131c23b8810422017ab1e4fd6415b09 100644
2362
+ --- a/packages/llm/llm/src/types.ts
2363
+ +++ b/packages/llm/llm/src/types.ts
2364
+ @@ -5,7 +5,7 @@
2365
+ */
2366
+
2367
+ import type { Branded } from '@deepseek-ai/dsh-brand'
2368
+ -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
2369
+ +import type { DocumentAttachmentRef, ImageAttachmentRef, ParsedDocumentRef } from '@deepseek-ai/dsh-attachment'
2370
+ import type { ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
2371
+ import type { Message } from './message.ts'
2372
+
2373
+ @@ -74,6 +74,15 @@ export interface ImageBlock {
2374
+ attachment: ImageAttachmentRef
2375
+ }
2376
+
2377
+ +/** A durable document with required original and parser-output references. */
2378
+ +export interface DocumentBlock {
2379
+ + type: 'document'
2380
+ + /** Immutable original document bytes and display metadata. */
2381
+ + attachment: DocumentAttachmentRef
2382
+ + /** Immutable provider-neutral parser outputs used by request projection. */
2383
+ + parsed: ParsedDocumentRef
2384
+ +}
2385
+ +
2386
+ /** A tool invocation requested by the model. */
2387
+ export interface ToolCallBlock {
2388
+ type: 'tool-call'
2389
+ @@ -100,6 +109,7 @@ export interface ContentBlockMap {
2390
+ 'text': TextBlock
2391
+ 'reasoning': ReasoningBlock
2392
+ 'image': ImageBlock
2393
+ + 'document': DocumentBlock
2394
+ 'tool-call': ToolCallBlock
2395
+ 'tool-result': ToolResultBlock
2396
+ }