@modesto_ma/dsh-channels 0.3.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.
@@ -47,8 +47,15 @@ import type {
47
47
  SaveCredentialsResponse,
48
48
  } from './contract.ts'
49
49
  import {
50
+ configValueOf,
51
+ deleteOverlayKey,
52
+ mergeOverlay,
53
+ mergeOverlayPatch,
54
+ overlayValueOf,
55
+ overlayValues,
50
56
  parseOverlay,
51
57
  pickEditable,
58
+ setOverlayValue,
52
59
  } from './overlay.ts'
53
60
  import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
54
61
  import type { SettingsCatalog } from './catalog.ts'
@@ -79,7 +86,7 @@ export interface SettingsTarget {
79
86
  configSnapshot(): FeishuConfig
80
87
  baseConfigSnapshot(): FeishuConfig
81
88
  runningConfigSnapshot(): FeishuConfig | undefined
82
- applyOverlay(overlay: Partial<FeishuConfig>): void
89
+ applyOverlay(overlay: OverlayPatch): void
83
90
  restart(): Promise<void>
84
91
  credentialStates(): Promise<{ appId: CredentialInfo; appSecret: CredentialInfo }>
85
92
  setCredential(field: CredentialField, value: string): Promise<void>
@@ -125,10 +132,17 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
125
132
  'welcomeOnboarding',
126
133
  'allowPermissionSwitch',
127
134
  'acceptAttachments',
135
+ 'prefetchImages',
128
136
  'workspaceRetentionAuto',
137
+ 'feishuTools.attachmentFetch',
138
+ 'feishuTools.topicMessages',
129
139
  ])
130
140
 
