dsh-plugin-mobile-link 0.7.0 → 0.8.1
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 +27 -12
- package/cordis.patch.yml +5 -0
- package/lib/cli.js +11 -13
- package/lib/client.js +1453 -91
- package/lib/dlp.js +15 -0
- package/lib/enroll.js +249 -35
- package/lib/handoff.js +1 -1
- package/lib/hello.js +1 -1
- package/lib/history/fold-summary.js +85 -32
- package/lib/history/fold.js +11 -1
- package/lib/history/question-record.js +186 -0
- package/lib/i18n.js +177 -0
- package/lib/image-originals.js +121 -0
- package/lib/image-variants.js +315 -0
- package/lib/index.js +59 -2
- package/lib/journal.js +2 -0
- package/lib/link.js +274 -17
- package/lib/router.js +70 -8
- package/lib/routes.js +147 -0
- package/lib/session-digest.js +24 -10
- package/lib/session-scope.js +89 -0
- package/lib/state.js +3 -3
- package/lib/status.js +42 -11
- package/package.json +1 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话图片的"预览 + 可选原图"两变体能力(连接器侧)。
|
|
3
|
+
*
|
|
4
|
+
* 这一层只做三件事,且都在电脑上完成:
|
|
5
|
+
* 1. 把手机已经落盘的暂存字节交给 Host 的附件服务:预览走
|
|
6
|
+
* `fileUploads.uploadImageStream`(Host 归一化,返回 prompt 可引用的 receipt),
|
|
7
|
+
* 原图走 `attachments.saveFileStream`(逐字节保存,不做任何重编码)。
|
|
8
|
+
* 2. 把"预览 attachmentId → 原图 file 引用"写进 Host 侧的索引
|
|
9
|
+
* (`lib/image-originals.js`),于是重装 App / 换设备后仍能取回原图。
|
|
10
|
+
* 3. 按需把原图按区间读回给手机(元数据 + 分片),供"查看原图/保存原图"使用。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不改 Host:Host 没有"按预览 id 读原图文件"的 RPC,`session/attachment`
|
|
13
|
+
* 也只认图像引用。连接的保留方法(`_link/*`)本来就是连接器自己回答的通道,
|
|
14
|
+
* 把这一段放在这里既不需要改 Host 契约,也不会新增中转的帧类型。
|
|
15
|
+
*
|
|
16
|
+
* 服务是**可选注入**:宿主缺少 `attachments` / `fileUploads`(老版本或裁剪部署)时,
|
|
17
|
+
* 三个方法都回 `link/unsupported`,手机据此显示"原图不可用",预览仍照常工作。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { closeSync, createReadStream, openSync, readSync, statSync, unlinkSync } from 'node:fs'
|
|
21
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
22
|
+
|
|
23
|
+
import { appendIndex, indexEntry, lookupOriginal } from './image-originals.js'
|
|
24
|
+
import { dshHome } from './state.js'
|
|
25
|
+
|
|
26
|
+
/** 手机侧调用的三个保留方法(`_link/` 前缀,永不转发给 Host)。 */
|
|
27
|
+
export const IMAGE_COMMIT = '_link/imageCommit'
|
|
28
|
+
export const IMAGE_ORIGINAL = '_link/imageOriginal'
|
|
29
|
+
export const IMAGE_ORIGINAL_CHUNK = '_link/imageOriginalChunk'
|
|
30
|
+
|
|
31
|
+
/** 一次分片读取的默认字节数与上限:base64 后仍远低于 32 MB 的帧上限。 */
|
|
32
|
+
export const DEFAULT_CHUNK_BYTES = 512 * 1024
|
|
33
|
+
export const MAX_CHUNK_BYTES = 1024 * 1024
|
|
34
|
+
|
|
35
|
+
/** 手机发来的图必须落在这个目录里(`FileInbox` 的落盘位置)。 */
|
|
36
|
+
function inboxDirectory(home, sessionId) {
|
|
37
|
+
return resolve(home, 'inbox', sessionId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 交给 Host 的字节流。
|
|
42
|
+
*
|
|
43
|
+
* 挂一个空错误处理:Host 可能在读完之前就拒绝(超限、格式不接受),而我们随后会删掉
|
|
44
|
+
* 暂存文件——被丢弃的流在 unlink 之后再打开会抛 ENOENT,没人接住的 `error` 事件会变成
|
|
45
|
+
* 进程级 uncaughtException。消费方替身靠 async iterator 仍能拿到真正的失败,这里只是
|
|
46
|
+
* 保证"我们自己制造的那条尾巴"不会冒到进程上。
|
|
47
|
+
*/
|
|
48
|
+
function readStream(path) {
|
|
49
|
+
const stream = createReadStream(path)
|
|
50
|
+
stream.on('error', () => {})
|
|
51
|
+
return stream
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 一个带稳定错误码的失败:路由把它原样翻成 `{ok:false,error}`。 */
|
|
55
|
+
class ImageVariantError extends Error {
|
|
56
|
+
constructor(code, message, details = {}) {
|
|
57
|
+
super(message)
|
|
58
|
+
this.code = code
|
|
59
|
+
this.details = details
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 宿主失败 → 保留方法错误码。认不出的按上传失败处理。 */
|
|
64
|
+
function commitFailure(error) {
|
|
65
|
+
const code = error?.code
|
|
66
|
+
const reason = error?.details?.reason
|
|
67
|
+
if (code === 'session/not-found') return new ImageVariantError('session/not-found', '会话不存在或未挂上 Agent。')
|
|
68
|
+
if (reason === 'UNSUPPORTED_IMAGE_TYPE') {
|
|
69
|
+
return new ImageVariantError('image/unsupported-type', '这个部署不接受该图片格式。')
|
|
70
|
+
}
|
|
71
|
+
if (reason === 'IMAGE_TOO_LARGE' || reason === 'IMAGES_TOO_LARGE') {
|
|
72
|
+
return new ImageVariantError('image/too-large', '预览图超过宿主的单图上限。')
|
|
73
|
+
}
|
|
74
|
+
return new ImageVariantError('image/upload-failed', error?.message ?? String(error))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 是否是本模块回答的方法。 */
|
|
78
|
+
export function isImageVariantMethod(method) {
|
|
79
|
+
return method === IMAGE_COMMIT || method === IMAGE_ORIGINAL || method === IMAGE_ORIGINAL_CHUNK
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Owns the image-variant methods for one connector. */
|
|
83
|
+
export class ImageVariants {
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} options
|
|
86
|
+
* @param {object} [options.attachments] 宿主附件服务(`ctx.attachments`)
|
|
87
|
+
* @param {object} [options.fileUploads] 宿主上传服务(`ctx.fileUploads`)
|
|
88
|
+
* @param {object} [options.logger]
|
|
89
|
+
* @param {string} [options.home] 索引与收件箱所在的 DSH_HOME(测试注入)
|
|
90
|
+
*/
|
|
91
|
+
constructor({ attachments, fileUploads, logger = console, home = dshHome() } = {}) {
|
|
92
|
+
this.attachments = attachments
|
|
93
|
+
this.fileUploads = fileUploads
|
|
94
|
+
this.logger = logger
|
|
95
|
+
this.home = home
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 两个宿主服务都到位才答原图;缺一个就如实报不支持。 */
|
|
99
|
+
get available() {
|
|
100
|
+
return Boolean(this.attachments && this.fileUploads)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Answers one reserved call. Throws an `ImageVariantError` for the router to report. */
|
|
104
|
+
async handle(method, args = {}) {
|
|
105
|
+
switch (method) {
|
|
106
|
+
case IMAGE_COMMIT: return this.#commit(args)
|
|
107
|
+
case IMAGE_ORIGINAL: return this.#metadata(args)
|
|
108
|
+
case IMAGE_ORIGINAL_CHUNK: return this.#chunk(args)
|
|
109
|
+
default: throw new ImageVariantError('link/unknown-method', `unknown image method ${method}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 校验并解析一个暂存路径。
|
|
115
|
+
*
|
|
116
|
+
* 只接受收件箱的**直接子文件**:手机本来就只能把文件写进那里,
|
|
117
|
+
* 放宽到子目录或任意路径等于让手机指定电脑上的任意文件。
|
|
118
|
+
*/
|
|
119
|
+
#stagedPath(rawPath, sessionId) {
|
|
120
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
121
|
+
throw new ImageVariantError('image/invalid-path', '缺少暂存文件路径。')
|
|
122
|
+
}
|
|
123
|
+
const resolved = resolve(rawPath)
|
|
124
|
+
if (dirname(resolved) !== inboxDirectory(this.home, sessionId)) {
|
|
125
|
+
throw new ImageVariantError('image/invalid-path', '暂存文件不在本会话的收件箱里。')
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
if (!statSync(resolved).isFile()) throw new Error('not a file')
|
|
129
|
+
} catch {
|
|
130
|
+
throw new ImageVariantError('image/invalid-path', '暂存文件不存在。')
|
|
131
|
+
}
|
|
132
|
+
return resolved
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 删掉本次用到的暂存文件:成品已经进了 Host 对象库,留在收件箱只会越积越多。 */
|
|
136
|
+
#discard(paths) {
|
|
137
|
+
for (const path of paths) {
|
|
138
|
+
if (!path) continue
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(path)
|
|
141
|
+
} catch {
|
|
142
|
+
/* best effort:删不掉不影响已经完成的上传 */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async #commit(args) {
|
|
148
|
+
if (!this.available) {
|
|
149
|
+
throw new ImageVariantError('link/unsupported', '这台电脑的宿主不支持图片变体上传。')
|
|
150
|
+
}
|
|
151
|
+
const sessionId = typeof args.sessionId === 'string' ? args.sessionId : ''
|
|
152
|
+
if (!sessionId) throw new ImageVariantError('session/not-found', '缺少 sessionId。')
|
|
153
|
+
|
|
154
|
+
const previewPath = args.preview ? this.#stagedPath(args.preview.path, sessionId) : undefined
|
|
155
|
+
const previewMediaType = typeof args.preview?.mediaType === 'string' && args.preview.mediaType
|
|
156
|
+
? args.preview.mediaType
|
|
157
|
+
: 'image/jpeg'
|
|
158
|
+
const previewName = typeof args.preview?.name === 'string' && args.preview.name
|
|
159
|
+
? basename(args.preview.name)
|
|
160
|
+
: 'image.jpg'
|
|
161
|
+
|
|
162
|
+
const originalPath = args.original ? this.#stagedPath(args.original.path, sessionId) : undefined
|
|
163
|
+
const originalName = typeof args.original?.name === 'string' && args.original.name
|
|
164
|
+
? basename(args.original.name)
|
|
165
|
+
: basename(originalPath ?? 'original')
|
|
166
|
+
const originalMediaType = typeof args.original?.mediaType === 'string' ? args.original.mediaType : undefined
|
|
167
|
+
const sendOriginalAsFile = args.sendOriginalAsFile === true
|
|
168
|
+
if (!previewPath && !originalPath) {
|
|
169
|
+
throw new ImageVariantError('image/invalid-path', '预览与原图至少要有一份。')
|
|
170
|
+
}
|
|
171
|
+
if (sendOriginalAsFile && !originalPath) {
|
|
172
|
+
throw new ImageVariantError('image/invalid-path', '要求按文件发送原图,但没有提供原图文件。')
|
|
173
|
+
}
|
|
174
|
+
if (!previewPath && !sendOriginalAsFile) {
|
|
175
|
+
// 没有预览还能发出去的唯一形态就是"按文件发送原图";否则用户既看不到图,
|
|
176
|
+
// 也不知道原图在哪。
|
|
177
|
+
throw new ImageVariantError('image/invalid-path', '没有预览时只能按文件发送原图。')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const discard = [previewPath, originalPath]
|
|
181
|
+
try {
|
|
182
|
+
let preview
|
|
183
|
+
if (previewPath) {
|
|
184
|
+
try {
|
|
185
|
+
preview = await this.fileUploads.uploadImageStream({
|
|
186
|
+
sessionId,
|
|
187
|
+
data: readStream(previewPath),
|
|
188
|
+
mediaType: previewMediaType,
|
|
189
|
+
name: previewName,
|
|
190
|
+
})
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw commitFailure(error)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = {
|
|
197
|
+
preview: preview ? { receiptId: preview.receiptId, image: preview.image } : null,
|
|
198
|
+
original: null,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (originalPath) {
|
|
202
|
+
if (sendOriginalAsFile) {
|
|
203
|
+
// 超限或不可解码:按**文件**交回给手机,由它额外发一个 file part。
|
|
204
|
+
// 不写索引、不产生 original 变体——预览绝不会被当成原图。
|
|
205
|
+
let file
|
|
206
|
+
try {
|
|
207
|
+
file = await this.fileUploads.uploadStream({
|
|
208
|
+
sessionId,
|
|
209
|
+
data: readStream(originalPath),
|
|
210
|
+
name: originalName,
|
|
211
|
+
})
|
|
212
|
+
} catch (error) {
|
|
213
|
+
throw commitFailure(error)
|
|
214
|
+
}
|
|
215
|
+
result.fileReceiptId = file.receiptId
|
|
216
|
+
result.originalReason = typeof args.originalReason === 'string' && args.originalReason
|
|
217
|
+
? args.originalReason
|
|
218
|
+
: 'tooLarge'
|
|
219
|
+
} else {
|
|
220
|
+
try {
|
|
221
|
+
const stored = await this.attachments.saveFileStream({
|
|
222
|
+
data: readStream(originalPath),
|
|
223
|
+
name: originalName,
|
|
224
|
+
})
|
|
225
|
+
const metadata = {
|
|
226
|
+
attachmentId: stored.attachmentId,
|
|
227
|
+
name: stored.name,
|
|
228
|
+
bytes: stored.bytes,
|
|
229
|
+
...(originalMediaType === undefined ? {} : { mediaType: originalMediaType }),
|
|
230
|
+
}
|
|
231
|
+
if (preview) {
|
|
232
|
+
appendIndex(
|
|
233
|
+
indexEntry({ previewId: preview.image.attachmentId, original: metadata, source: 'composer' }),
|
|
234
|
+
{ home: this.home, logger: this.logger },
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
result.original = metadata
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// 原图存不进去不该挡下发图:预览照发,手机显示"原图不可用"。
|
|
240
|
+
this.logger.warn?.(`mobile-link: original save failed: ${error?.message ?? error}`)
|
|
241
|
+
result.originalReason = 'failed'
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result
|
|
247
|
+
} finally {
|
|
248
|
+
this.#discard(discard)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 原图是否存在、多大、叫什么。不读字节。 */
|
|
253
|
+
#metadata(args) {
|
|
254
|
+
const previewId = typeof args.attachmentId === 'string' ? args.attachmentId : ''
|
|
255
|
+
if (!previewId) return { available: false, reason: 'missing' }
|
|
256
|
+
const entry = lookupOriginal(previewId, { home: this.home })
|
|
257
|
+
if (!entry) return { available: false, reason: 'missing' }
|
|
258
|
+
if (!this.available) return { available: false, reason: 'unsupported' }
|
|
259
|
+
const path = this.#hostPath(entry)
|
|
260
|
+
if (!path) return { available: false, reason: 'cleaned' }
|
|
261
|
+
return { available: true, original: entry }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 原图对象在电脑上的路径;Host 不提供(对象已清理、提供者不支持)时返回 undefined。 */
|
|
265
|
+
#hostPath(entry) {
|
|
266
|
+
try {
|
|
267
|
+
const path = this.attachments.fileHostPath({
|
|
268
|
+
attachmentId: entry.attachmentId,
|
|
269
|
+
name: entry.name,
|
|
270
|
+
bytes: entry.bytes,
|
|
271
|
+
})
|
|
272
|
+
if (typeof path !== 'string' || !path) return undefined
|
|
273
|
+
if (!statSync(path).isFile()) return undefined
|
|
274
|
+
return path
|
|
275
|
+
} catch {
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 读原图的一个区间。只读请求的那一段,不把整文件塞进内存。 */
|
|
281
|
+
#chunk(args) {
|
|
282
|
+
const previewId = typeof args.attachmentId === 'string' ? args.attachmentId : ''
|
|
283
|
+
const entry = previewId ? lookupOriginal(previewId, { home: this.home }) : undefined
|
|
284
|
+
if (!entry) throw new ImageVariantError('image-original/missing', '没有这张图的原图。')
|
|
285
|
+
if (!this.available) throw new ImageVariantError('image-original/unsupported', '这台电脑的宿主读不到原图。')
|
|
286
|
+
const path = this.#hostPath(entry)
|
|
287
|
+
if (!path) throw new ImageVariantError('image-original/cleaned', '原图已被清理。')
|
|
288
|
+
|
|
289
|
+
const total = entry.bytes
|
|
290
|
+
const offset = Number.isFinite(args.offset) ? Math.max(0, Math.trunc(args.offset)) : 0
|
|
291
|
+
const requested = Number.isFinite(args.length) && args.length > 0
|
|
292
|
+
? Math.trunc(args.length)
|
|
293
|
+
: DEFAULT_CHUNK_BYTES
|
|
294
|
+
const length = Math.min(requested, MAX_CHUNK_BYTES)
|
|
295
|
+
if (offset >= total) {
|
|
296
|
+
return { data: '', offset, eof: true, bytes: total }
|
|
297
|
+
}
|
|
298
|
+
const toRead = Math.min(length, total - offset)
|
|
299
|
+
const buffer = Buffer.alloc(toRead)
|
|
300
|
+
const fd = openSync(path, 'r')
|
|
301
|
+
try {
|
|
302
|
+
const read = readSync(fd, buffer, 0, toRead, offset)
|
|
303
|
+
return {
|
|
304
|
+
data: buffer.subarray(0, read).toString('base64'),
|
|
305
|
+
offset,
|
|
306
|
+
eof: offset + read >= total,
|
|
307
|
+
bytes: total,
|
|
308
|
+
}
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new ImageVariantError('image-original/read-failed', error?.message ?? String(error))
|
|
311
|
+
} finally {
|
|
312
|
+
closeSync(fd)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Scope (deliberately tiny so a DSH upgrade can never make it fatal):
|
|
5
5
|
* 1. Run the DLP agent: dial the relay over WSS, serve the iOS client's
|
|
6
6
|
* session/event traffic from the local DSH instance. On a computer that has
|
|
7
|
-
* never been registered,
|
|
8
|
-
*
|
|
7
|
+
* never been registered, enroll with the relay address first (open
|
|
8
|
+
* enrollment), so a fresh install becomes usable without the relay
|
|
9
9
|
* operator provisioning anything by hand.
|
|
10
10
|
* 2. Two routes under `/mobile-link` behind DSH's own connection fence:
|
|
11
11
|
* GET /status — is the link up, which devices are attached, and is
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { MobileLinkAgent } from './link.js'
|
|
33
33
|
import { registerMobileLinkRoutes } from './routes.js'
|
|
34
34
|
import { createHistoryService } from './history.js'
|
|
35
|
+
import { ImageVariants } from './image-variants.js'
|
|
35
36
|
|
|
36
37
|
/** Cordis function-plugin name (the loader row id is separate). */
|
|
37
38
|
export const name = 'mobile-link'
|
|
@@ -43,6 +44,10 @@ const DEFAULTS = {
|
|
|
43
44
|
relayUrl: process.env.DSH_RELAY_URL
|
|
44
45
|
|| process.env.DSH_MOBILE_LINK_RELAY
|
|
45
46
|
|| 'wss://relay.example.com/dsh-link',
|
|
47
|
+
// 面板的默认中转:只用于预填,不参与拨号优先级。它和 `relayUrl` 分开,是因为
|
|
48
|
+
// 把地址钉进 `relayUrl` 会覆盖身份文件里真正登记过的地址;这里只给表单一个
|
|
49
|
+
// 可修改的初值。
|
|
50
|
+
defaultRelayUrl: process.env.DSH_DEFAULT_RELAY_URL || '',
|
|
46
51
|
agentId: '',
|
|
47
52
|
agentSecret: '',
|
|
48
53
|
stateFile: '',
|
|
@@ -101,6 +106,9 @@ function buildAgent(config, logger) {
|
|
|
101
106
|
return new MobileLinkAgent({
|
|
102
107
|
enabled: config.enabled !== false,
|
|
103
108
|
relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : DEFAULTS.relayUrl,
|
|
109
|
+
defaultRelayUrl: typeof config.defaultRelayUrl === 'string'
|
|
110
|
+
? config.defaultRelayUrl
|
|
111
|
+
: DEFAULTS.defaultRelayUrl,
|
|
104
112
|
agentId: typeof config.agentId === 'string' ? config.agentId : '',
|
|
105
113
|
agentSecret: typeof config.agentSecret === 'string' ? config.agentSecret : '',
|
|
106
114
|
stateFile: typeof config.stateFile === 'string' && config.stateFile ? config.stateFile : undefined,
|
|
@@ -198,6 +206,32 @@ function wireSessionQuery(ctx, history, logger) {
|
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* 把宿主的附件与上传服务交给图片变体能力(**可选注入**)。
|
|
211
|
+
*
|
|
212
|
+
* 两个服务都到位才回答 `_link/image*`:预览要经 `fileUploads` 拿 prompt receipt,
|
|
213
|
+
* 原图要经 `attachments` 逐字节保存并读回。缺一个就保持"不支持",手机显示
|
|
214
|
+
* "原图不可用"——这是如实降级,不是错误;预览仍走原有内联路径。
|
|
215
|
+
*
|
|
216
|
+
* 服务卸载(宿主换代 / 插件禁用)时把绑定摘掉,避免路由拿着失效的服务对象。
|
|
217
|
+
*/
|
|
218
|
+
function wireImageVariants(ctx, agent, logger) {
|
|
219
|
+
try {
|
|
220
|
+
ctx.inject(['attachments', 'fileUploads'], (scope) => {
|
|
221
|
+
const variants = new ImageVariants({
|
|
222
|
+
attachments: scope.attachments,
|
|
223
|
+
fileUploads: scope.fileUploads,
|
|
224
|
+
logger,
|
|
225
|
+
})
|
|
226
|
+
agent.setImageVariants?.(variants)
|
|
227
|
+
scope.effect?.(() => () => agent.setImageVariants?.(undefined), 'mobile-link: image variants')
|
|
228
|
+
logger.info?.('mobile-link: image variants bound to ctx.attachments / ctx.fileUploads')
|
|
229
|
+
})
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger.warn?.(`mobile-link: image variants unavailable (preview-only): ${error}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
201
235
|
/**
|
|
202
236
|
* 把宿主的任务与活会话服务交给活动名单(**可选注入**,spec 018)。
|
|
203
237
|
*
|
|
@@ -218,6 +252,27 @@ function wireJobsServices(ctx, agent, logger) {
|
|
|
218
252
|
}
|
|
219
253
|
}
|
|
220
254
|
|
|
255
|
+
/**
|
|
256
|
+
* 把「哪些会话值得提醒」的宿主账本交给路由(SR-02.08)。
|
|
257
|
+
*
|
|
258
|
+
* 两个服务都到位才启用过滤:`ctx.sessions` 判子会话、`ctx.workspaceRegistry` 判归档与
|
|
259
|
+
* 工作区归属。缺一个就维持旧行为(全报)——拿不到账本时把提醒全吞掉,比多报几条更难发现。
|
|
260
|
+
*/
|
|
261
|
+
function wireSessionScope(ctx, agent, logger) {
|
|
262
|
+
try {
|
|
263
|
+
ctx.inject(['sessions', 'workspaceRegistry'], (scope) => {
|
|
264
|
+
agent.setSessionScopeServices?.({
|
|
265
|
+
sessions: scope.sessions,
|
|
266
|
+
workspaces: scope.workspaceRegistry,
|
|
267
|
+
})
|
|
268
|
+
scope.effect?.(() => () => agent.setSessionScopeServices?.(undefined), 'mobile-link: session scope')
|
|
269
|
+
logger.info?.('mobile-link: reminders limited to formal sessions')
|
|
270
|
+
})
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.warn?.(`mobile-link: session scope unavailable (reminders keep legacy scope): ${error}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
221
276
|
/**
|
|
222
277
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
223
278
|
* @param {Record<string, unknown>} rawConfig
|
|
@@ -234,6 +289,8 @@ export function apply(ctx, rawConfig) {
|
|
|
234
289
|
wireEndpointResolver(ctx, agent, logger)
|
|
235
290
|
wireSessionQuery(ctx, history, logger)
|
|
236
291
|
wireJobsServices(ctx, agent, logger)
|
|
292
|
+
wireSessionScope(ctx, agent, logger)
|
|
293
|
+
wireImageVariants(ctx, agent, logger)
|
|
237
294
|
logger.info?.(`mobile-link: ready (relay ${config.relayUrl})`)
|
|
238
295
|
}
|
|
239
296
|
|