@modesto_ma/dsh-channels 0.2.0 → 0.4.0
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/README.md +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH native attachment storage layer for inbound Feishu resources.
|
|
3
|
+
*
|
|
4
|
+
* Two backends exist and one switch selects them (`attachmentStore`):
|
|
5
|
+
*
|
|
6
|
+
* - `native` (default): images are validated and durably committed through
|
|
7
|
+
* `ctx.attachments.saveImage`, files are streamed into
|
|
8
|
+
* `ctx.attachments.saveFileStream` without ever being collected in memory.
|
|
9
|
+
* Images come back as an {@link ImageBlock}, files as a {@link FileBlock}
|
|
10
|
+
* plus the deterministic handle text and read-only host path that keeps the
|
|
11
|
+
* resource readable when a tool result cannot carry a `FileBlock`.
|
|
12
|
+
* - `archive`: the legacy behavior — resources are written into the topic
|
|
13
|
+
* workspace `attachments/` directory by the existing `AttachmentIntake`.
|
|
14
|
+
*
|
|
15
|
+
* Native is downgraded to `archive` when the host does not mount
|
|
16
|
+
* `ctx.attachments`; the downgrade is reported (plan + warning) instead of
|
|
17
|
+
* failing the turn, so headless and older deployments keep working.
|
|
18
|
+
*
|
|
19
|
+
* Every operation returns a structured result instead of throwing: P0 showed
|
|
20
|
+
* that an exception reaching a tool result becomes an unreadable
|
|
21
|
+
* `[object Object]` in session history, and inbound attachments must degrade
|
|
22
|
+
* per resource without aborting the rest of the message.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-channels/feishu/native-attachments
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { FeishuChannel, NormalizedMessage, ResourceChunkStream } from '../feishu-channel.ts'
|
|
28
|
+
import { sniffImageMediaType } from '../feishu-channel.ts'
|
|
29
|
+
import type { AttachmentStoreMode } from '../config.ts'
|
|
30
|
+
import type { AgentContext } from '../host/agent.ts'
|
|
31
|
+
import type {
|
|
32
|
+
AdmittedPromptContentPart,
|
|
33
|
+
AttachmentAdmissionPart,
|
|
34
|
+
AttachmentErrorCode,
|
|
35
|
+
AttachmentStore,
|
|
36
|
+
FileAttachmentRef,
|
|
37
|
+
FileBlock,
|
|
38
|
+
ImageAttachmentLimits,
|
|
39
|
+
ImageAttachmentRef,
|
|
40
|
+
ImageBlock,
|
|
41
|
+
ImageMediaType,
|
|
42
|
+
} from '../host/attachments.ts'
|
|
43
|
+
import type { AttachmentFailure, AttachmentIntake, AttachmentResource } from './inbound-attachments.ts'
|
|
44
|
+
import {
|
|
45
|
+
attachmentResources,
|
|
46
|
+
describeFailure,
|
|
47
|
+
EMPTY_POST_PLACEHOLDER,
|
|
48
|
+
hasAttachmentPayload,
|
|
49
|
+
sanitizeFileName,
|
|
50
|
+
stripAttachmentPlaceholders,
|
|
51
|
+
} from './inbound-attachments.ts'
|
|
52
|
+
|
|
53
|
+
/** Cordis service name of the DSH native attachment store. */
|
|
54
|
+
export const ATTACHMENT_SERVICE_NAME = 'attachments'
|
|
55
|
+
|
|
56
|
+
/** Fixed reason reported whenever the host attachment service is absent. */
|
|
57
|
+
export const ATTACHMENT_SERVICE_UNMOUNTED_REASON = '宿主未挂载原生附件库(ctx.attachments 不可用)'
|
|
58
|
+
|
|
59
|
+
/** File name used when a source resource carries no usable display name. */
|
|
60
|
+
export const DEFAULT_FILE_NAME = 'file.bin'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* In-memory image cap used when the host publishes no image limits. Feishu
|
|
64
|
+
* caps inbound images well below this, so the value only guards against a
|
|
65
|
+
* misbehaving host/stream, never against a legitimate upload.
|
|
66
|
+
*/
|
|
67
|
+
export const FALLBACK_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
68
|
+
|
|
69
|
+
// ---- Service discovery --------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal context surface that can resolve the host attachment service.
|
|
73
|
+
*
|
|
74
|
+
* Both the plugin's host context and an Agent-scoped context satisfy it: the
|
|
75
|
+
* direct property is attempted first (it throws in cordis scopes that did not
|
|
76
|
+
* inject the service) and the safe `get()` lookup resolves host-wide services
|
|
77
|
+
* (including from the plugin's own context, which is a sibling of the
|
|
78
|
+
* attachment plugin in the loader composition).
|
|
79
|
+
*/
|
|
80
|
+
export interface AttachmentServiceContext {
|
|
81
|
+
attachments?: unknown
|
|
82
|
+
get?(name: string): unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The host attachment store is mounted and usable. */
|
|
86
|
+
export interface AttachmentServiceMounted {
|
|
87
|
+
readonly mounted: true
|
|
88
|
+
readonly service: AttachmentStore
|
|
89
|
+
/** Deployment-resolved image limits, when the host publishes them. */
|
|
90
|
+
readonly limits?: ImageAttachmentLimits
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The host has no attachment store in this Agent scope (degrade to archive). */
|
|
94
|
+
export interface AttachmentServiceUnmounted {
|
|
95
|
+
readonly mounted: false
|
|
96
|
+
readonly code: 'ATTACHMENT_SERVICE_UNMOUNTED'
|
|
97
|
+
readonly reason: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether the native attachment service can be used for this Agent scope. */
|
|
101
|
+
export type AttachmentServiceState = AttachmentServiceMounted | AttachmentServiceUnmounted
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Structural guard for the narrow service surface. All operations listed in
|
|
105
|
+
* {@link AttachmentStore} are required: a partially matching object is not a
|
|
106
|
+
* usable attachment store and must degrade to `archive` instead of failing
|
|
107
|
+
* later inside a turn.
|
|
108
|
+
*/
|
|
109
|
+
export function isAttachmentStore(value: unknown): value is AttachmentStore {
|
|
110
|
+
if (value === null || typeof value !== 'object') return false
|
|
111
|
+
const candidate = value as Partial<AttachmentStore>
|
|
112
|
+
return typeof candidate.saveImage === 'function'
|
|
113
|
+
&& typeof candidate.saveFileStream === 'function'
|
|
114
|
+
&& typeof candidate.fileHostPath === 'function'
|
|
115
|
+
&& typeof candidate.imageHostPath === 'function'
|
|
116
|
+
&& typeof candidate.admitPromptContent === 'function'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Mount state for one already-resolved service value. */
|
|
120
|
+
export function attachmentServiceStateOf(service: AttachmentStore | undefined): AttachmentServiceState {
|
|
121
|
+
if (service === undefined || !isAttachmentStore(service)) {
|
|
122
|
+
return { mounted: false, code: 'ATTACHMENT_SERVICE_UNMOUNTED', reason: ATTACHMENT_SERVICE_UNMOUNTED_REASON }
|
|
123
|
+
}
|
|
124
|
+
const limits = service.imageLimits
|
|
125
|
+
return { mounted: true, service, ...(limits === undefined ? {} : { limits }) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the native attachment service from one context.
|
|
130
|
+
*
|
|
131
|
+
* Cordis throws when an un-injected service property is read, so both the
|
|
132
|
+
* direct property and the safe `ctx.get` lookup are attempted inside guards:
|
|
133
|
+
* a missing service must read as "not mounted", never as a thrown error.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveAttachmentService(
|
|
136
|
+
ctx: AttachmentServiceContext | undefined,
|
|
137
|
+
): AttachmentStore | undefined {
|
|
138
|
+
if (ctx === undefined || ctx === null) return undefined
|
|
139
|
+
try {
|
|
140
|
+
const direct = ctx.attachments
|
|
141
|
+
if (isAttachmentStore(direct)) return direct
|
|
142
|
+
} catch {
|
|
143
|
+
// Not injected into this scope: fall through to the safe lookup.
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const viaGet = ctx.get?.(ATTACHMENT_SERVICE_NAME)
|
|
147
|
+
if (isAttachmentStore(viaGet)) return viaGet
|
|
148
|
+
} catch {
|
|
149
|
+
// Exotic host: treat the lookup itself as "not mounted".
|
|
150
|
+
}
|
|
151
|
+
return undefined
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Mount state for one Agent scope (never throws). */
|
|
155
|
+
export function attachmentServiceState(ctx: AgentContext | undefined): AttachmentServiceState {
|
|
156
|
+
return attachmentServiceStateOf(resolveAttachmentService(ctx))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Structured failures ------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** Stable failure codes surfaced by this storage layer. */
|
|
162
|
+
export type AttachmentFailureCode =
|
|
163
|
+
| AttachmentErrorCode
|
|
164
|
+
| 'ATTACHMENT_SERVICE_UNMOUNTED'
|
|
165
|
+
| 'ABORTED'
|
|
166
|
+
| 'DOWNLOAD_FAILED'
|
|
167
|
+
| 'FOLDER_UNSUPPORTED'
|
|
168
|
+
| 'ATTACHMENT_STORE_ERROR'
|
|
169
|
+
|
|
170
|
+
/** One structured, model-safe failure (no raw bytes, no internal stack). */
|
|
171
|
+
export interface AttachmentFailureResult {
|
|
172
|
+
readonly ok: false
|
|
173
|
+
readonly code: AttachmentFailureCode
|
|
174
|
+
/** User/model-facing reason; stable wording, safe to place in a prompt. */
|
|
175
|
+
readonly reason: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Result of one attachment operation; failures are values, never throws. */
|
|
179
|
+
export type AttachmentResult<T> = { readonly ok: true; readonly value: T } | AttachmentFailureResult
|
|
180
|
+
|
|
181
|
+
const FAILURE_REASONS: Record<AttachmentFailureCode, string> = {
|
|
182
|
+
// Host attachment codes, mapped to user-facing Chinese wording.
|
|
183
|
+
TOO_MANY_IMAGES: '图片数量超过单条消息上限',
|
|
184
|
+
IMAGES_TOO_LARGE: '图片总字节超过单条消息上限',
|
|
185
|
+
UNSUPPORTED_IMAGE_TYPE: '不支持的图片格式(仅支持 PNG/JPEG/WebP/GIF)',
|
|
186
|
+
INVALID_IMAGE_BASE64: '图片数据编码无效',
|
|
187
|
+
INVALID_IMAGE: '图片内容损坏或无法解码',
|
|
188
|
+
IMAGE_TYPE_MISMATCH: '图片内容与声明的类型不一致',
|
|
189
|
+
IMAGE_TOO_LARGE: '图片超过大小上限',
|
|
190
|
+
IMAGE_TOO_MANY_PIXELS: '图片像素总数超过上限',
|
|
191
|
+
IMAGE_DIMENSION_TOO_LARGE: '图片宽高超过上限',
|
|
192
|
+
INVALID_FILE_BASE64: '文件数据编码无效',
|
|
193
|
+
INVALID_ATTACHMENT_REF: '附件引用无效',
|
|
194
|
+
ATTACHMENT_CORRUPT: '附件内容校验失败(文件可能损坏或被替换)',
|
|
195
|
+
ATTACHMENT_WRITE_FAILED: '附件写入失败',
|
|
196
|
+
ATTACHMENT_NOT_FOUND: '附件不存在(可能已被删除)',
|
|
197
|
+
ATTACHMENT_READ_FAILED: '附件读取失败',
|
|
198
|
+
ATTACHMENT_PROJECTION_UNSUPPORTED: '当前部署不支持该附件投影',
|
|
199
|
+
ATTACHMENT_FILES_UNSUPPORTED: '当前部署不支持文件附件',
|
|
200
|
+
// Storage-layer codes.
|
|
201
|
+
ATTACHMENT_SERVICE_UNMOUNTED: ATTACHMENT_SERVICE_UNMOUNTED_REASON,
|
|
202
|
+
ABORTED: '操作已取消',
|
|
203
|
+
DOWNLOAD_FAILED: '飞书附件下载失败',
|
|
204
|
+
FOLDER_UNSUPPORTED: '飞书文件夹消息暂不支持下载',
|
|
205
|
+
ATTACHMENT_STORE_ERROR: '原生附件库操作失败',
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ATTACHMENT_ERROR_CODES: ReadonlySet<string> = new Set<AttachmentErrorCode>([
|
|
209
|
+
'TOO_MANY_IMAGES', 'IMAGES_TOO_LARGE', 'UNSUPPORTED_IMAGE_TYPE', 'INVALID_IMAGE_BASE64',
|
|
210
|
+
'INVALID_IMAGE', 'IMAGE_TYPE_MISMATCH', 'IMAGE_TOO_LARGE', 'IMAGE_TOO_MANY_PIXELS',
|
|
211
|
+
'IMAGE_DIMENSION_TOO_LARGE', 'INVALID_FILE_BASE64', 'INVALID_ATTACHMENT_REF',
|
|
212
|
+
'ATTACHMENT_CORRUPT', 'ATTACHMENT_WRITE_FAILED', 'ATTACHMENT_NOT_FOUND',
|
|
213
|
+
'ATTACHMENT_READ_FAILED', 'ATTACHMENT_PROJECTION_UNSUPPORTED', 'ATTACHMENT_FILES_UNSUPPORTED',
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
function isAttachmentErrorCode(code: string): code is AttachmentErrorCode {
|
|
217
|
+
return ATTACHMENT_ERROR_CODES.has(code)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** User/model-facing wording for one stable failure code. */
|
|
221
|
+
export function attachmentFailureReason(code: AttachmentFailureCode): string {
|
|
222
|
+
return FAILURE_REASONS[code]
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extract a host attachment failure code from an arbitrary thrown value.
|
|
227
|
+
* Prefers the service classifier when the host exposes one (it survives
|
|
228
|
+
* duplicate package installations), then falls back to the structural
|
|
229
|
+
* `error.code` contract.
|
|
230
|
+
*/
|
|
231
|
+
export function attachmentErrorCodeOf(error: unknown, service?: AttachmentStore): AttachmentErrorCode | undefined {
|
|
232
|
+
if (error === null || typeof error !== 'object') return undefined
|
|
233
|
+
const code = (error as { code?: unknown }).code
|
|
234
|
+
if (typeof code !== 'string' || !isAttachmentErrorCode(code)) return undefined
|
|
235
|
+
if (service?.isAttachmentError !== undefined) {
|
|
236
|
+
// Prefer the host classifier so an unrelated error that happens to carry a
|
|
237
|
+
// colliding `code` field is not misreported as an attachment failure.
|
|
238
|
+
let classified = false
|
|
239
|
+
try {
|
|
240
|
+
classified = service.isAttachmentError(error) === true
|
|
241
|
+
} catch {
|
|
242
|
+
classified = false
|
|
243
|
+
}
|
|
244
|
+
if (!classified) return undefined
|
|
245
|
+
}
|
|
246
|
+
return code
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isAbortError(error: unknown): boolean {
|
|
250
|
+
return error !== null && typeof error === 'object'
|
|
251
|
+
&& (error as { name?: unknown }).name === 'AbortError'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Cap for one error description placed into a model-safe reason. */
|
|
255
|
+
const MAX_ERROR_TEXT = 500
|
|
256
|
+
|
|
257
|
+
function errorMessage(error: unknown): string {
|
|
258
|
+
return truncateErrorText(rawErrorMessage(error))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function rawErrorMessage(error: unknown): string {
|
|
262
|
+
if (error instanceof Error) return error.message
|
|
263
|
+
if (typeof error === 'string' && error !== '') return error
|
|
264
|
+
if (error === null || error === undefined) return '未知错误'
|
|
265
|
+
if (typeof error === 'object') {
|
|
266
|
+
// Never surface `[object Object]` (the P0 unreadable-failure lesson); a
|
|
267
|
+
// circular or unserializable value falls back to a fixed description.
|
|
268
|
+
try {
|
|
269
|
+
const text = JSON.stringify(error)
|
|
270
|
+
if (text !== undefined && text !== '{}') return text
|
|
271
|
+
} catch {
|
|
272
|
+
// Circular structure: fall through to the fixed description.
|
|
273
|
+
}
|
|
274
|
+
return '未知错误(不可序列化的对象)'
|
|
275
|
+
}
|
|
276
|
+
return String(error)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Keep one untrusted error description short enough for prompt/tool output. */
|
|
280
|
+
function truncateErrorText(text: string): string {
|
|
281
|
+
return text.length <= MAX_ERROR_TEXT ? text : `${text.slice(0, MAX_ERROR_TEXT)}…(已截断)`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Build the cancellation error passed through a cancelled byte source. */
|
|
285
|
+
function abortReason(signal: AbortSignal): Error {
|
|
286
|
+
const reason: unknown = signal.reason
|
|
287
|
+
if (reason instanceof Error) return reason
|
|
288
|
+
const error = new Error('The operation was aborted')
|
|
289
|
+
error.name = 'AbortError'
|
|
290
|
+
return error
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- File handle text ---------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Deterministic model-facing handle for one stored file: the name, byte size,
|
|
297
|
+
* digest prefix, and the read-only host path to read on demand.
|
|
298
|
+
*
|
|
299
|
+
* Mirrors the host's `FileBlock` projection (`fileHandleText` in
|
|
300
|
+
* `@deepseek-ai/dsh-llm`) so the fallback used when a tool result cannot
|
|
301
|
+
* carry a `FileBlock` is byte-identical to what the host would have produced.
|
|
302
|
+
*/
|
|
303
|
+
export function formatFileHandle(ref: FileAttachmentRef, hostPath?: string): string {
|
|
304
|
+
const identity = `File ${JSON.stringify(ref.name)} (${ref.bytes} bytes, sha256:${digestPrefix(ref)})`
|
|
305
|
+
if (hostPath === undefined || hostPath === '') {
|
|
306
|
+
return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]`
|
|
307
|
+
}
|
|
308
|
+
return `[${identity}: verbatim read-only copy saved at ${JSON.stringify(hostPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** First 8 hex characters of a content-addressed attachment id. */
|
|
312
|
+
function digestPrefix(ref: FileAttachmentRef): string {
|
|
313
|
+
const raw = String(ref.attachmentId)
|
|
314
|
+
const hex = raw.startsWith('sha256:') ? raw.slice('sha256:'.length) : raw
|
|
315
|
+
return hex.slice(0, 8)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- Native attachment store -------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** Input of one image admission (bytes already downloaded). */
|
|
321
|
+
export interface SaveImageInput {
|
|
322
|
+
/** Complete encoded image bytes; callers must bound the download size. */
|
|
323
|
+
bytes: Uint8Array
|
|
324
|
+
/** Caller-declared media type (facade detectors leave it undefined). */
|
|
325
|
+
mediaType?: ImageMediaType
|
|
326
|
+
/** Optional display name; sanitized before reaching the host. */
|
|
327
|
+
name?: string
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One durably stored image and the content block built from it. */
|
|
331
|
+
export interface StoredImage {
|
|
332
|
+
readonly block: ImageBlock
|
|
333
|
+
readonly ref: ImageAttachmentRef
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Input of one file streaming admission. */
|
|
337
|
+
export interface SaveFileInput {
|
|
338
|
+
/** Ordered byte chunks; the source is consumed exactly once, never buffered. */
|
|
339
|
+
source: AsyncIterable<Uint8Array>
|
|
340
|
+
/** Optional display name; sanitized before reaching the host. */
|
|
341
|
+
name?: string
|
|
342
|
+
/** Cooperative cancellation honored between chunks and forwarded to the host. */
|
|
343
|
+
signal?: AbortSignal
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One durably stored file, its content block, and the readable fallback. */
|
|
347
|
+
export interface StoredFile {
|
|
348
|
+
readonly block: FileBlock
|
|
349
|
+
readonly ref: FileAttachmentRef
|
|
350
|
+
/** Deterministic handle text for tool results that cannot carry a `FileBlock`. */
|
|
351
|
+
readonly handleText: string
|
|
352
|
+
/** Read-only host path, when the backend maps one. */
|
|
353
|
+
readonly hostPath?: string
|
|
354
|
+
/** Why the read-only path could not be resolved; absent when `hostPath` exists. */
|
|
355
|
+
readonly readPathError?: AttachmentFailureResult
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface NativeAttachmentStoreDeps {
|
|
359
|
+
/**
|
|
360
|
+
* Live lookup of the host attachment service. Called per operation so a
|
|
361
|
+
* service that unmounts (Agent disposal) reads as unmounted instead of
|
|
362
|
+
* handing out a stale reference.
|
|
363
|
+
*/
|
|
364
|
+
service(): AttachmentStore | undefined
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Thin, degradation-aware wrapper over one host `ctx.attachments` service.
|
|
369
|
+
*
|
|
370
|
+
* The wrapper owns no state and never imports the host package; construct it
|
|
371
|
+
* inside an Agent scope that injected `attachments`, or resolve it lazily with
|
|
372
|
+
* {@link nativeAttachmentStoreOf}.
|
|
373
|
+
*/
|
|
374
|
+
export class NativeAttachmentStore {
|
|
375
|
+
constructor(private readonly deps: NativeAttachmentStoreDeps) {}
|
|
376
|
+
|
|
377
|
+
/** Current mount state of the underlying service. */
|
|
378
|
+
state(): AttachmentServiceState {
|
|
379
|
+
return attachmentServiceStateOf(this.deps.service())
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Validate and durably store one image.
|
|
384
|
+
*
|
|
385
|
+
* The declared media type is verified against the bytes when both are
|
|
386
|
+
* available; an unknown raster is refused before touching the store. Limits
|
|
387
|
+
* published by the host are enforced here as a fast pre-check, and the store
|
|
388
|
+
* still re-validates decode, dimensions, and pixels.
|
|
389
|
+
*/
|
|
390
|
+
async saveImage(input: SaveImageInput): Promise<AttachmentResult<StoredImage>> {
|
|
391
|
+
const mounted = this.state()
|
|
392
|
+
if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
|
|
393
|
+
|
|
394
|
+
const detected = sniffImageMediaType(input.bytes)
|
|
395
|
+
if (input.mediaType !== undefined && detected !== undefined && detected !== input.mediaType) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
code: 'IMAGE_TYPE_MISMATCH',
|
|
399
|
+
reason: `图片内容与声明的类型不一致(声明 ${input.mediaType},实际 ${detected})`,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const mediaType = input.mediaType ?? detected
|
|
403
|
+
if (mediaType === undefined) {
|
|
404
|
+
return { ok: false, code: 'UNSUPPORTED_IMAGE_TYPE', reason: FAILURE_REASONS.UNSUPPORTED_IMAGE_TYPE }
|
|
405
|
+
}
|
|
406
|
+
const limits = mounted.limits
|
|
407
|
+
if (limits !== undefined) {
|
|
408
|
+
// The host owns authoritative validation; a malformed limits object must
|
|
409
|
+
// not throw here (every operation in this module returns a value).
|
|
410
|
+
try {
|
|
411
|
+
const maxBytes = limits.maxImageBytes
|
|
412
|
+
if (Number.isFinite(maxBytes) && maxBytes > 0 && input.bytes.byteLength > maxBytes) {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
code: 'IMAGE_TOO_LARGE',
|
|
416
|
+
reason: `图片超过大小上限(${input.bytes.byteLength} > ${maxBytes} 字节)`,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const mediaTypes: readonly string[] | undefined = Array.isArray(limits.mediaTypes)
|
|
420
|
+
? limits.mediaTypes
|
|
421
|
+
: undefined
|
|
422
|
+
if (mediaTypes !== undefined && mediaTypes.length > 0 && !mediaTypes.includes(mediaType)) {
|
|
423
|
+
return { ok: false, code: 'UNSUPPORTED_IMAGE_TYPE', reason: FAILURE_REASONS.UNSUPPORTED_IMAGE_TYPE }
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
// Fall through: the store re-validates the image authoritatively.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const name = sanitizeFileName(input.name)
|
|
431
|
+
try {
|
|
432
|
+
const ref = await mounted.service.saveImage({
|
|
433
|
+
data: input.bytes,
|
|
434
|
+
mediaType,
|
|
435
|
+
...(name === undefined ? {} : { name }),
|
|
436
|
+
})
|
|
437
|
+
return { ok: true, value: { block: { type: 'image', attachment: ref }, ref } }
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return this.failure(error, mounted.service)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Durably store one streamed file.
|
|
445
|
+
*
|
|
446
|
+
* The source is forwarded chunk by chunk (with a cancellation guard between
|
|
447
|
+
* yields); nothing is collected here or held after the host returns its
|
|
448
|
+
* reference. Cancellation is reported as `ABORTED`, never as a raw error.
|
|
449
|
+
*/
|
|
450
|
+
async saveFile(input: SaveFileInput): Promise<AttachmentResult<StoredFile>> {
|
|
451
|
+
const mounted = this.state()
|
|
452
|
+
if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
|
|
453
|
+
if (input.signal?.aborted === true) {
|
|
454
|
+
return { ok: false, code: 'ABORTED', reason: FAILURE_REASONS.ABORTED }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const name = sanitizeFileName(input.name) ?? DEFAULT_FILE_NAME
|
|
458
|
+
try {
|
|
459
|
+
const ref = await mounted.service.saveFileStream({
|
|
460
|
+
data: cancelableBytes(input.source, input.signal),
|
|
461
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
462
|
+
name,
|
|
463
|
+
})
|
|
464
|
+
return { ok: true, value: this.storedFile(mounted.service, ref) }
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return this.failure(error, mounted.service, input.signal)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Resolve the read-only host path of one stored file (structured failure when not). */
|
|
471
|
+
fileReadPath(ref: FileAttachmentRef): AttachmentResult<string> {
|
|
472
|
+
const mounted = this.state()
|
|
473
|
+
if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
|
|
474
|
+
return readPathOf(ref, 'file', mounted.service)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Resolve the read-only host path of one stored image (structured failure when not). */
|
|
478
|
+
imageReadPath(ref: ImageAttachmentRef): AttachmentResult<string> {
|
|
479
|
+
const mounted = this.state()
|
|
480
|
+
if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
|
|
481
|
+
return readPathOf(ref, 'image', mounted.service)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Admit one host prompt: uploaded images become durable references, text and
|
|
486
|
+
* resolved file references pass through. Wrapped so a prefetch failure stays
|
|
487
|
+
* a readable value instead of aborting the turn.
|
|
488
|
+
*/
|
|
489
|
+
async admitPromptContent(
|
|
490
|
+
content: readonly AttachmentAdmissionPart[],
|
|
491
|
+
): Promise<AttachmentResult<AdmittedPromptContentPart[]>> {
|
|
492
|
+
const mounted = this.state()
|
|
493
|
+
if (!mounted.mounted) return { ok: false, code: mounted.code, reason: mounted.reason }
|
|
494
|
+
try {
|
|
495
|
+
return { ok: true, value: await mounted.service.admitPromptContent(content) }
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return this.failure(error, mounted.service)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Build the model-facing value of one stored file (block, handle text, path). */
|
|
502
|
+
private storedFile(service: AttachmentStore, ref: FileAttachmentRef): StoredFile {
|
|
503
|
+
const read = readPathOf(ref, 'file', service)
|
|
504
|
+
const hostPath = read.ok ? read.value : undefined
|
|
505
|
+
return {
|
|
506
|
+
block: { type: 'file', attachment: ref },
|
|
507
|
+
ref,
|
|
508
|
+
handleText: formatFileHandle(ref, hostPath),
|
|
509
|
+
...(hostPath === undefined ? {} : { hostPath }),
|
|
510
|
+
...(read.ok ? {} : { readPathError: read }),
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Classify one thrown value into a structured, model-safe failure. */
|
|
515
|
+
private failure(error: unknown, service: AttachmentStore, signal?: AbortSignal): AttachmentFailureResult {
|
|
516
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
517
|
+
return { ok: false, code: 'ABORTED', reason: FAILURE_REASONS.ABORTED }
|
|
518
|
+
}
|
|
519
|
+
const code = attachmentErrorCodeOf(error, service)
|
|
520
|
+
if (code !== undefined) return { ok: false, code, reason: FAILURE_REASONS[code] }
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
code: 'ATTACHMENT_STORE_ERROR',
|
|
524
|
+
reason: `${FAILURE_REASONS.ATTACHMENT_STORE_ERROR}:${errorMessage(error)}`,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Lazy store bound to one Agent scope; unmounted scopes degrade per call. */
|
|
530
|
+
export function nativeAttachmentStoreOf(ctx: AgentContext | undefined): NativeAttachmentStore {
|
|
531
|
+
return new NativeAttachmentStore({ service: () => resolveAttachmentService(ctx) })
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function readPathOf(
|
|
535
|
+
ref: FileAttachmentRef | ImageAttachmentRef,
|
|
536
|
+
kind: 'file' | 'image',
|
|
537
|
+
service: AttachmentStore,
|
|
538
|
+
): AttachmentResult<string> {
|
|
539
|
+
try {
|
|
540
|
+
const path = kind === 'file'
|
|
541
|
+
? service.fileHostPath(ref as FileAttachmentRef)
|
|
542
|
+
: service.imageHostPath(ref as ImageAttachmentRef)
|
|
543
|
+
if (path === undefined || path === '') {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
code: 'ATTACHMENT_PROJECTION_UNSUPPORTED',
|
|
547
|
+
reason: '当前附件后端不提供主机只读路径(无法映射到执行环境路径)',
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return { ok: true, value: path }
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const code = attachmentErrorCodeOf(error, service)
|
|
553
|
+
if (code !== undefined) return { ok: false, code, reason: FAILURE_REASONS[code] }
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
code: 'ATTACHMENT_READ_FAILED',
|
|
557
|
+
reason: `读取附件只读路径失败:${errorMessage(error)}`,
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Wrap one byte source so an aborted signal stops the pull loop between
|
|
564
|
+
* chunks. The host still receives the signal and owns its own aborts.
|
|
565
|
+
*/
|
|
566
|
+
async function* cancelableBytes(
|
|
567
|
+
source: AsyncIterable<Uint8Array>,
|
|
568
|
+
signal?: AbortSignal,
|
|
569
|
+
): AsyncGenerator<Uint8Array> {
|
|
570
|
+
if (signal === undefined) {
|
|
571
|
+
yield* source
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
for await (const chunk of source) {
|
|
575
|
+
if (signal.aborted) throw abortReason(signal)
|
|
576
|
+
yield chunk
|
|
577
|
+
}
|
|
578
|
+
if (signal.aborted) throw abortReason(signal)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Read one image resource completely (bounded by `cap` bytes). Images are
|
|
583
|
+
* platform-capped far below the file path, so one bounded in-memory read is
|
|
584
|
+
* the correct shape here; files never take this path.
|
|
585
|
+
*/
|
|
586
|
+
async function collectImageBytes(
|
|
587
|
+
source: AsyncIterable<Uint8Array>,
|
|
588
|
+
cap: number,
|
|
589
|
+
): Promise<AttachmentResult<Uint8Array>> {
|
|
590
|
+
const chunks: Uint8Array[] = []
|
|
591
|
+
let total = 0
|
|
592
|
+
for await (const chunk of source) {
|
|
593
|
+
total += chunk.byteLength
|
|
594
|
+
if (total > cap) {
|
|
595
|
+
return {
|
|
596
|
+
ok: false,
|
|
597
|
+
code: 'IMAGE_TOO_LARGE',
|
|
598
|
+
reason: `图片超过大小上限(已读取 ${total} 字节,上限 ${cap} 字节)`,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
chunks.push(chunk)
|
|
602
|
+
}
|
|
603
|
+
const bytes = new Uint8Array(total)
|
|
604
|
+
let offset = 0
|
|
605
|
+
for (const chunk of chunks) {
|
|
606
|
+
bytes.set(chunk, offset)
|
|
607
|
+
offset += chunk.byteLength
|
|
608
|
+
}
|
|
609
|
+
return { ok: true, value: bytes }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ---- Backend selection / archive fallback ------------------------------
|
|
613
|
+
|
|
614
|
+
/** Why an inbound message could not use its configured backend. */
|
|
615
|
+
export type AttachmentStorageFallback = 'attachment-service-unmounted' | 'forwarded-record'
|
|
616
|
+
|
|
617
|
+
/** Resolved backend choice for the current deployment. */
|
|
618
|
+
export interface AttachmentStoragePlan {
|
|
619
|
+
readonly configured: AttachmentStoreMode
|
|
620
|
+
/** Backend actually used for new inbound bytes. */
|
|
621
|
+
readonly effective: AttachmentStoreMode
|
|
622
|
+
/** Present when `effective` differs from `configured` or the message path is limited. */
|
|
623
|
+
readonly fallback?: AttachmentStorageFallback
|
|
624
|
+
/** One-shot operator warning for a degraded deployment. */
|
|
625
|
+
readonly warning?: string
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Choose the storage backend. A configured `archive` is always honored; a
|
|
630
|
+
* configured `native` without a mounted service downgrades to `archive` with
|
|
631
|
+
* an explicit fallback and warning (never a failed turn).
|
|
632
|
+
*/
|
|
633
|
+
export function planAttachmentStorage(input: {
|
|
634
|
+
configured: AttachmentStoreMode
|
|
635
|
+
service: AttachmentStore | undefined
|
|
636
|
+
}): AttachmentStoragePlan {
|
|
637
|
+
if (input.configured === 'archive') {
|
|
638
|
+
return { configured: 'archive', effective: 'archive' }
|
|
639
|
+
}
|
|
640
|
+
if (input.service !== undefined) {
|
|
641
|
+
return { configured: 'native', effective: 'native' }
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
configured: 'native',
|
|
645
|
+
effective: 'archive',
|
|
646
|
+
fallback: 'attachment-service-unmounted',
|
|
647
|
+
warning: 'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,回退话题 attachments/ 工作区落盘',
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Effective backend for new inbound bytes.
|
|
653
|
+
*
|
|
654
|
+
* An explicit `archive` configuration always wins (legacy behavior, including
|
|
655
|
+
* synchronous workspace downloads). A `native` configuration degrades to
|
|
656
|
+
* `archive` when:
|
|
657
|
+
*
|
|
658
|
+
* - the host does not mount the attachment service (headless/older hosts must
|
|
659
|
+
* keep the pre-v2 pipeline instead of losing their attachments); or
|
|
660
|
+
* - the model-facing fetch tool is disabled: files in the native pipeline are
|
|
661
|
+
* only obtainable through `feishu_attachment_fetch`, so a manifest without
|
|
662
|
+
* that tool would promise an operation the model cannot perform. Falling
|
|
663
|
+
* back keeps their bytes readable from the workspace archive instead.
|
|
664
|
+
*
|
|
665
|
+
* With `acceptAttachments: false` the backend is `archive` too: the legacy
|
|
666
|
+
* disabled path is the known-inert behavior (no downloads, no turn for
|
|
667
|
+
* attachment-only messages), and the archive guidance never advertises a tool
|
|
668
|
+
* that is not mounted.
|
|
669
|
+
*/
|
|
670
|
+
export function effectiveAttachmentMode(
|
|
671
|
+
configured: AttachmentStoreMode,
|
|
672
|
+
service: AttachmentStore | undefined,
|
|
673
|
+
attachmentFetchToolEnabled = true,
|
|
674
|
+
attachmentsEnabled = true,
|
|
675
|
+
): AttachmentStoreMode {
|
|
676
|
+
if (configured === 'archive' || service === undefined) return 'archive'
|
|
677
|
+
if (!attachmentsEnabled || !attachmentFetchToolEnabled) return 'archive'
|
|
678
|
+
return 'native'
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** One image stored natively for an inbound message. */
|
|
682
|
+
export interface StoredNativeImage {
|
|
683
|
+
readonly kind: 'image'
|
|
684
|
+
readonly resource: AttachmentResource
|
|
685
|
+
readonly ref: ImageAttachmentRef
|
|
686
|
+
readonly block: ImageBlock
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** One file stored natively for an inbound message. */
|
|
690
|
+
export interface StoredNativeFile {
|
|
691
|
+
readonly kind: 'file'
|
|
692
|
+
readonly resource: AttachmentResource
|
|
693
|
+
readonly ref: FileAttachmentRef
|
|
694
|
+
readonly block: FileBlock
|
|
695
|
+
readonly handleText: string
|
|
696
|
+
readonly hostPath?: string
|
|
697
|
+
readonly readPathError?: AttachmentFailureResult
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** One inbound resource stored by the native backend. */
|
|
701
|
+
export type StoredAttachment = StoredNativeImage | StoredNativeFile
|
|
702
|
+
|
|
703
|
+
/** Result of storing one message's attachment payload. */
|
|
704
|
+
export interface InboundAttachmentStoreResult {
|
|
705
|
+
/** Backend that actually handled this message. */
|
|
706
|
+
readonly mode: AttachmentStoreMode
|
|
707
|
+
/** Present when the configured backend could not be used for this message. */
|
|
708
|
+
readonly fallback?: AttachmentStorageFallback
|
|
709
|
+
/** Resources durably stored (native mode only). */
|
|
710
|
+
readonly saved: number
|
|
711
|
+
/** Resources that failed to download/store. */
|
|
712
|
+
readonly failed: number
|
|
713
|
+
/** Per-resource failure notes (user-facing), mirroring the archive intake. */
|
|
714
|
+
readonly failures: readonly AttachmentFailure[]
|
|
715
|
+
/** Native content blocks, ready for prompt assembly or tool results. */
|
|
716
|
+
readonly stored: readonly StoredAttachment[]
|
|
717
|
+
/** True when the message carried no prompt text after removing resource placeholders. */
|
|
718
|
+
readonly attachmentOnly: boolean
|
|
719
|
+
/** Remaining prompt text after attachment placeholders are removed. */
|
|
720
|
+
readonly text: string
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export interface InboundAttachmentStoreDeps {
|
|
724
|
+
/** Live `acceptAttachments` gate (disabled keeps attachment messages inert). */
|
|
725
|
+
enabled(): boolean
|
|
726
|
+
/** Live configured backend (`attachmentStore`). */
|
|
727
|
+
mode(): AttachmentStoreMode
|
|
728
|
+
/** Live native service lookup (undefined = unmounted). */
|
|
729
|
+
service(): AttachmentStore | undefined
|
|
730
|
+
/** Live Feishu channel; undefined after shutdown. */
|
|
731
|
+
channel(): FeishuChannel | undefined
|
|
732
|
+
/** Existing workspace archive, used by archive mode and every fallback. */
|
|
733
|
+
archive: AttachmentIntake
|
|
734
|
+
/** Structured warning sink (never throws). */
|
|
735
|
+
warn(message: string, ...args: unknown[]): void
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Inbound attachment storage entry point shared by every backend.
|
|
740
|
+
*
|
|
741
|
+
* This is the layer the admission task wires in place of the direct
|
|
742
|
+
* `AttachmentIntake.archive()` call: it resolves the backend once per message,
|
|
743
|
+
* stores trigger-message resources natively, and preserves the legacy
|
|
744
|
+
* workspace archive whenever native is unavailable or configured off.
|
|
745
|
+
*/
|
|
746
|
+
export class InboundAttachmentStore {
|
|
747
|
+
private warnedUnmounted = false
|
|
748
|
+
|
|
749
|
+
constructor(private readonly deps: InboundAttachmentStoreDeps) {}
|
|
750
|
+
|
|
751
|
+
/** Current backend decision for a new inbound message. */
|
|
752
|
+
plan(): AttachmentStoragePlan {
|
|
753
|
+
return planAttachmentStorage({ configured: this.deps.mode(), service: this.deps.service() })
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Store every image/file resource (and, for forwarded records, the legacy
|
|
758
|
+
* text archive) of one inbound message.
|
|
759
|
+
*
|
|
760
|
+
* The v2 native admission pipeline does NOT call this for triggering
|
|
761
|
+
* messages: it prefetches images and leaves files lazy behind the prompt
|
|
762
|
+
* manifest. This facade is used by the `archive` / missing-host fallback
|
|
763
|
+
* (synchronous workspace download) and by the storage-layer tests. Its
|
|
764
|
+
* `forwarded-record` text fallback therefore only runs for archive
|
|
765
|
+
* deployments; reading merged-forward transcripts in native deployments is
|
|
766
|
+
* owned by `quote_history_tools`.
|
|
767
|
+
*
|
|
768
|
+
* @returns `undefined` when the message carries no attachment payload;
|
|
769
|
+
* otherwise a structured result whose `text` is the remaining prompt.
|
|
770
|
+
*/
|
|
771
|
+
async store(msg: NormalizedMessage, directory: string): Promise<InboundAttachmentStoreResult | undefined> {
|
|
772
|
+
if (!hasAttachmentPayload(msg)) return undefined
|
|
773
|
+
|
|
774
|
+
// Snapshot the live config/service once per message: the backend decision,
|
|
775
|
+
// the one-shot warning and the storage call all describe the same moment
|
|
776
|
+
// (a later service unmount surfaces as a per-resource structured failure).
|
|
777
|
+
const configured = this.deps.mode()
|
|
778
|
+
const service = this.deps.service()
|
|
779
|
+
const plan = planAttachmentStorage({ configured, service })
|
|
780
|
+
const prompt = remainingPromptText(msg, msg.rawContentType === 'merge_forward')
|
|
781
|
+
|
|
782
|
+
if (!this.deps.enabled()) {
|
|
783
|
+
// Disabled means inert for attachment-only messages too: strip the
|
|
784
|
+
// resource placeholders and keep the same attachment-only classification
|
|
785
|
+
// as the enabled paths, so the caller starts no turn when no prompt text
|
|
786
|
+
// is left (config: "Disabled keeps attachment messages inert").
|
|
787
|
+
return {
|
|
788
|
+
mode: plan.effective,
|
|
789
|
+
saved: 0,
|
|
790
|
+
failed: 0,
|
|
791
|
+
failures: [],
|
|
792
|
+
stored: [],
|
|
793
|
+
attachmentOnly: prompt.attachmentOnly,
|
|
794
|
+
text: prompt.text,
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (plan.warning !== undefined && !this.warnedUnmounted) {
|
|
798
|
+
this.warnedUnmounted = true
|
|
799
|
+
this.deps.warn(plan.warning)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Forwarded records are plain text, not DSH attachments: keep the legacy
|
|
803
|
+
// text archive for them until the quote/history task owns that resource.
|
|
804
|
+
if (plan.effective === 'native' && service !== undefined && msg.rawContentType !== 'merge_forward') {
|
|
805
|
+
return await this.storeNative(msg, service)
|
|
806
|
+
}
|
|
807
|
+
return await this.storeArchive(msg, directory, plan)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Legacy backend: workspace `attachments/` through the existing intake. */
|
|
811
|
+
private async storeArchive(
|
|
812
|
+
msg: NormalizedMessage,
|
|
813
|
+
directory: string,
|
|
814
|
+
plan: AttachmentStoragePlan,
|
|
815
|
+
): Promise<InboundAttachmentStoreResult> {
|
|
816
|
+
const archived = await this.deps.archive.archive(msg, directory)
|
|
817
|
+
const fallback: AttachmentStorageFallback | undefined = msg.rawContentType === 'merge_forward'
|
|
818
|
+
&& plan.effective === 'native'
|
|
819
|
+
? 'forwarded-record'
|
|
820
|
+
: plan.fallback
|
|
821
|
+
return {
|
|
822
|
+
mode: 'archive',
|
|
823
|
+
...(fallback === undefined ? {} : { fallback }),
|
|
824
|
+
saved: archived.saved,
|
|
825
|
+
failed: archived.failed,
|
|
826
|
+
failures: archived.failures,
|
|
827
|
+
stored: [],
|
|
828
|
+
attachmentOnly: archived.attachmentOnly,
|
|
829
|
+
text: archived.text,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Native backend: images/files through `ctx.attachments`. */
|
|
834
|
+
private async storeNative(
|
|
835
|
+
msg: NormalizedMessage,
|
|
836
|
+
service: AttachmentStore,
|
|
837
|
+
): Promise<InboundAttachmentStoreResult> {
|
|
838
|
+
const resources = attachmentResources(msg)
|
|
839
|
+
const { text: remainingText, attachmentOnly } = remainingPromptText(msg, false)
|
|
840
|
+
|
|
841
|
+
const channel = this.deps.channel()
|
|
842
|
+
if (channel === undefined) {
|
|
843
|
+
return {
|
|
844
|
+
mode: 'native',
|
|
845
|
+
saved: 0,
|
|
846
|
+
failed: resources.length,
|
|
847
|
+
failures: resources.map(resource => ({
|
|
848
|
+
name: displayName(resource),
|
|
849
|
+
reason: '飞书连接未运行,附件未保存',
|
|
850
|
+
})),
|
|
851
|
+
stored: [],
|
|
852
|
+
attachmentOnly,
|
|
853
|
+
text: remainingText,
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Live lookup inside the store: if the host unmounts mid-message, the
|
|
858
|
+
// remaining resources fail as "unmounted" instead of reusing a stale handle.
|
|
859
|
+
const native = new NativeAttachmentStore({ service: () => this.deps.service() })
|
|
860
|
+
const imageCap = imageByteCap(service)
|
|
861
|
+
const stored: StoredAttachment[] = []
|
|
862
|
+
const failures: AttachmentFailure[] = []
|
|
863
|
+
|
|
864
|
+
for (const resource of resources) {
|
|
865
|
+
const name = displayName(resource)
|
|
866
|
+
if (resource.folder === true) {
|
|
867
|
+
failures.push({ name, reason: FAILURE_REASONS.FOLDER_UNSUPPORTED })
|
|
868
|
+
continue
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
const result = resource.type === 'image'
|
|
872
|
+
? await storeImageResource(native, channel, msg, resource, imageCap)
|
|
873
|
+
: await storeFileResource(native, channel, msg, resource)
|
|
874
|
+
if (result.ok) {
|
|
875
|
+
stored.push(result.value)
|
|
876
|
+
} else {
|
|
877
|
+
failures.push({ name, reason: result.reason })
|
|
878
|
+
this.deps.warn(
|
|
879
|
+
'dsh-channels: storing attachment %s of message %s failed (%s): %s',
|
|
880
|
+
resource.fileKey, msg.messageId, result.code, result.reason,
|
|
881
|
+
)
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
failures.push({ name, reason: describeFailure(error) })
|
|
885
|
+
this.deps.warn(
|
|
886
|
+
'dsh-channels: storing attachment %s of message %s failed: %s',
|
|
887
|
+
resource.fileKey, msg.messageId, String(error),
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return {
|
|
893
|
+
mode: 'native',
|
|
894
|
+
saved: stored.length,
|
|
895
|
+
failed: failures.length,
|
|
896
|
+
failures,
|
|
897
|
+
stored,
|
|
898
|
+
attachmentOnly,
|
|
899
|
+
text: remainingText,
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** Largest image the native path will buffer before refusing it. */
|
|
905
|
+
function imageByteCap(service: AttachmentStore): number {
|
|
906
|
+
const max = service.imageLimits?.maxImageBytes
|
|
907
|
+
if (max !== undefined && Number.isFinite(max) && max > 0) return max
|
|
908
|
+
return FALLBACK_MAX_IMAGE_BYTES
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Strip attachment placeholders and classify what prompt text is left. Shared
|
|
913
|
+
* by the enabled native path and the disabled (inert) branch so both agree on
|
|
914
|
+
* the attachment-only decision.
|
|
915
|
+
*/
|
|
916
|
+
function remainingPromptText(
|
|
917
|
+
msg: NormalizedMessage,
|
|
918
|
+
forwarded: boolean,
|
|
919
|
+
): { text: string; attachmentOnly: boolean } {
|
|
920
|
+
const stripped = stripAttachmentPlaceholders(msg.content ?? '', forwarded)
|
|
921
|
+
const text = stripped === EMPTY_POST_PLACEHOLDER ? '' : stripped
|
|
922
|
+
return { text, attachmentOnly: text === '' }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** User-facing name for one attachment (mirrors the archive intake). */
|
|
926
|
+
function displayName(resource: AttachmentResource): string {
|
|
927
|
+
if (resource.fileName !== undefined && resource.fileName !== '') return resource.fileName
|
|
928
|
+
return resource.type === 'image' ? '图片' : '文件'
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function storeImageResource(
|
|
932
|
+
native: NativeAttachmentStore,
|
|
933
|
+
channel: FeishuChannel,
|
|
934
|
+
msg: NormalizedMessage,
|
|
935
|
+
resource: AttachmentResource,
|
|
936
|
+
cap: number,
|
|
937
|
+
): Promise<AttachmentResult<StoredNativeImage>> {
|
|
938
|
+
let stream: ResourceChunkStream
|
|
939
|
+
try {
|
|
940
|
+
stream = await channel.downloadResource(msg.messageId, resource.fileKey, 'image')
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return { ok: false, code: 'DOWNLOAD_FAILED', reason: describeFailure(error) }
|
|
943
|
+
}
|
|
944
|
+
const bytes = await collectImageBytes(stream, cap)
|
|
945
|
+
if (!bytes.ok) return bytes
|
|
946
|
+
const saved = await native.saveImage({
|
|
947
|
+
bytes: bytes.value,
|
|
948
|
+
...(resource.fileName === undefined ? {} : { name: resource.fileName }),
|
|
949
|
+
})
|
|
950
|
+
if (!saved.ok) return saved
|
|
951
|
+
return {
|
|
952
|
+
ok: true,
|
|
953
|
+
value: { kind: 'image', resource, ref: saved.value.ref, block: saved.value.block },
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async function storeFileResource(
|
|
958
|
+
native: NativeAttachmentStore,
|
|
959
|
+
channel: FeishuChannel,
|
|
960
|
+
msg: NormalizedMessage,
|
|
961
|
+
resource: AttachmentResource,
|
|
962
|
+
): Promise<AttachmentResult<StoredNativeFile>> {
|
|
963
|
+
let stream: ResourceChunkStream
|
|
964
|
+
try {
|
|
965
|
+
stream = await channel.downloadResource(msg.messageId, resource.fileKey, 'file')
|
|
966
|
+
} catch (error) {
|
|
967
|
+
return { ok: false, code: 'DOWNLOAD_FAILED', reason: describeFailure(error) }
|
|
968
|
+
}
|
|
969
|
+
const saved = await native.saveFile({
|
|
970
|
+
source: stream,
|
|
971
|
+
...(resource.fileName === undefined ? {} : { name: resource.fileName }),
|
|
972
|
+
})
|
|
973
|
+
if (!saved.ok) {
|
|
974
|
+
// A stream error surfaces from inside the host write. Only a failure whose
|
|
975
|
+
// text matches the known Feishu download refusals (400/403) is re-labeled
|
|
976
|
+
// as a download failure; a real storage fault (ENOSPC, ...) keeps its
|
|
977
|
+
// ATTACHMENT_STORE_ERROR code and its wording.
|
|
978
|
+
if (saved.code === 'ATTACHMENT_STORE_ERROR') {
|
|
979
|
+
const friendly = describeFailure(new Error(saved.reason))
|
|
980
|
+
if (friendly !== saved.reason) {
|
|
981
|
+
return { ok: false, code: 'DOWNLOAD_FAILED', reason: friendly }
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return saved
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
ok: true,
|
|
988
|
+
value: {
|
|
989
|
+
kind: 'file',
|
|
990
|
+
resource,
|
|
991
|
+
ref: saved.value.ref,
|
|
992
|
+
block: saved.value.block,
|
|
993
|
+
handleText: saved.value.handleText,
|
|
994
|
+
...(saved.value.hostPath === undefined ? {} : { hostPath: saved.value.hostPath }),
|
|
995
|
+
...(saved.value.readPathError === undefined ? {} : { readPathError: saved.value.readPathError }),
|
|
996
|
+
},
|
|
997
|
+
}
|
|
998
|
+
}
|