131
141
  const SELECT_OPTIONS: Partial<Record<EditableKey, ReadonlyArray<{ value: string; label: string }>>> = {
142
+ attachmentStore: [
143
+ { value: 'native', label: 'DSH 原生附件库(默认)' },
144
+ { value: 'archive', label: '工作区 attachments/(旧版)' },
145
+ ],
132
146
  dmMode: [
133
147
  { value: 'open', label: '开放私聊' },
134
148
  { value: 'disabled', label: '禁用私聊' },
@@ -185,9 +199,104 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
185
199
  max: 30,
186
200
  step: 1,
187
201
  },
188
- acceptAttachments: { label: '接收并保存附件', help: '接收飞书图片、文件与合并转发记录,保存到工作区的 attachments/ 目录;模型不会自动读取,需要时用文件工具自行查看。关闭后附件消息不保存、不触发回复。' },
189
- workspaceRetention: { label: '过期目录保留期', help: '超过所选天数没有活动的飞书话题目录(以及旧版本按话题建立的工作区目录)会进入下方清理列表;用户工作区本身永不删除。DSH 会话日志不受影响。' },
190
- workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置。' },
202
+ acceptAttachments: {
203
+ label: '接收入站附件',
204
+ help: '接收飞书图片、文件与合并转发记录。native(默认)下入站附件存入 DSH 原生附件库:触发消息的图片预取进首轮、文件保持懒取,纯附件消息静默登记元数据;archive 下按旧版行为下载到工作区 attachments/。关闭后附件消息不保存、不触发模型、也不登记。',
205
+ },
206
+ attachmentStore: {
207
+ label: '入站附件存储后端',
208
+ help: 'native(默认):图片与文件存入 DSH 原生附件库(不可变、内容寻址去重、读取校验、随 Session 回放;不自动清理)。archive:旧版下载到话题工作区 attachments/(可清理但会阻塞会话)。宿主未挂载附件服务时 native 自动降级 archive。',
209
+ },
210
+ prefetchImages: {
211
+ label: '预取触发消息的图片',
212
+ help: 'native 下把触发消息的图片预取为首轮 ImageBlock,会话/话题/卡片不等下载;失败、超限或超时的图片降级为清单中的懒取项。关闭后图片也需用 feishu_attachment_fetch 获取。',
213
+ },
214
+ prefetchTimeoutMs: {
215
+ label: '图片预取整段超时(ms)',
216
+ help: '一次触发消息图片预取的总超时(默认 10000 ms,100–120000);到期后已取回的图片保留,其余降级为清单,不阻塞回合。',
217
+ min: 100,
218
+ max: 120_000,
219
+ step: 100,
220
+ },
221
+ prefetchImageMaxBytes: {
222
+ label: '单张预取图片上限(字节)',
223
+ help: '单张图片超过该字节数即不入首轮图片、留在清单懒取(默认 20971520 = 20 MiB)。',
224
+ min: 1,
225
+ max: 200 * 1024 * 1024,
226
+ step: 1,
227
+ },
228
+ prefetchTotalMaxBytes: {
229
+ label: '单次预取总量上限(字节)',
230
+ help: '一次触发消息图片预取的总字节预算(默认 52428800 = 50 MiB);用尽后剩余图片降级为清单懒取。',
231
+ min: 1,
232
+ max: 1024 * 1024 * 1024,
233
+ step: 1,
234
+ },
235
+ historyBootstrapLimit: {
236
+ label: '首轮话题历史条数',
237
+ help: '新 Session 首轮自动附带本话题最近 N 条消息摘要(0 = 关闭,上限 20);被动读取使用短预算,缺权限/失败时静默省略。更早的消息用 feishu_topic_messages 工具翻页。',
238
+ min: 0,
239
+ max: 20,
240
+ step: 1,
241
+ },
242
+ pendingAttachmentTtlMs: {
243
+ label: '静默附件登记有效期(ms)',
244
+ help: '纯附件/未触发消息只登记元数据(不下载)。超过该时长的登记由周期 GC 清除;0 = 不按时间清理(每个 scope 仍有容量上限)。默认 604800000 = 7 天。',
245
+ min: 0,
246
+ step: 1,
247
+ },
248
+ pendingAttachmentLimit: {
249
+ label: '静默附件登记容量(每个 scope)',
250
+ help: '每个 (会话, 话题, 发送者) scope 最多保留的登记条数(1–100);超出时淘汰该 scope 最旧的一条。',
251
+ min: 1,
252
+ max: 100,
253
+ step: 1,
254
+ },
255
+ promptContextTimeoutMs: {
256
+ label: '被动读取预算(ms)',
257
+ help: '引用摘录与首轮话题历史这类被动 prompt 增强的整段预算(默认 3000 ms,100–10000);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 feishu_tools 超时控制。',
258
+ min: 100,
259
+ max: 10_000,
260
+ step: 100,
261
+ },
262
+ 'feishuTools.attachmentFetch': {
263
+ label: '启用 feishu_attachment_fetch 工具',
264
+ help: '让模型按 message_id/file_key 懒取已授权消息的图片/文件。关闭后文件无法懒取,插件整体回退 archive 后端(入站文件直接下载到工作区)。对新会话生效。',
265
+ },
266
+ 'feishuTools.attachmentFetchMaxResources': {
267
+ label: '单次取回资源数上限',
268
+ help: '一次工具调用最多取回的附件数量(默认 10,1–50);超出会返回可读原因,模型可按 file_key 拆分重试。',
269
+ min: 1,
270
+ max: 50,
271
+ step: 1,
272
+ },
273
+ 'feishuTools.attachmentFetchMaxBytes': {
274
+ label: '单次取回字节预算(字节)',
275
+ help: '一次工具调用的总下载字节预算(默认 104857600 = 100 MiB);文件流式写入原生库,不整文件缓冲。',
276
+ min: 1,
277
+ max: 1024 * 1024 * 1024,
278
+ step: 1,
279
+ },
280
+ 'feishuTools.attachmentFetchTimeoutMs': {
281
+ label: '单次取回超时(ms)',
282
+ help: '一次 feishu_attachment_fetch 的整段超时(默认 120000 ms,1000–600000);到期取消下载并逐项报告可读原因。',
283
+ min: 1_000,
284
+ max: 600_000,
285
+ step: 1_000,
286
+ },
287
+ 'feishuTools.topicMessages': {
288
+ label: '启用 feishu_topic_messages 工具',
289
+ help: '让模型按需读取引用消息与话题内更早的消息/附件。关闭只影响模型工具;被动的引用摘录与首轮历史仍由 historyBootstrapLimit 控制。',
290
+ },
291
+ 'feishuTools.topicMessagesTimeoutMs': {
292
+ label: '单次话题读取超时(ms)',
293
+ help: '一次 feishu_topic_messages 的整段超时(默认 20000 ms,1000–120000);被动摘录使用独立的短预算。',
294
+ min: 1_000,
295
+ max: 120_000,
296
+ step: 1_000,
297
+ },
298
+ workspaceRetention: { label: '过期目录保留期', help: '只清理工作区与 Agent 产物目录,不影响 Session 附件:默认 native 存储下附件在 DSH 原生附件库,不参与目录清理;仅当显式使用 archive 后端时,附件副本位于话题目录、会随目录清理。用户工作区本身永不删除,DSH 会话日志不受影响。' },
299
+ workspaceRetentionAuto: { label: '自动执行目录清理', help: '开启后插件在启动时及每天执行一次过期目录清理;关闭时只能在下方卡片手动清理。开启前请确认保留期已设置;清理只清理工作区与 Agent 产物目录,不影响 Session 附件。' },
191
300
  provider: { label: '模型服务商', help: '留空 = 使用部署默认;例如 deepseek-official。', placeholder: '留空 = 部署默认' },
192
301
  model: { label: '模型 id', help: '留空 = 使用部署默认;例如 deepseek-v4-flash。', placeholder: '留空 = 部署默认' },
193
302
  reasoningEffort: { label: '推理档位', help: '模型声明的精确档位 id(如 high / max);留空 = 适配器默认。', placeholder: '留空 = 适配器默认' },
@@ -210,6 +319,21 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
210
319
  accessMode: 'access',
211
320
  authRequestTtlDays: 'access',
212
321
  acceptAttachments: 'content',
322
+ attachmentStore: 'content',
323
+ prefetchImages: 'content',
324
+ prefetchTimeoutMs: 'content',
325
+ prefetchImageMaxBytes: 'content',
326
+ prefetchTotalMaxBytes: 'content',
327
+ historyBootstrapLimit: 'content',
328
+ pendingAttachmentTtlMs: 'content',
329
+ pendingAttachmentLimit: 'content',
330
+ promptContextTimeoutMs: 'tools',
331
+ 'feishuTools.attachmentFetch': 'tools',
332
+ 'feishuTools.attachmentFetchMaxResources': 'tools',
333
+ 'feishuTools.attachmentFetchMaxBytes': 'tools',
334
+ 'feishuTools.attachmentFetchTimeoutMs': 'tools',
335
+ 'feishuTools.topicMessages': 'tools',
336
+ 'feishuTools.topicMessagesTimeoutMs': 'tools',
213
337
  provider: 'model',
214
338
  model: 'model',
215
339
  reasoningEffort: 'model',
@@ -536,9 +660,11 @@ export class SettingsSurface {
536
660
  const effective = this.target.configSnapshot()
537
661
  const overlay = await this.store.load()
538
662
  const running = this.target.runningConfigSnapshot()
539
- const overriddenKeys = EDITABLE_KEYS.filter(key => overlay[key] !== undefined)
663
+ const overriddenKeys = EDITABLE_KEYS.filter(
664
+ key => overlayValueOf(overlay as Record<string, unknown>, key) !== undefined,
665
+ )
540
666
  const restartPendingKeys = this.target.isRunning() && running !== undefined
541
- ? RESTART_KEYS.filter(key => effective[key] !== running[key])
667
+ ? RESTART_KEYS.filter(key => configValueOf(effective, key) !== configValueOf(running, key))
542
668
  : []
543
669
  const catalog = await this.catalogFor(effective.provider, effective.model)
544
670
  const fields = withDirectoryBrowse(
@@ -611,7 +737,7 @@ export class SettingsSurface {
611
737
  config: {
612
738
  base: pickEditable(base) as Record<string, ConfigValue | undefined>,
613
739
  effective: pickEditable(effective) as Record<string, ConfigValue | undefined>,
614
- overlay: overlay as EditableValues,
740
+ overlay: overlayValues(overlay),
615
741
  overriddenKeys,
616
742
  restartPendingKeys,
617
743
  fields,
@@ -641,7 +767,7 @@ export class SettingsSurface {
641
767
  clearedKeys.push(key)
642
768
  continue
643
769
  }
644
- raw[key] = value
770
+ setOverlayValue(raw, key, value)
645
771
  }
646
772
 
647
773
  const parsed = parseOverlay(raw)
@@ -670,9 +796,8 @@ export class SettingsSurface {
670
796
 
671
797
  let merged: OverlayPatch
672
798
  try {
673
- merged = { ...await this.store.load() }
674
- for (const key of clearedKeys) delete merged[key]
675
- Object.assign(merged, parsed.patch)
799
+ merged = mergeOverlayPatch(await this.store.load(), parsed.patch)
800
+ for (const key of clearedKeys) deleteOverlayKey(merged, key)
676
801
  } catch (error) {
677
802
  return { ok: false, error: `写入覆盖配置失败:${messageOf(error)}` }
678
803
  }
@@ -681,7 +806,7 @@ export class SettingsSurface {
681
806
  // provider (or lone model) leaves the session without a complete route,
682
807
  // so an edit to either side must end with both set or both cleared.
683
808
  if (values.provider !== undefined || values.model !== undefined) {
684
- const prospective = { ...this.target.baseConfigSnapshot(), ...merged }
809
+ const prospective = mergeOverlay(this.target.baseConfigSnapshot(), merged)
685
810
  const hasProvider = (prospective.provider ?? '') !== ''
686
811
  const hasModel = (prospective.model ?? '') !== ''
687
812
  if (hasProvider !== hasModel) {
package/src/workspaces.ts CHANGED
@@ -14,9 +14,13 @@
14
14
  * those directories are kept verbatim for legacy routes and are only removed
15
15
  * by the retention cleanup, never migrated.
16
16
  *
17
- * Attachments received before a topic exists are staged below
18
- * `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
19
- * topic directory once its first Session is created.
17
+ * Attachments received before a topic exists were staged by older plugin
18
+ * versions below `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and
19
+ * adopted into the topic directory once its first Session is created. The v2
20
+ * pipeline stores inbound attachments in the DSH native attachment library and
21
+ * never writes staging, so this is a one-release compatibility path only: it is
22
+ * scheduled for removal in the release cycle after attachment_pipeline_v2 ships
23
+ * (see Roadmap decisions D12 and docs/attachment-pipeline-v2.md).
20
24
  *
21
25
  * Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
22
26
  * anyway so a hostile id can never escape `baseDir`.
@@ -173,10 +177,27 @@ export function legacyTopicWorkspaceRoot(baseDir: string): string {
173
177
  return path.join(baseDir, 'topics')
174
178
  }
175
179
 
180
+ /**
181
+ * Legacy pre-topic attachment staging root.
182
+ *
183
+ * Only older plugin versions wrote here; the v2 native pipeline never does and
184
+ * the retention cleanup never deletes it. Scheduled for removal together with
185
+ * the one-time adoption in the release cycle after attachment_pipeline_v2
186
+ * (decisions D12).
187
+ *
188
+ * @deprecated legacy archive-upload path, kept for one release cycle only.
189
+ */
190
+ export function stagingRoot(baseDir: string): string {
191
+ return path.join(baseDir, '_staging')
192
+ }
193
+
176
194
  /**
177
195
  * Staging directory for attachments that arrive before their topic (and so
178
196
  * its topic directory) exists. Not a registered workspace: files move into the
179
197
  * real topic directory as soon as the first Session of that topic is created.
198
+ *
199
+ * @deprecated v2 never writes staging; kept for one-time adoption of legacy
200
+ * files for one release cycle (decisions D12).
180
201
  */
181
202
  export function stagingPathFor(
182
203
  baseDir: string,
@@ -186,7 +207,7 @@ export function stagingPathFor(
186
207
  ): string {
187
208
  const chat = safeSegment(chatId, 'chatId')
188
209
  const user = safeSegment(senderOpenId, 'senderOpenId')
189
- return path.join(baseDir, '_staging', chatType, chat, user)
210
+ return path.join(stagingRoot(baseDir), chatType, chat, user)
190
211
  }
191
212
 
192
213
  /** Human-friendly workspace title shown in the DSH workspace list. */