@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.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.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -29,6 +29,7 @@ import { appendSessionHistory, sessionDirOf } from '../domain.ts'
29
29
  import type { RouteRecord } from '../domain.ts'
30
30
  import { mountChannelGuidance } from '../feishu/agent-context.ts'
31
31
  import { attachmentsDir } from '../feishu/inbound-attachments.ts'
32
+ import { effectiveAttachmentMode, resolveAttachmentService } from '../feishu/native-attachments.ts'
32
33
  import { installRequestPolicy } from './request-policy.ts'
33
34
  import type { RequestPolicyController } from './request-policy.ts'
34
35
  import { placementServices, restoreTopicDir } from './isolation.ts'
@@ -65,6 +66,14 @@ export interface SessionManagerDeps {
65
66
  started(): boolean
66
67
  /** Drop streamer state for a session whose owner handle is being released. */
67
68
  detachStream(sessionId: string): void
69
+ /**
70
+ * Register this channel's model tools inside one Agent scope. Called from
71
+ * the agent-preset `setup` after the preset is mounted (tools live inside
72
+ * presets in web-profile deployments), and once more for a BORROWED live
73
+ * agent as a best-effort race guard (D7). Optional: unit tests that do not
74
+ * compose model tools omit it.
75
+ */
76
+ mountTools?(agentCtx: AgentContext, sessionId: string): Promise<void> | void
68
77
  warn(message: string, ...args: unknown[]): void
69
78
  }
70
79
 
