@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.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +9 -0
- package/README.zh.md +9 -0
- package/package.json +46 -0
- package/patches/document-attachments.patch +2396 -0
|
@@ -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
|
+
}
|