@@ -82,6 +91,8 @@ export class SessionManager {
82
91
  private readonly live = new Map<string, LiveAgent>()
83
92
  private readonly pendingResume = new Map<string, Promise<LiveAgent>>()
84
93
  private readonly policies = new WeakMap<AgentContext, RequestPolicyController>()
94
+ /** Agent scopes this plugin already attempted to mount model tools into. */
95
+ private readonly toolMounts = new WeakSet<AgentContext>()
85
96
 
86
97
  constructor(private readonly deps: SessionManagerDeps) {}
87
98
 
@@ -116,7 +127,7 @@ export class SessionManager {
116
127
  cwd: string,
117
128
  agentOptions: Record<string, unknown> = this.agentOptions(),
118
129
  ): Promise<AgentHandle> {
119
- const composition = await this.presetComposition(undefined, true)
130
+ const composition = await this.presetComposition(undefined, true, sessionId)
120
131
  const handle = await this.deps.ctx.agents.create({
121
132
  sessionId,
122
133
  meta: {
@@ -132,15 +143,31 @@ export class SessionManager {
132
143
  }
133
144
 
134
145
  /**
135
- * Register the attachment-directory guidance for one Session. Called after
136
- * the effective working directory is known (a worktree checkout path is
137
- * only returned once the worktree exists), so the prompt section can name
138
- * the absolute directory. Best-effort: a missing system-prompt service
139
- * (headless) is a no-op and a late registration never blocks the turn.
146
+ * Register the attachment guidance for one Session. Called after the
147
+ * effective working directory is known (a worktree checkout path is only
148
+ * returned once the worktree exists), so the archive guidance can name the
149
+ * absolute directory. The guidance flavor follows the *effective* attachment
150
+ * backend: an explicitly configured archive deployment, or a native
151
+ * deployment whose host does not mount `ctx.attachments`, keeps the legacy
152
+ * workspace-directory instructions. Best-effort: a missing system-prompt
153
+ * service (headless) is a no-op and a late registration never blocks the turn.
140
154
  */
141
155
  async mountGuidance(agent: Agent, directory: string): Promise<void> {
156
+ // With inbound attachments disabled the Agent has no attachment workflow to
157
+ // discover (and the legacy guidance would wrongly claim files are archived).
158
+ if (!this.deps.config.acceptAttachments) return
159
+ const mode = effectiveAttachmentMode(
160
+ this.deps.config.attachmentStore,
161
+ resolveAttachmentService(this.deps.ctx),
162
+ this.deps.config.feishuTools.attachmentFetch,
163
+ this.deps.config.acceptAttachments,
164
+ )
142
165
  try {
143
- await mountChannelGuidance(agent.ctx, attachmentsDir(directory))
166
+ await mountChannelGuidance(
167
+ agent.ctx,
168
+ mode,
169
+ mode === 'archive' ? attachmentsDir(directory) : undefined,
170
+ )
144
171
  } catch (error) {
145
172
  this.deps.warn(
146
173
  'dsh-channels: mounting attachment guidance for session %s failed: %s',
@@ -166,7 +193,7 @@ export class SessionManager {
166
193
  if (own !== undefined) return own
167
194
 
168
195
  const foreign = this.deps.ctx.agents.get(sessionId as SessionId)
169
- if (foreign !== undefined) return this.borrow(sessionId, foreign)
196
+ if (foreign !== undefined) return await this.borrow(sessionId, foreign)
170
197
 
171
198
  let pending = this.pendingResume.get(sessionId)
172
199
  if (pending === undefined) {
@@ -179,9 +206,14 @@ export class SessionManager {
179
206
  }
180
207
 
181
208
  /** Borrow one foreign live agent and mount this channel's scoped policy. */
182
- private borrow(sessionId: string, agent: Agent): LiveAgent {
209
+ private async borrow(sessionId: string, agent: Agent): Promise<LiveAgent> {
183
210
  const borrowed = borrowLiveAgent(agent, this.mountRequestPolicy(agent.ctx, agent.session))
184
211
  this.live.set(sessionId, borrowed)
212
+ // Best-effort tool backfill (D7): the foreign owner may have composed the
213
+ // session without this plugin's setup (web UI resolver, previous plugin
214
+ // instance). mountToolsFor swallows failures, so the borrow itself never
215
+ // fails; awaiting it makes the tool available before the first turn.
216
+ await this.mountToolsFor(agent.ctx, sessionId)
185
217
  return borrowed
186
218
  }
187
219
 
@@ -193,7 +225,7 @@ export class SessionManager {
193
225
  // (config override or roster default). A session persisted before the
194
226
  // preset fix has no preset recorded on its header; re-resolving here is
195
227
  // what repairs it on the first resume after a restart.
196
- const composition = await this.presetComposition(undefined, true)
228
+ const composition = await this.presetComposition(undefined, true, sessionId)
197
229
  const handle = await this.deps.ctx.agents.resume({
198
230
  resumeSessionId: sessionId,
199
231
  agentOptions: this.agentOptions(),
@@ -215,7 +247,7 @@ export class SessionManager {
215
247
  // Lost a cold-resume race: another owner published the session while our
216
248
  // resume was in flight. Borrow the winner instead of failing the turn.
217
249
  const winner = this.deps.ctx.agents.get(sessionId)
218
- if (winner !== undefined) return this.borrow(sessionId, winner)
250
+ if (winner !== undefined) return await this.borrow(sessionId, winner)
219
251
  throw error
220
252
  }
221
253
  }
@@ -255,13 +287,13 @@ export class SessionManager {
255
287
  async renew(record: RouteRecord, oldLive: LiveAgent): Promise<RenewCommit> {
256
288
  const sessionId = randomUUID() as SessionId
257
289
  const preset = this.effectivePreset(oldLive.agent.session)
258
- const composition = await this.presetComposition(preset, false)
290
+ const composition = await this.presetComposition(preset, false, sessionId)
259
291
  const services: PlacementServices = placementServices(this.deps.ctx)
260
292
  const switchable = services.workingDirectory !== undefined
261
293
  const workspacePath = record.workspacePath
262
294
  const workspace = await this.deps.ctx.workspaceRegistry.create(
263
295
  workspacePath,
264
- workspaceTitleFor(record.chatType, record.chatId, record.senderOpenId),
296
+ workspaceTitleFor(record.chatType, record.chatId, record.senderUserId),
265
297
  )
266
298
 
267
299
  let newHandle: AgentHandle | undefined
@@ -358,6 +390,7 @@ export class SessionManager {
358
390
  private async presetComposition(
359
391
  requestedPreset: string | undefined = this.deps.config.agentPreset,
360
392
  useRosterDefault = true,
393
+ sessionId?: string,
361
394
  ): Promise<PresetComposition> {
362
395
  // Read through ctx.get (safe lookup): only the web profile ships a roster,
363
396
  // and direct property access on a cordis context throws for services the
@@ -369,6 +402,7 @@ export class SessionManager {
369
402
  // cannot guarantee that the explicit channel value wins.
370
403
  this.mountRequestPolicy(agentCtx)
371
404
  await mountChannelCommands(agentCtx)
405
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
372
406
  }
373
407
  if (presets === undefined || (requestedPreset === undefined && !useRosterDefault)) {
374
408
  return { setup: channelCommandsOnly }
@@ -380,10 +414,30 @@ export class SessionManager {
380
414
  this.mountRequestPolicy(agentCtx)
381
415
  await presets.mount(agentCtx, resolved.id)
382
416
  await mountChannelCommands(agentCtx)
417
+ if (sessionId !== undefined) await this.mountToolsFor(agentCtx, sessionId)
383
418
  },
384
419
  }
385
420
  }
386
421
 
422
+ /**
423
+ * Mount the channel model tools into one Agent scope, at most once per
424
+ * scope. A failure is logged and swallowed: missing tools never fail a
425
+ * session (attachment fetch degrades to its structured failures).
426
+ */
427
+ private async mountToolsFor(agentCtx: AgentContext, sessionId: string): Promise<void> {
428
+ if (this.deps.mountTools === undefined) return
429
+ if (this.toolMounts.has(agentCtx)) return
430
+ try {
431
+ await this.deps.mountTools(agentCtx, sessionId)
432
+ this.toolMounts.add(agentCtx)
433
+ } catch (error) {
434
+ this.deps.warn(
435
+ 'dsh-channels: mounting channel tools for session %s failed: %s',
436
+ sessionId, String(error),
437
+ )
438
+ }
439
+ }
440
+
387
441
  /** Effective preset after folding any durable runtime preset switch. */
388
442
  private effectivePreset(session: Session): string | undefined {
389
443
  let selected = session.header.agentPreset
@@ -135,8 +135,7 @@ export class RouteStore {
135
135
 
136
136
  /**
137
137
  * Retire one active thread route into a persistent tombstone regardless of
138
- * the configured TTL. Used by the workspace-retention cleanup, which may
139
- * close topics earlier than the route TTL.
138
+ * the configured TTL.
140
139
  */
141
140
  async retire(key: string, now = Date.now()): Promise<ExpiredRouteRecord | undefined> {
142
141
  const record = this.get(key)
@@ -145,7 +144,7 @@ export class RouteStore {
145
144
  key,
146
145
  chatId: record.chatId,
147
146
  chatType: record.chatType,
148
- senderOpenId: record.senderOpenId,
147
+ senderUserId: record.senderUserId,
149
148
  expiredAt: now,
150
149
  workspacePath: record.workspacePath,
151
150
  ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
@@ -20,13 +20,11 @@ export const CREDENTIALS_ROUTE = '/dsh-channels/credentials'
20
20
  export const RECONNECT_ROUTE = '/dsh-channels/reconnect'
21
21
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
22
22
  export const CATALOG_ROUTE = '/dsh-channels/catalog'
23
- /** 手动执行一次过期目录清理(POST,无请求体)。 */
24
- export const CLEANUP_ROUTE = '/dsh-channels/cleanup'
25
23
  /** 访问与授权只读快照(GET/HEAD):准入模式、管理员、群/用户白名单、待处理申请。 */
26
24
  export const ACCESS_ROUTE = '/dsh-channels/access'
27
25
  /** 群白名单增删(POST/DELETE,body `{ chatId, name? }` / `{ chatId }`)。 */
28
26
  export const ACCESS_GROUPS_ROUTE = '/dsh-channels/access/groups'
29
- /** 用户白名单增删(POST/DELETE,body `{ openId, name? }` / `{ openId }`)。 */
27
+ /** 用户白名单增删(POST/DELETE,body `{ userId, name? }` / `{ userId }`)。 */
30
28
  export const ACCESS_USERS_ROUTE = '/dsh-channels/access/users'
31
29
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
32
30
  export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
@@ -40,9 +38,10 @@ export const ACCESS_REQUESTS_ROUTE = '/dsh-channels/access/requests'
40
38
  export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
41
39
 
42
40
  /**
43
- * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
44
- * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
45
- * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
41
+ * 可由 Web 面板覆盖的配置项。顶层键对应 `src/config.ts` 的字段;`feishuTools.*`
42
+ * 是嵌套字段(覆盖层里仍写成 `{ "feishuTools": { ... } }` 对象)。覆盖值写进
43
+ * `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile 的
44
+ * `cordis.patch.yml`(见 README「Web 设置面板」)。
46
45
  *
47
46
  * 刻意不包含 `appId` / `appSecret`:它们是**凭据引用名**,面板改为直接
48
47
  * 编辑引用背后的值(`PUT /dsh-channels/credentials`,经 DSH 凭据服务写入),
@@ -55,20 +54,34 @@ export const PICK_DIRECTORY_ROUTE = '/dsh-channels/pick-directory'
55
54
  export const EDITABLE_KEYS = [
56
55
  // 工作区
57
56
  'baseDir',
58
- 'workspaceRetention',
59
- 'workspaceRetentionAuto',
60
57
  // 行为
61
58
  'dmMode',
62
59
  'requireMention',
63
60
  'threadFallback',
64
61
  'welcomeOnboarding',
62
+ 'slashCommandPanel',
65
63
  'allowPermissionSwitch',
66
64
  // 准入与授权(管理员/准入模式/申请有效期;白名单由「访问与授权」卡片管理)
67
- 'adminOpenId',
65
+ 'adminUserId',
68
66
  'accessMode',
69
67
  'authRequestTtlDays',
70
- // 内容
68
+ // 内容与附件(v2 管线;`feishuTools.*` 是嵌套配置,覆盖层仍写成嵌套对象)
71
69
  'acceptAttachments',
70
+ 'attachmentStore',
71
+ 'prefetchImages',
72
+ 'prefetchTimeoutMs',
73
+ 'prefetchImageMaxBytes',
74
+ 'prefetchTotalMaxBytes',
75
+ 'historyBootstrapLimit',
76
+ // 模型工具(feishuTools 子项)
77
+ 'feishuTools.attachmentFetch',
78
+ 'feishuTools.attachmentFetchMaxResources',
79
+ 'feishuTools.attachmentFetchMaxBytes',
80
+ 'feishuTools.attachmentFetchTimeoutMs',
81
+ 'feishuTools.topicMessages',
82
+ 'feishuTools.topicMessagesTimeoutMs',
83
+ 'feishuTools.sendFile',
84
+ 'promptContextTimeoutMs',
72
85
  // 模型
73
86
  'provider',
74
87
  'model',
@@ -89,6 +102,7 @@ export type EditableKey = (typeof EDITABLE_KEYS)[number]
89
102
  export const RESTART_KEYS = [
90
103
  'dmMode',
91
104
  'requireMention',
105
+ 'slashCommandPanel',
92
106
  'refreshMs',
93
107
  'statusRefreshMs',
94
108
  'loggerLevel',
@@ -104,7 +118,7 @@ export type FieldKind = 'boolean' | 'number' | 'text' | 'select'
104
118
  * panel tab (see {@link TAB_FIELD_GROUPS}); the Host is the source of truth for
105
119
  * which key belongs to which group.
106
120
  */
107
- export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'model' | 'streaming' | 'access'
121
+ export type FieldGroup = 'workspace' | 'behavior' | 'content' | 'tools' | 'model' | 'streaming' | 'access'
108
122
 
109
123
  /** One selectable value rendered by the generic form. */
110
124
  export interface FieldOptionWire {
@@ -249,8 +263,40 @@ export interface ChannelStatusWire {
249
263
  overlayPath: string
250
264
  /** 正在连接或重连时为 true,此时面板的写操作按钮禁用。 */
251
265
  busy: boolean
266
+ /** 连接后的权限自检结果;未连接或自检尚未完成时缺省。 */
267
+ scopeCheck?: ScopeCheckWire
268
+ /** 连接后飞书原生斜杠指令面板的同步结果;未连接或同步尚未完成时缺省。 */
269
+ slashCommandSync?: SlashCommandSyncWire
252
270
  }
253
271
 
272
+ /** One plugin need whose scopes are all missing. */
273
+ export interface MissingScopeWire {
274
+ /** Scope names, preferred first; granting any one satisfies the need. */
275
+ scopes: string[]
276
+ /** `required` breaks a core path; `optional` only degrades a feature. */
277
+ level: 'required' | 'optional'
278
+ /** What the plugin loses without it. */
279
+ purpose: string
280
+ /** Developer-console permission page filtered to the preferred scope. */
281
+ consoleUrl: string
282
+ }
283
+
284
+ /** Post-connect scope self-check outcome. */
285
+ export type ScopeCheckWire =
286
+ /** Every applicable need is granted. */
287
+ | { state: 'ok'; checkedAt: number }
288
+ /** Some needs are missing; `consoleUrl` opens the console filtered to all of them. */
289
+ | { state: 'missing'; checkedAt: number; missing: MissingScopeWire[]; consoleUrl: string }
290
+ /** The grant list could not be read; `error` carries the Feishu reason. */
291
+ | { state: 'error'; checkedAt: number; error: string }
292
+
293
+ /** Post-connect native slash-command panel sync outcome. */
294
+ export type SlashCommandSyncWire =
295
+ /** The panel lists exactly the registry commands; counts are this sync's writes. */
296
+ | { state: 'ok'; checkedAt: number; created: number; updated: number; removed: number; total: number }
297
+ /** A Feishu call failed; `error` carries the Feishu reason. */
298
+ | { state: 'error'; checkedAt: number; error: string }
299
+
254
300
  /** The whole editable configuration surface, with provenance. */
255
301
  export interface PanelConfigWire {
256
302
  /** profile `cordis.patch.yml` 提供的值(覆盖层之下)。 */
@@ -281,9 +327,9 @@ export interface AccessGroupWire {
281
327
  addedAt: number
282
328
  }
283
329
 
284
- /** One authorized user `open_id` (global across every authorized group). */
330
+ /** One authorized user `user_id` (global across every authorized group). */
285
331
  export interface AccessUserWire {
286
- openId: string
332
+ userId: string
287
333
  /** Display name captured at authorization time. */
288
334
  name?: string
289
335
  /** Which authorization entry produced the grant. */
@@ -297,7 +343,7 @@ export interface AccessRequestWire {
297
343
  /** Opaque token; never derived from the target id. */
298
344
  token: string
299
345
  kind: 'group' | 'user'
300
- /** `chat_id` for a group request, `open_id` for a user request. */
346
+ /** `chat_id` for a group request, `user_id` for a user request. */
301
347
  targetId: string
302
348
  /** Display name of the requested target (group name / user name), when known. */
303
349
  name?: string
@@ -320,9 +366,14 @@ export interface AccessRequestWire {
320
366
  export interface AccessWire {
321
367
  mode: 'open' | 'guarded'
322
368
  admin: {
323
- openId?: string
369
+ /** The effective administrator: configured `adminUserId`, else the app owner. */
370
+ userId?: string
371
+ /** Where `userId` came from: explicit config or the app-owner fallback. */
372
+ source?: 'config' | 'owner'
324
373
  /** false while guarded mode would refuse every conversation (fail closed). */
325
374
  configured: boolean
375
+ /** Why the app-owner fallback is unavailable, when its lookup failed. */
376
+ ownerLookupError?: string
326
377
  }
327
378
  /**
328
379
  * Whether the persisted whitelists were loaded. While the channel holds no
@@ -344,51 +395,10 @@ export type AccessMutationResponse =
344
395
  export interface PanelWire {
345
396
  status: ChannelStatusWire
346
397
  config: PanelConfigWire
347
- cleanup: CleanupPreviewWire
348
398
  /** 访问与授权卡片的数据(准入模式、管理员、白名单、待处理申请)。 */
349
399
  access: AccessWire
350
400
  }
351
401
 
352
- /**
353
- * What the workspace-retention cleanup would delete right now. Counts are
354
- * zero and `error` is set when the Host cannot compute the preview (for
355
- * example the cleanup seam is unavailable); the panel stays usable.
356
- */
357
- export interface CleanupPreviewWire {
358
- /** Effective `workspaceRetention` value (`off` / `7d` / `30d` / `90d` / `180d`). */
359
- retention: string
360
- /** Retention threshold in milliseconds; absent when `retention` is `off`. */
361
- thresholdMs?: number
362
- /** Periodic cleanup runs at startup and once per day. */
363
- auto: boolean
364
- /** Plugin-created workspaces under the current baseDir (user projects). */
365
- workspaces: number
366
- /** Directories older than the threshold (legacy topic workspaces + per-topic dirs). */
367
- candidates: number
368
- /** Total bytes currently occupied by the candidates. */
369
- candidateBytes: number
370
- /** Why the preview could not be computed (counts are zero then). */
371
- error?: string
372
- }
373
-
374
- /** Outcome of one manual cleanup run (partial failures do not abort the run). */
375
- export interface CleanupRunWire {
376
- ok: boolean
377
- deletedWorkspaces: number
378
- /** Per-topic working directories removed (current layout). */
379
- deletedSessionDirs: number
380
- retiredRoutes: number
381
- freedBytes: number
382
- /** One `path: message` line per directory that could not be deleted. */
383
- failures: string[]
384
- error?: string
385
- }
386
-
387
- /** `POST /dsh-channels/cleanup` response. */
388
- export type CleanupResponse =
389
- | { ok: true; panel: PanelWire; result: CleanupRunWire }
390
- | { ok: false; error: string }
391
-
392
402
  /**
393
403
  * `POST /dsh-channels/pick-directory` response. `canceled` is a normal
394
404
  * outcome (the operator dismissed the OS dialog), not an error; `path` is an
@@ -486,7 +496,7 @@ export type PanelTabId = (typeof PANEL_TABS)[number]['id']
486
496
  /** Which editable field groups each tab renders (the tabs own one or two). */
487
497
  export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]>> = {
488
498
  connection: [],
489
- sessions: ['behavior', 'content'],
499
+ sessions: ['behavior', 'content', 'tools'],
490
500
  model: ['model'],
491
501
  workspace: ['workspace'],
492
502
  access: ['access'],
@@ -498,6 +508,7 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
498
508
  workspace: '工作区与文件',
499
509
  behavior: '行为',
500
510
  content: '内容与附件',
511
+ tools: '模型工具',
501
512
  model: '模型与推理',
502
513
  streaming: '流式卡片与日志',
503
514
  access: '准入与授权',
@@ -522,6 +533,21 @@ export const PANEL_COPY = {
522
533
  statusSummaryConnecting: '正在连接飞书长连接…',
523
534
  statusSummaryError: '最近一次连接飞书失败,请检查凭据与网络后重试。',
524
535
  lastError: '最近一次连接失败',
536
+ scopeCheckTitle: '权限自检',
537
+ scopeCheckOk: '已开通插件需要的全部飞书权限。',
538
+ scopeCheckMissing: '以下飞书权限未开通,开通后需在开发者后台发布新版本:',
539
+ scopeCheckOpenAll: '在开发者后台开通全部缺失权限',
540
+ scopeCheckOpenOne: '去开通',
541
+ scopeCheckOptional: '可选',
542
+ scopeCheckRequired: '必需',
543
+ scopeCheckError: '无法读取应用的权限授予状态:',
544
+ slashCommandSyncTitle: '斜杠指令面板',
545
+ slashCommandSyncOk: '插件命令已同步到飞书原生斜杠指令面板,在聊天框输入 / 即可选择(飞书客户端约 5 分钟后刷新)。',
546
+ slashCommandSyncTotal: '命令数',
547
+ slashCommandSyncCreated: '本次新增',
548
+ slashCommandSyncUpdated: '本次更新',
549
+ slashCommandSyncRemoved: '本次删除',
550
+ slashCommandSyncError: '同步飞书原生斜杠指令面板失败(不影响直接输入命令):',
525
551
  missingConfig: '缺少配置',
526
552
  statusHintUnconfigured: '配置完成后插件会自动连接;也可以稍后在本页手动连接。',
527
553
  connectionInfo: '连接信息',
@@ -570,9 +596,9 @@ export const PANEL_COPY = {
570
596
  credentialsPendingHint: '填写后点「保存凭据」;两项齐全时插件会自动连接飞书。',
571
597
  // ---- tabs / forms -------------------------------------------------------
572
598
  connectionTabHelp: '连接状态与飞书应用凭据。凭据不完整时不会连接飞书。',
573
- sessionsTabHelp: '私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。',
599
+ sessionsTabHelp: '私聊、群聊触发方式与入站内容:附件存储/预取、模型工具开关与读取预算;「需重连」项在下次连接时读取。',
574
600
  modelTabHelp: '飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。',
575
- workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。',
601
+ workspaceTabHelp: '每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录。插件不会自动删除工作区或话题目录;过期目录可手动删除,话题下次对话时会重建空目录。',
576
602
  accessTabHelp: '准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。',
577
603
  advancedTabHelp: '流式刷新等低层参数、Web 覆盖文件与只读信息。',
578
604
  save: '保存更改',
@@ -619,37 +645,12 @@ export const PANEL_COPY = {
619
645
  advancedHelp: '这些值来自 profile 配置,不能在面板里编辑。',
620
646
  readOnlyCard: '部署级参数',
621
647
  // ---- model --------------------------------------------------------------
622
- // ---- cleanup ------------------------------------------------------------
623
- cleanupCard: '目录清理',
624
- cleanupCardHelp: '删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。',
625
- cleanupRetention: '保留期',
626
- cleanupAutoLabel: '自动清理',
627
- cleanupCandidates: '可清理目录',
628
- cleanupWorkspaces: '插件工作区',
629
- cleanupCandidateBytes: '预计释放空间',
630
- cleanupRetentionOff: '关闭(不删除)',
631
- cleanupAutoOn: '已开启(启动时 + 每天一次)',
632
- cleanupAutoOff: '已关闭(仅手动执行)',
633
- cleanupAutoInactive: '未执行(保留期为关闭)',
634
- cleanupNow: '立即清理',
635
- cleanupRunning: '清理中…',
636
- cleanupConfirm: '确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。',
637
- cleanupConfirmHint: '删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。',
638
- cleanupDisabledOff: '保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。',
639
- cleanupDisabledEmpty: '没有超过保留期的过期目录。',
640
- cleanupPreviewFailed: '无法读取清理预览',
641
- cleanupDone: '清理完成',
642
- cleanupFailed: '清理失败',
643
- cleanupDeleted: '已删除工作区',
644
- cleanupDeletedDirs: '话题目录',
645
- cleanupRetired: '已清理话题路由',
646
- cleanupFreed: '释放空间',
647
- cleanupFailures: '以下目录未能删除:',
648
648
  // ---- access -------------------------------------------------------------
649
649
  accessCard: '访问与授权',
650
- accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。',
650
+ accessCardHelp: '准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 user_id。',
651
651
  accessModeOpenWarning: '当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。',
652
- accessAdminMissingWarning: '当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 open_id。',
652
+ accessAdminMissingWarning: '当前准入=guarded 但未配置管理员,也无法自动使用应用所有者,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 user_id(私聊机器人发送 /userid 获取;私聊模式关闭时 /userid 不可用,可在飞书管理后台的成员详情里查看用户 ID)。',
653
+ accessAdminOwnerNotice: '未配置管理员,已自动使用应用所有者作为管理员:',
653
654
  accessGroupsTitle: '已授权群',
654
655
  accessGroupsEmpty: '尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。',
655
656
  accessUsersTitle: '已授权用户',
@@ -658,7 +659,7 @@ export const PANEL_COPY = {
658
659
  accessRequestsEmpty: '没有待处理的授权申请。',
659
660
  accessGroupChatIdPlaceholder: '群 chat_id(oc_…)',
660
661
  accessGroupNamePlaceholder: '群名(可选)',
661
- accessUserOpenIdPlaceholder: '用户 open_id(ou_…)',
662
+ accessUserIdPlaceholder: '用户 user_id',
662
663
  accessUserNamePlaceholder: '昵称(可选)',
663
664
  accessAddGroup: '添加群',
664
665
  accessAddUser: '添加用户',
@@ -680,5 +681,5 @@ export const PANEL_COPY = {
680
681
  accessRequestAllowed: '已授权',
681
682
  accessRequestDenied: '已忽略',
682
683
  accessHints: '三种授权入口:群内 /auth 命令、入群与用户授权卡、本面板。撤销立即生效;白名单只约束非管理员(管理员在任何私聊/群聊都直接放行),非管理员需要群和用户两层同时命中。',
683
- accessChatIdHint: 'chat_id 可从入群群信息卡或群内 /groupid 获取;open_id 可私聊机器人发送 /userid 获取。',
684
+ accessChatIdHint: 'chat_id 可从入群群信息卡或群内 /groupid 获取;user_id 可私聊机器人发送 /userid 获取。',
684
685
  } as const