@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.
Files changed (46) hide show
  1. package/README.md +115 -39
  2. package/docs/access-control-design.md +1 -1
  3. package/docs/attachment-pipeline-v2.md +318 -0
  4. package/docs/feasibility-and-design.md +54 -26
  5. package/docs/feishu-onboarding.md +123 -15
  6. package/docs/persistence.md +4 -4
  7. package/docs/refactor-plan.md +7 -6
  8. package/docs/workspace-isolation-design.md +81 -0
  9. package/lib/client.js +30 -15
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +11008 -5132
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/cards/index.ts +1 -0
  15. package/src/cards/onboarding.ts +23 -0
  16. package/src/cards/primitives.ts +20 -0
  17. package/src/cards/streaming.ts +310 -291
  18. package/src/cleanup.ts +267 -119
  19. package/src/client/panel/cleanup-card.tsx +4 -2
  20. package/src/client/panel/workspace-tab.tsx +1 -1
  21. package/src/config.ts +221 -13
  22. package/src/domain.ts +141 -3
  23. package/src/feishu/agent-context.ts +66 -16
  24. package/src/feishu/inbound-attachments.ts +440 -3
  25. package/src/feishu/manifest.ts +270 -0
  26. package/src/feishu/message-read.ts +1333 -0
  27. package/src/feishu/native-attachments.ts +998 -0
  28. package/src/feishu/pending-attachments.ts +562 -0
  29. package/src/feishu/tools.ts +2345 -0
  30. package/src/feishu-channel.ts +55 -0
  31. package/src/host/agent.ts +55 -0
  32. package/src/host/attachments.ts +210 -0
  33. package/src/host/index.ts +1 -0
  34. package/src/index.ts +8 -6
  35. package/src/routing/inbound.ts +730 -81
  36. package/src/service.ts +536 -50
  37. package/src/session/isolation.ts +277 -0
  38. package/src/session/manager.ts +120 -23
  39. package/src/session/repair.ts +2 -3
  40. package/src/session/routes.ts +2 -0
  41. package/src/session-commands.ts +41 -14
  42. package/src/settings/contract.ts +43 -21
  43. package/src/settings/overlay.ts +124 -13
  44. package/src/settings/surface.ts +147 -14
  45. package/src/streamer.ts +70 -30
  46. package/src/workspaces.ts +74 -31
@@ -1,6 +1,6 @@
1
1
  # dsh-channels · 真机联调手册
2
2
 
3
- 本文档指导你**手动安装** dsh-channels 插件到本机 dsh,配置飞书机器人,并逐项验证私聊/群聊/话题群三条通道的流式卡片体验。所有步骤均已在本机 dsh checkout(web profile)上实测通过。
3
+ 本文档指导你**手动安装** dsh-channels 插件到本机 dsh,配置飞书机器人,并逐项验证私聊/群聊/话题群三条通道的流式卡片体验。基础安装、连接与三通道流式体验均已在本机 dsh checkout(web profile)上实测通过;§5.4 中标注「待手测」的附件管线项尚需在真实环境确认(自动测试覆盖的项已单独注明)。
4
4
 
5
5
  > 约定:下文用 `$HARNESS` 表示你的 deepseek-harness 仓库目录,`$PLUGIN` 表示本仓库目录(dsh_channels)。
6
6
 
@@ -17,13 +17,19 @@
17
17
  3. **权限管理**,开通以下权限(发布版本时生效):
18
18
  | 权限 | 用途 |
19
19
  |------|------|
20
- | `im:message`(获取与发送单聊、群组消息) | 收发消息、回复话题 |
20
+ | `im:message`(获取与发送单聊、群组消息) | 收发消息、回复话题、下载消息内图片/文件资源(`messageResource.get`,具体 scope 以控制台该接口要求为准) |
21
+ | `im:message:readonly`(读取单聊、群组消息内容) | 引用摘录、话题历史、`feishu_topic_messages` 的消息读取(message.get / mget / list),以及附件资源定位与合并转发子资源读取 |
21
22
  | `im:message.p2p_msg:readonly`(读取用户发给机器人的单聊消息) | 私聊通道 |
23
+ | `im:message.group_msg`(读取群组中所有消息,**敏感权限**) | 读取群/话题中机器人入群前或未 @ 机器人的历史消息;控制台要求时申请并由企业管理员审批,未开通时按“机器人可见范围”降级 |
22
24
  | `im:message.reactions:write_only`(发送、删除消息表情回复) | Typing / DONE / No 表态 |
23
25
  | `im:message:update`(更新消息卡片) | 流式卡片刷新(patch) |
24
26
  | `im:chat:readonly` 一类(获取群信息) | 入群通知卡展示群名/成员数(具体 scope 以控制台要求为准) |
25
27
 
26
28
  > 卡片更新接口本身支持 `im:message` 一项即可,但建议把 `im:message:update` 也加上,避免组合差异。
29
+ >
30
+ > **入站附件(v2)的权限**:图片/文件下载与合并转发容器子资源读取走消息资源接口,随消息收发/读取 scope(`im:message`,必要时 `im:message:readonly`;以控制台该接口的要求为准)。缺下载权限时不会静默丢文件:`feishu_attachment_fetch` 逐资源返回可读失败(如 `DOWNLOAD_FAILED` / 403 文案),archive 后端则加 `No` 表情并回一条纯文字失败提示。**native 主路径(默认)不新增附件专用权限**——下载仍走上面的消息资源 scope,存入 DSH 原生附件库后由宿主管理。
31
+ >
32
+ > **引用与话题历史(P2)的权限降级**:`feishu_topic_messages`、引用摘录、新会话首轮话题历史都依赖消息读取 scope。若权限未随版本发布或 `im:message.group_msg` 审批未通过,普通对话**不受影响**:引用/历史块静默省略,模型仍会正常回答;显式调用 `feishu_topic_messages` 时返回可读的 `PERMISSION_DENIED`(缺失 `im:message:readonly` 一类权限),不会报死错。被动引用/历史读取使用 **默认 3 秒**的短预算(`promptContextTimeoutMs`,不复用工具超时),首次降级会输出一条 **warn** 级日志(含原因与 scope 提示,之后同类降级仅 debug 级;`/status` 不报告读取 scope 状态)。控制台若提示“读取群消息需要敏感权限”,先申请并审批 `im:message.group_msg` 后再验证入群前历史。
27
33
  4. **事件与回调** → 事件订阅方式选择 **使用长连接接收事件**(WebSocket,免公网 IP)。
28
34
  - 添加事件:**接收消息 im.message.receive_v1**(勾选“接收群聊中 @ 机器人消息”)。
29
35
  - 添加事件:**机器人进群 im.chat.member.bot.added_v1**(机器人被拉进群后私聊管理员发群信息卡;未订阅时退化为“群里第一次未授权 @ 时补发”)。
@@ -106,6 +112,24 @@ chmod 600 $DSH_HOME/.credentials.yaml
106
112
  requireMention: true # 普通群需 @ 机器人
107
113
  dmMode: 'open' # 私聊通道开关
108
114
  threadFallback: 'degrade' # 话题被拒时降级为会话级路由
115
+ topicIsolation: 'auto' # 话题目录隔离:auto(Git 仓库用 worktree,否则子目录)/ subdir / worktree
116
+ acceptAttachments: true # 接收飞书附件:native 下图片预取、文件懒取、纯附件静默登记
117
+ attachmentStore: 'native' # 入站附件后端:native(DSH 原生附件库)/ archive(旧版工作区 attachments/)
118
+ prefetchImages: true # 触发消息图片预取为首轮 ImageBlock
119
+ prefetchImageMaxBytes: 20971520 # 单张预取图片上限(20 MiB)
120
+ prefetchTotalMaxBytes: 52428800 # 单次预取总字节预算(50 MiB)
121
+ prefetchTimeoutMs: 10000 # 图片预取整段超时(10 s)
122
+ historyBootstrapLimit: 10 # 新会话首轮话题历史条数(0 关闭,上限 20)
123
+ promptContextTimeoutMs: 3000 # 被动引用/历史读取预算(3 s)
124
+ pendingAttachmentTtlMs: 604800000 # 静默登记保留期(7 天;0 = 不按时间清理)
125
+ pendingAttachmentLimit: 10 # 每个 (会话,话题,发送者) scope 的静默登记容量
126
+ feishuTools: # 模型工具(Web 面板按子键合并,不写空未改子键)
127
+ attachmentFetch: true # feishu_attachment_fetch:按需懒取已知消息的图片/文件
128
+ attachmentFetchMaxResources: 10 # 单次最多取回资源数(1–50)
129
+ attachmentFetchMaxBytes: 104857600 # 单次总字节预算(100 MiB)
130
+ attachmentFetchTimeoutMs: 120000 # 单次调用整段超时(120 s)
131
+ topicMessages: true # feishu_topic_messages:读取引用/话题历史并授权附件
132
+ topicMessagesTimeoutMs: 20000 # 单次话题读取整段超时(20 s)
109
133
  approvalTimeoutMs: 300000 # HITL 审批决策卡等待时长(默认 5 分钟)
110
134
  accessMode: 'guarded' # 准入模式:guarded(默认)| open(旧行为,不安全)
111
135
  adminOpenId: 'ou_xxx' # 管理员 open_id(可留空,留空时 guarded 下拒绝一切对话)
@@ -118,7 +142,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
118
142
  | 配置项 | 默认 | 说明 |
119
143
  |--------|------|------|
120
144
  | `appId` / `appSecret` | `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 凭据引用名(shell 标识符),经 `ctx.credentials` 解析;仅当凭据库使用其它变量名时才需要覆盖。明文密钥会被拒绝 |
121
- | `baseDir` | `./channels-data` | 工作区目录树根:每个话题一个独立工作区,`topics/p2p/<open_id>/<topic>/`、`topics/groups/<chat_id>/<open_id>/<topic>/`(模型/工具产生文件的落点);Web 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的话题 |
145
+ | `baseDir` | `./channels-data` | 工作区目录树根:每个用户一个工作区(私聊 `p2p/<open_id>/`,群聊 `groups/<chat_id>/<open_id>/`),每个话题的工作目录为 `<工作区>/topics/<话题>/`(模型/工具产生文件的落点);Web 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的会话 |
122
146
  | `provider` / `model` | 部署默认 | 新建 session 的路由;省略时继承部署默认 |
123
147
  | `reasoningEffort` | 适配器/provider 默认 | Session 初始/重置推理档位;可在话题内用 `/effort` 覆盖 |
124
148
  | `maxTokens` | 适配器默认 | 每次模型请求输出上限 |
@@ -126,12 +150,31 @@ chmod 600 $DSH_HOME/.credentials.yaml
126
150
  | `dmMode` | `open` | 私聊开关 |
127
151
  | `requireMention` | `true` | 普通群是否需要 @ 机器人(话题内续聊不受影响) |
128
152
  | `threadFallback` | `degrade` | 话题回复被拒时:`degrade` 降级为会话级延续 / `strict` 直接失败 |
153
+ | `topicIsolation` | `auto` | 话题工作目录形式:`auto`(工作区含 `.git` 时用 Git worktree,否则普通子目录)/ `subdir` / `worktree`(失败回退子目录) |
129
154
  | `approvalTimeoutMs` | `300000` | HITL 审批决策卡等待时长(10s–1h);超时自动拒绝 |
130
155
  | `accessMode` | `guarded` | 准入模式:`guarded` 仅管理员+白名单;`open` 保留旧行为(任何可用范围用户可对话,不安全) |
131
156
  | `adminOpenId` | 未设置 | 唯一管理员 open_id(`ou_` 开头);留空 + guarded = 拒绝一切对话(`/userid` 引导仍可用)。面板可改 |
132
157
  | `authRequestTtlDays` | `7` | 入群/用户授权申请卡有效期(1–30 天) |
158
+ | `acceptAttachments` | `true` | 接收飞书图片/文件/合并转发记录。native 下触发消息图片预取、文件懒取、纯附件静默登记;`false` 时附件消息完全惰性(不保存、不触发、不登记) |
159
+ | `attachmentStore` | `native` | 入站附件后端:`native`(DSH 原生附件库,默认;不可变、内容寻址去重、随 Session 回放、不自动清理)/ `archive`(旧版下载到话题工作区 `attachments/`,会阻塞会话建立) |
160
+ | `prefetchImages` | `true` | native 下把触发消息图片预取为首轮 `ImageBlock`;失败/超限/超时降级为清单懒取项。关闭后图片也需 `feishu_attachment_fetch` |
161
+ | `prefetchImageMaxBytes` | `20971520` | 单张预取图片字节上限(20 MiB,1 B–200 MiB) |
162
+ | `prefetchTotalMaxBytes` | `52428800` | 一次触发消息预取的总字节预算(50 MiB,1 B–1 GiB);用尽后剩余图片降级清单 |
163
+ | `prefetchTimeoutMs` | `10000` | 图片预取整段超时(100–120000 ms);到期保留已取回图片、其余降级清单。注意配置键是 `prefetchTimeoutMs`(早期 Issue 草稿写作 `prefetchImageTimeoutMs`) |
164
+ | `pendingAttachmentTtlMs` | `604800000` | 静默登记元数据保留期(7 天,≥0;`0` = 不按时间清理,每 scope 容量上限仍生效) |
165
+ | `pendingAttachmentLimit` | `10` | 每个 `(会话, 话题, 发送者)` scope 的静默登记容量(1–100);超出淘汰该 scope 最旧记录 |
166
+ | `historyBootstrapLimit` | `10` | 新会话首轮自动附带本话题最近 N 条消息摘要(0 关闭,上限 20;失败静默降级);更早的消息用 `feishu_topic_messages` 翻页 |
167
+ | `promptContextTimeoutMs` | `3000` | 被动引用/首轮历史读取的整段预算(100–10000 ms);超时即省略对应块、回合照跑。模型主动调用工具的读取超时另由 `feishuTools.topicMessagesTimeoutMs` 控制 |
168
+ | `feishuTools.attachmentFetch` | `true` | 是否挂载 `feishu_attachment_fetch`;关闭后文件无法懒取,插件**整体回退 archive 后端** |
169
+ | `feishuTools.attachmentFetchMaxResources` | `10` | 单次工具调用最多取回的附件数(1–50);超出返回可读原因,可按 `file_key` 拆分重试 |
170
+ | `feishuTools.attachmentFetchMaxBytes` | `104857600` | 单次工具调用的总下载字节预算(100 MiB,1 B–1 GiB);文件流式写入、不整文件缓冲 |
171
+ | `feishuTools.attachmentFetchTimeoutMs` | `120000` | 单次 `feishu_attachment_fetch` 整段超时(1000–600000 ms);到期取消并逐项报告 |
172
+ | `feishuTools.topicMessages` | `true` | 是否挂载 `feishu_topic_messages`;只影响模型工具,被动引用/历史摘录仍生效 |
173
+ | `feishuTools.topicMessagesTimeoutMs` | `20000` | 单次 `feishu_topic_messages` 整段超时(1000–120000 ms) |
133
174
  | `loggerLevel` | `info` | 官方 SDK 日志级别 |
134
175
 
176
+ > **面板与配置位置**:v2 附件配置已加入 Web 面板「设置 → 飞书渠道 → 会话与行为」:`acceptAttachments` / `attachmentStore` / `prefetch*` / `pendingAttachment*` / `historyBootstrapLimit` 在「内容与附件」组,`feishuTools.*` 与 `promptContextTimeoutMs` 在「模型工具」组;保存写入同一 Web 覆盖文件,`feishuTools.*` 按子键合并(不会把未修改子键写空)。直接写 profile 补丁层同样生效,面板覆盖优先。其中 `feishuTools.topicMessages=false` 只关闭模型工具,被动的引用/历史 prompt 摘录仍生效;要完全关闭首轮历史请设 `historyBootstrapLimit: 0`。archive 后端下 `feishu_attachment_fetch` 不挂载,`feishu_topic_messages` 仍可读取历史文本,但工具描述会提示附件不可取。
177
+
135
178
  ### 3.3 初始化管理员与授权(默认准入)
136
179
 
137
180
  插件默认 `accessMode: 'guarded'`:**没有配置管理员时,所有对话都会被拒绝**(这是 fail-closed 的安全默认值)。初始化顺序:
@@ -177,6 +220,16 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
177
220
 
178
221
  > 第二行只在宿主挂载了 `approval` 服务时出现;若显示 `主机未挂载 approval 服务,HITL 审批应答关闭`,说明该部署未启用 HITL(`approval/request` 事件永远不会派发),并非插件故障。
179
222
 
223
+ **附件管线启动告警**(只影响附件,不影响对话):
224
+
225
+ | 日志 | 含义 |
226
+ |------|------|
227
+ | `attachmentStore=native 但宿主未挂载 ctx.attachments,入站附件回退话题 attachments/ 工作区落盘` | 宿主没有 DSH 原生附件库:入站附件按旧 archive 行为处理;如需 native 请确认 web profile 启用 `dsh-attachment-local` |
228
+ | `feishu_attachment_fetch 未挂载:宿主 tools/attachments 服务不可用(会话继续)` | 缺 `tools` 或 `attachments` 服务:该工具不注册,会话照常建立 |
229
+ | `feishu_topic_messages 未挂载:宿主 tools 服务不可用(会话继续)` | 缺 `tools` 服务:话题读取工具不注册 |
230
+ | `pending attachment storage domain unavailable; using a process-memory TTL fallback…` | 宿主拒绝 `pending_attachments` 表:静默登记退化为进程内 TTL,重启丢失(仅防御场景) |
231
+ | `adopted N legacy staged attachment(s) into …; the one-time _staging migration is deprecated and will be removed after one release cycle` | 旧版 `_staging` 遗留文件被一次性迁入话题目录;按 D12,该兼容路径在 v2 发布后的下一个 minor 版本移除,可据此清理旧数据 |
232
+
180
233
  **未配置凭据(不是启动失败)**:日志会提示 `飞书应用凭据未配置完整(缺少 FEISHU_APP_…)`,插件照常加载但**不连接**;打开 Web **设置 → 飞书渠道 → 连接**,按三步引导填写 App ID / App Secret 并保存后自动连接。
181
234
 
182
235
  **常见连接失败**(插件保持加载,面板「连接」标签页显示原因,可重试):
@@ -196,13 +249,13 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
196
249
 
197
250
  | # | 场景 | 操作 | 预期 |
198
251
  |---|------|------|------|
199
- | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;卡片底部两行 session 信息 |
200
- | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片变 **回复失败**,显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
252
+ | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板;无推理内容的回合结算后面板省略)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;**完成后主卡底部出现分割线与两行元数据**(第一行:模型/会话/推理时长;第二行:上下文占比/输入输出 token/缓存命中),首帧与流式中没有这两行 |
253
+ | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片进入 **回复失败** 终态(有推理内容时面板标题为「回复失败」,否则不渲染面板),正文显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
201
254
  | 1b | Session 推理强度切换 | 话题内依次执行 `/effort`、`/effort tiny`、发普通消息、重启后续聊、`/effort reset` | 首次显示配置来源;切换后下一轮请求使用 `tiny`,重启恢复后仍保持;reset 后恢复配置值。若模型不支持 `tiny`,下一轮按 1a 失败,不会静默回退 |
202
- | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:卡片底部 session id 不变,input/output token 累计增长 |
255
+ | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:完成后主卡结算页脚第一行的 session id 不变,input/output token 累计增长 |
203
256
  | 3 | 群聊 @ | 在普通群 @ 机器人发消息 | 同 #1;该消息成为新话题根 |
204
257
  | 4 | 群聊话题内 | 在上一步的话题里直接发消息(不 @) | 延续该 session,不再要求 @ |
205
- | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**独立的第二个工作区**(`topics/groups/<chat_id>/<用户 open_id>/<topic>/`),与原用户的话题工作区互不可见 |
258
+ | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**该用户的独立工作区**(`groups/<chat_id>/<用户 open_id>/`),会话实际工作在它自己的 `topics/<话题>/` 下,与原用户的话题目录互不可见 |
206
259
  | 6 | 话题群 | 在话题群某话题中 @ 机器人 | 机器人直接在该话题内回复卡片(不新建话题) |
207
260
  | 7 | 折叠面板 | 点面板标题 | 展开推理内容,右侧箭头翻转向上;再点收起 |
208
261
  | 8 | 重启恢复 | 重启 dsh 后在旧话题里发消息 | 机器人懒恢复原 session,token 统计继续累计 |
@@ -214,7 +267,7 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
214
267
  | 14 | 管理员直通(免群授权) | 刚被拉进未授权群时,管理员直接 @ 机器人发消息 | 正常开会话(管理员不受白名单限制);不授权该群也不影响管理员使用 |
215
268
  | 14a | 群授权与群内准入 | 管理员点「授权本群」(或在群内发 `/auth allow`);未授权用户 @;管理员 `/auth user add <open_id>` 后该用户再 @ | 授权前非管理员 @ 无响应(管理员除外);授权后白名单用户可分别开会话;未授权用户 @ 不建会话且管理员收到用户授权卡;授权该用户后可正常开会话 |
216
269
  | 15 | 撤销与持久化 | `/auth revoke`(群内)/ `/auth user remove <open_id>`;重启 dsh 后再试 | 撤销后对应主体立即无法开启对话;重启后已授权群/用户仍有效(持久化) |
217
- | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的工作区;A 自己发言正常 |
270
+ | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的话题目录;A 自己发言正常 |
218
271
 
219
272
  ### 5.0 HITL 审批验证点(重点)
220
273
 
@@ -228,16 +281,64 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
228
281
 
229
282
  飞书官方文档没有明示 `reply_in_thread` 对**私聊**的约束。联调时在私聊里观察第 1 步:消息是否真的聚合成“话题”形态(话题气泡)。若飞书拒绝话题回复,插件按 `threadFallback: 'degrade'` 自动改为普通回复,此时私聊内所有消息延续同一个 session(等价于“私聊整体即话题”),功能不受阻。若你希望私聊必须话题形态,把 `threadFallback` 设为 `strict` 并反馈错误码。
230
283
 
231
- ### 5.2 验证工作区归属
284
+ ### 5.2 验证工作区与话题目录
232
285
 
233
- 打开 dsh Web GUI → 工作区列表,应看到:
286
+ 打开 dsh Web GUI → 工作区列表,应看到**每人一个**项目:
234
287
 
235
288
  ```
236
- 飞书私聊 · ou_xxx · omt_xxx (对应 <baseDir>/topics/p2p/ou_xxx/omt_xxx/)
237
- 飞书群聊 · oc_yyy · ou_xxx · omt_xxx (对应 <baseDir>/topics/groups/oc_yyy/ou_xxx/omt_xxx/)
289
+ 飞书私聊 · ou_xxx (对应 <baseDir>/p2p/ou_xxx/)
290
+ 飞书群聊 · oc_yyy · ou_xxx (对应 <baseDir>/groups/oc_yyy/ou_xxx/)
238
291
  ```
239
292
 
240
- 每个新话题会创建新的工作区目录;同一话题内 `/renew` 仍复用该工作区。
293
+ 每个新话题在该工作区内创建独立的话题工作目录 `<工作区>/topics/<话题 id>/`,会话列表仍归属该项目;同一话题内 `/renew` 复用同一话题目录。也可在飞书里用 `/workspace` 查看当前会话的实际工作目录。
294
+
295
+ **附件落点(v2)**:默认 `attachmentStore: 'native'` 下入站图片/文件存入 **DSH 原生附件库**(`$DSH_HOME/attachments/v1` 一类宿主目录,由宿主管理),**话题目录下不会出现 `attachments/`**;只有显式 archive 后端或宿主缺原生附件库时才会看到 `<工作区>/topics/<话题>/attachments/`。v2 主路径也不再写 `<baseDir>/_staging/`:该目录只保留旧版遗留文件的一次性迁入(成功时一条 warn),按 D12 在 v2 发布后的下一个 minor 版本移除;`_staging` 被显式排除在「目录清理」之外,不会被保留期清理误删。
296
+
297
+ ### 5.3 引用与话题历史(P2)验证点
298
+
299
+ 需要真机操作;自动测试已覆盖 prompt 组装与工具行为,但飞书侧权限/历史范围仍需人工确认。
300
+
301
+ 1. **引用文字**:在私聊或已绑定话题内,长按一条更早的机器人/用户消息 → 回复(飞书会自动 @ 被回复消息的发送方)→ 发送“解释一下上面这条”。预期:卡片正常开始;模型能复述被引用消息的内容(引用摘录已进 prompt),而不是回答“看不到引用”。
302
+ 2. **引用文件/图片**:先发一条带文件(或图片)的消息,再引用它问“这个文件里是什么”。预期:引用消息的附件会出现在 `<feishu_context>` 清单(`message_id` / `file_key` 为被引用消息),模型用 `feishu_attachment_fetch` 取回后回答;若模型未调用工具,可要求它“用 feishu_attachment_fetch 取一下清单里被引用那条消息的附件”。
303
+ 3. **引用合并转发**:把几条消息合并转发到聊天里,然后引用该合并转发问“总结一下转发内容”。预期:prompt 中出现被引用的转发文本,且容器 `message_id` 同时出现在引用块与清单;模型需要子资源时用**顶层容器 message_id** 调用 `feishu_attachment_fetch` 成功(子消息 id 会 400,属预期)。
304
+ 4. **话题历史 / @ 之前**:在已有讨论的话题群里,先让其他成员(或自己)在话题内发几条消息,再 @ 机器人提问。预期:新会话首轮 prompt 含最近的 `<feishu_history>` 摘要(默认最近 10 条,每行带 `message_id`,附件可按该 id 懒取);更早的消息用 `feishu_topic_messages` 翻页查看。若缺 `im:message.group_msg` 等权限,历史块会静默省略但普通对话不受影响——首次降级有一条 warn 日志(含义见 §1 权限说明),按 §1 申请权限后重新发布版本。
305
+ 5. **入群前历史**:机器人入群前该群已有消息时,重复第 4 步;若能看到入群前的消息,说明当前应用权限覆盖了机器人可见范围,否则按“机器人可见范围”降级(文档已注明,不保证入群前历史)。
306
+ 6. **关闭首轮历史**:把 `historyBootstrapLimit` 设为 `0` 并重载插件,新建会话确认首轮不再附带 `<feishu_history>`;`feishu_topic_messages` 仍可显式读取。
307
+
308
+ ### 5.4 附件管线 v2(P3)真机验证清单
309
+
310
+ > 自动测试已覆盖以下行为(见 [docs/attachment-pipeline-v2.md](attachment-pipeline-v2.md) §12.1),但**插件链路与飞书权限/历史范围仍需真机确认**。表中「状态」一列区分:**已真机** = 对应 Issue 已记录的真实设备结果;**待手测** = 尚无插件链路真机记录(自动测试覆盖);**API 已验** = 同应用 bot 身份只读 API 探针已验,但插件端到端待手测。执行后把结论写回 `.agents/roadmaps/attachment_pipeline_v2/acceptance.md`,未通过项如实记录,不要用 mock 结果冒充。
311
+
312
+ **准备**:`pnpm build` 后用新构建启动(或 HMR 重载插件);准备好 p2p、普通群、话题群各一个,聊天里发一个 ≥100 MB 的大文件附件(飞书允许范围内);确认 `attachmentStore: 'native'`、`feishuTools.attachmentFetch/topicMessages` 为默认开启。
313
+
314
+ | # | 场景 | 操作 | 预期 | 状态 |
315
+ | --- | --- | --- | --- | --- |
316
+ | A1 | 私聊·文本 | 私聊发送一条普通文字 | Typing → 卡片**立即**出现并流式回复;会话/话题正常建立 | ✅ 已真机(P1/P2) |
317
+ | A2 | 私聊·图片 | 发一张截图 + “这是什么” | 卡片先出现;首轮含 `ImageBlock`,模型不调用 fetch 即可描述(可能另用 `read_image`);首轮清单不列该已预取图片;原生库对象数 +1、话题目录无 `attachments/` | ✅ 已真机(P1 步骤 2) |
318
+ | A3 | 私聊·文件懒取 | 发 “看下这个文件” + 一个文件(如 48 MB zip) | 卡片先出现;首轮只有清单行(`message_id/file_key/name`),**下载发生在回合开始之后**;模型调 `feishu_attachment_fetch` 后结果为 `[text, file]` + 只读路径;文件字节进原生库、话题目录无 `attachments/`、无 `_staging` | ✅ 已真机(P1 步骤 3) |
319
+ | A4 | 私聊·纯附件静默 | 只发一张图片(不带文字),然后再发一条文件(不带文字) | 无回复、无表情、无新话题/会话、无下载;`pending_attachments` 各新增 1 行(scope=chat,kind=image/file),原生库对象数与工作区文件不变 | ✅ 已真机(P1 步骤 1 图片;P2 步骤 3a 文件) |
320
+ | A5 | 私聊·静默引用 | 在 A4 之后发普通文字:“刚发的文件叫什么/内容是什么” | 首轮清单列出静默登记的文件;模型 fetch 取回后正确回答;对应 pending 行变 `consumed=true` | ✅ 已真机(P1 步骤 4;P2 步骤 3b) |
321
+ | A6 | 私聊·引用文件 | 引用一条带文件的消息,问文件内容 | prompt 出现 `<feishu_quote>`(含 message_id),清单出现 quote 行;模型 fetch 成功 | ✅ 已真机(P2 步骤 3c) |
322
+ | A7 | 私聊·引用合并转发 | 合并转发两条消息(含一个文件),引用它问“总结/取其中文件” | quote 块展开子消息并标“合并转发,共 N 条”;清单为容器行;fetch 用**顶层容器 message_id** 成功(子消息 id 会 400,属预期) | ✅ 已真机(P2 步骤 4) |
323
+ | A8 | 私聊·大文件 | 发 ≥100 MB(如有 1 GB 级更好)文件 + “看下这个文件” | 卡片立即出现、不等待;清单先列文件;模型调 fetch 后流式入库,超过 `attachmentFetchTimeoutMs` 时返回可读超时,不阻塞后续对话 | ⏳ 待手测(P0 已验 980 MB 下载/取消,admission 链路未验;自动测试覆盖超时/取消) |
324
+ | A9 | 私聊·下载失败 | 引用/登记一条已被撤回或过期(资源不可用)的附件消息,让模型 fetch | 工具逐资源返回可读失败(如 `DOWNLOAD_FAILED` / 资源过期说明),不出现 `[object Object]`,回合正常结束 | ⏳ 待手测(自动测试覆盖失败路径) |
325
+ | B1 | 对话群·@ 触发 | 普通群 @ 机器人发图片 + 文字 | 新话题 + 卡片;图片预取进首轮;话题内后续无 @ 消息续聊同一 Session | ✅ 已真机(P1 步骤 5) |
326
+ | B2 | 对话群·未 @ 附件 | 同一群内不 @ 只发一张图片 / 只发一个文件,随后 @ 机器人问“我刚才发的文件” | 未 @ 时完全静默(无回复/表情/会话/下载),群 scope pending 新增行;随后 @ 建话题的首轮清单可列出并懒取该文件 | ✅ 已真机(P1 步骤 6 群未 @ 图片静默);⏳ 文件与跨 scope 引用部分待手测(自动测试覆盖) |
327
+ | B3 | 对话群·引用文件 | @ 机器人并引用群里的一条文件消息 | quote 摘录 + 清单 quote 行 + fetch 成功(权限范围内) | ⏳ 待手测(私聊同源已真机;自动测试覆盖) |
328
+ | C1 | 话题群·@ 图片/文件 | 在话题群的话题内 @ 机器人发图片 + 文件 | 在该话题内回复卡片(不新建话题);图片进首轮/文件进清单 | ⏳ 待手测(普通群 @ 建话题与话题群历史已真机;话题群图片/文件组合自动测试覆盖) |
329
+ | C2 | 话题群·纯附件静默 | 已绑定话题内不 @ 只发文件 | 完全静默;thread scope 登记;该话题后续触发消息的清单可列出 | ⏳ 待手测(群未 @ 静默已真机;话题内纯附件由自动测试覆盖,未单独真机) |
330
+ | C3 | 话题群·历史 | 话题内先发 A/B/C(不 @),再 @ 机器人提问 | 新 Session 首轮含 `<feishu_history>` A/B/C(带 message_id);`feishu_topic_messages` 可翻页,续聊轮不重复注入历史;工具渲染含 `- 下一页 cursor:` | ✅ 已真机(P2 步骤 5/6R) |
331
+ | C4 | 话题群·入群前历史 | 选择一个入群前就有消息的群,重复 C3 | 能看到机器人可见范围内早于入群的消息;看不到时属权限/可见范围限制,历史块静默省略(不报错) | ⏳ 待手测(API 探针显示可见早约 1 个月历史,但“入群时间”不可查询、不同租户可能不同) |
332
+ | D1 | archive 降级 | 面板把存储后端改为 `archive`(或临时把 `feishuTools.attachmentFetch` 设为 `false`),发图片/文件 | 旧行为:图片/文件下载到话题 `attachments/`(或 `_staging/` 后迁入),纯附件消息加 `DONE` 表情、不触发模型;不挂 fetch 工具;清单不出现 | ✅ 已真机(P1 步骤 7 显式 archive);⏳ `attachmentFetch=false` 组合待手测(自动测试覆盖) |
333
+ | D2 | 缺原生附件库 | 在无 `ctx.attachments` 的部署(如 headless/旧宿主)启动并发附件 | 自动回退 archive 旧行为;日志一条 warn:`attachmentStore=native 但宿主未挂载 ctx.attachments…`;不崩溃 | ⏳ 待手测(自动测试覆盖) |
334
+ | D3 | 关闭预取 | 面板关闭 `prefetchImages` 后重启/重载,新会话发图片 + 文字 | 快速出现卡片,首轮**无** ImageBlock;图片出现在清单,模型用 `feishu_attachment_fetch` 取回后回答 | ⏳ 待手测(自动测试覆盖) |
335
+ | D4 | 关闭附件接收 | 面板关闭 `acceptAttachments` 后发纯附件 / 文件+文字 | 纯附件:无回复/无登记/无下载;文件+文字:只以文字触发,附件不保存不登记 | ⏳ 待手测(自动测试覆盖) |
336
+ | E1 | 权限缺失·读取 | 临时移除 `im:message:readonly`(或让 `im:message.group_msg` 未审批)后发布版本,发引用消息/在群内 @ | 普通对话正常;引用/历史块静默省略;首次降级一条 warn;显式 `feishu_topic_messages` 返回可读 `PERMISSION_DENIED`;恢复权限并重发版本后恢复 | ⏳ 待手测(用户已决定跳过;自动测试覆盖 throw/resolved 两种降级) |
337
+ | E2 | 权限缺失·下载 | 临时移除消息资源读取相关 scope,让模型 fetch 一个文件 | 工具逐资源返回可读失败(403/权限说明),回合不崩;archive 下则加 `No` 并回纯文字失败提示 | ⏳ 待手测(自动测试覆盖) |
338
+ | F1 | 面板配置 | 在 Web 「会话与行为」依次修改存储后端、预取开关/上限、登记 TTL/容量、两个工具开关与上限并保存,新建会话验证生效 | 面板读写与帮助文案正确;嵌套 `feishuTools.*` 只改被改子键;旧值不丢;保存后入站行为即时按新配置判定、工具挂载对新会话生效;非法值被拒 | ⏳ 待手测 + 自动测试(settings 覆盖) |
339
+ | F2 | 清理语义 | 在 native 后端执行「目录清理」,再回到旧话题发消息 | 只删过期话题/旧版目录;用户工作区与会话日志保留;native 附件不受影响(Session 历史仍可读);archive 后端下话题目录内 `attachments/` 随目录删除 | ⏳ 待手测(cleanup/settings 自动测试覆盖) |
340
+
341
+ **记录要求**:每个「待手测」项执行后,在 `acceptance.md` 对应 AC 下写「操作 / 实际结果 / 结论 / 环境(构建 SHA、profile、日期)」;失败的项保留原文与日志摘录,修复后复测再更新。`historyBootstrapLimit: 0`、非本 route 负向、`acceptAttachments=false` 等用户已决定跳过真机的项,标注「自动测试覆盖,用户决定跳过真机」,不要写成已验证。
241
342
 
242
343
  ## 6. 常见问题排查
243
344
 
@@ -249,14 +350,21 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
249
350
  | 群内 @ 机器人无响应(登录台无 access 报错) | 管理员不受白名单限制,可直接对话;**非管理员**需要该群已授权且自己在用户白名单里。可用 `/auth status`(管理员)或 Web 面板「访问授权」标签页确认 |
250
351
  | `/auth` 无反应 | `/auth` 仅管理员可用;非管理员在群里会被静默忽略、私聊会收到固定提示 |
251
352
  | 群聊 @ 无响应 | 事件订阅是否勾选“接收群聊中 @ 机器人消息”;机器人是否在群内且未被群主禁言 |
353
+ | 模型说看不到刚发的文件 / 文件没有自动出现 | 属 v2 设计:文件默认不加载,只在 `<feishu_context>` 清单里声明;模型需调用 `feishu_attachment_fetch`。可明确要求“用 feishu_attachment_fetch 取清单里 message_id=… 的附件”;若工具没注册,检查启动日志 `feishu_attachment_fetch 未挂载` 与宿主 `tools`/`attachments` 服务 |
354
+ | 只发附件(不带文字)机器人毫无反应 | native 后端下的**预期行为**:纯附件消息静默登记,不回复、不建会话、不下载;随后发一条文字即可引用清单里的附件。archive 后端下会看到 `DONE` 表情且附件落到话题目录 |
355
+ | 模型没有调用 feishu_attachment_fetch | 检查 `<feishu_context>` 清单是否出现在 prompt(有未加载附件时才有);确认工具开关 `feishuTools.attachmentFetch` 与 native 后端;必要时在消息里点明“用附件工具取一下”。漏调风险由清单措辞 / 工具描述 / guidance 收敛,没有预下载兜底(决定 D4) |
356
+ | 引用或话题历史内容缺失 | 缺消息读取 scope(`im:message:readonly` / 敏感的 `im:message.group_msg`):引用/历史块静默省略、普通对话不受影响;启动后首次降级有一条 warn,之后 debug 级。按 §1 申请权限并重新发布版本;显式调用 `feishu_topic_messages` 会返回可读 `PERMISSION_DENIED` |
357
+ | `feishu_attachment_fetch` 超时 / 报字节超限 | 大文件受 `feishuTools.attachmentFetchTimeoutMs`(默认 120 s)与 `attachmentFetchMaxBytes`(默认 100 MiB)限制:可调大上限/超时,或用 `file_key` 分批取;资源过期/被撤回时逐资源返回可读失败,可直接重试或让用户重发 |
358
+ | 升级到 v2 后在旧话题里“找不到以前的附件” | 旧 archive 部署的附件仍在旧话题 `attachments/`,但新会话不再枚举该目录;Session 历史里的只读路径仍可读。需要长期保留请勿启用目录清理,或从历史消息重新发送/引用 |
359
+ | 话题目录里出现 `attachments/` | 当前生效的是 archive 后端:检查 `attachmentStore`(是否显式设为 `archive`)、宿主是否挂载 `ctx.attachments`、`feishuTools.attachmentFetch` 是否为 `false`。native 后端不会写话题 `attachments/` |
252
360
  | 有 Typing 但无卡片 | 权限 `im:message` / `im:message:update`;卡片 2.0 需要较新的飞书客户端(升级客户端后重试) |
253
- | 卡片正文只到某长度 | 旧版行为;新版单卡预算 28 KB(30 KB 上限留 2 KB 冗余),超长正文自动分段为多张续卡(「回复分段 n」,每卡 ≤ 5 个表格),全文保留;推理面板独立 5 KB 上限,超出的推理只保留开头并追加「已截断」提示 |
361
+ | 卡片正文只到某长度 | 旧版行为;新版单卡目标 28 KB(`CARD_TARGET_BYTES`,飞书上限 30 KB 留 2 KB 冗余),超长正文自动分段为多张续卡(「📄 回复分段 n」,每卡 ≤ 5 个表格,断点避开代码围栏与表格),续卡无面板/页脚、可用满 28 KB,全文保留;推理面板独立 5 KB 上限,超出的推理只保留开头并追加「已截断」提示 |
254
362
  | 报错 230099 / `ErrCode: 11310 card table number over limit` | 飞书单卡表格数量超限(真机验证上限 5 个);新版已按“每卡最多 5 个表格”自动切分,不再触发;历史版本请重装插件并重启 dsh |
255
363
  | 流式期间手动展开面板又被收起 | v1 已知取舍:patch 是全量替换卡片,500ms 内的下一次刷新会重置折叠状态;流式结束后可自由展开 |
256
364
  | 错误码 `230013` | 用户不在可用范围 / 被禁用 |
257
365
  | 错误码 `230071` | 该群不支持话题回复 → 自动降级路径(见 §5.1) |
258
366
  | 错误码 `230099`(`parse card json err` / `unknown property, property: elements`) | 卡片结构混用:2.0 声明下把组件放在顶层 `elements`(应在 `body.elements`)。新版插件已改为规范 2.0 结构:重装插件并重启 dsh |
259
- | 折叠面板不显示 / 正文不是富文本 | 旧版卡片的两个已知问题(`icon.tag: "img"` 非法、1.0 结构 markdown 语法受限),新版插件已切 2.0 结构 + 内置 `standard_icon` 箭头:重装插件并重启 dsh |
367
+ | 折叠面板不显示 / 正文不是富文本 | 无推理内容的回合结算后省略面板属**正常行为**;若旧版插件连推理内容也不显示,是旧卡片的两个已知问题(`icon.tag: "img"` 非法、1.0 结构 markdown 语法受限),新版插件已切 2.0 结构 + 内置 `standard_icon` 箭头:重装插件并重启 dsh |
260
368
  | 审批卡不出现 | 确认该部署启用了 HITL:启动日志应有「HITL 审批应答已就绪」;若显示「主机未挂载 approval 服务」则该部署不会派发审批请求;另外确认当前会话的审批策略不是 `never`(`/permission danger-full-access` 会关闭审批) |
261
369
  | 审批卡点击无反应 | 未订阅 **card.action.trigger** 回调(或订阅后未重新发布版本),见 §1 第 4 步;按 §5.0 重新核对 |
262
370
  | 错误码 `231001` / `231002` | 表态表情失败:表情名(`Typing`/`DONE`/`No`)必须一字不差;机器人需在会话内 |
@@ -6,13 +6,13 @@
6
6
  |---|---|---|
7
7
  | 会话本体(事件日志 + header) | 宿主 `dsh-session-persistence` | `$DSH_HOME/sessions/<workspace>/<sessionId>/session.jsonl.zstd` |
8
8
  | 插件自有状态 | 插件 → `ctx.storageDomain` | 域 `channels_routes`,JSON 后端为 `$DSH_HOME/storages/channels_routes.json` |
9
- | 工作区 | 磁盘目录 + 宿主 registry | `<baseDir>/p2p|groups/...`(`baseDir` 可在 Web 面板配置),注册表 `$DSH_HOME/storages/workspace.json` |
9
+ | 工作区 / 话题目录 | 磁盘目录 + 宿主 registry + Session 日志 | 工作区 `<baseDir>/p2p|groups/...`(`baseDir` 可在 Web 面板配置,注册表 `$DSH_HOME/storages/workspace.json`);每个话题的工作目录(`<工作区>/topics/<话题>` 或 Git worktree)由 Session 日志的 `working-directory/change` 事件恢复 |
10
10
 
11
11
  ## 一、已持久化的内容
12
12
 
13
- 1. **路由与过期墓碑**(`src/domain.ts` 域 `channels_routes`):`routes` 表以 `thread_id` 或降级键 `chat:p2p|group:<chatId>:<openId>` 保存活动 `RouteRecord`(sessionId / chatId / chatType / senderOpenId / workspacePath / cardMessageId / createdAt / lastActivityAt);`expired_routes` 以 thread id 保存 TTL 退休后的最小墓碑,跨重启拒绝旧话题误建 Session。`cardMessageId` 每 turn 更新,`lastActivityAt` 供路由退休使用。
13
+ 1. **路由与过期墓碑**(`src/domain.ts` 域 `channels_routes`):`routes` 表以 `thread_id` 或降级键 `chat:p2p|group:<chatId>:<openId>` 保存活动 `RouteRecord`(sessionId / chatId / chatType / senderOpenId / workspacePath / sessionDir? / worktreeBranch? / cardMessageId / createdAt / lastActivityAt);`workspacePath` 是用户工作区(Session header cwd),`sessionDir` 是该话题的实际工作目录(旧记录缺省 = `workspacePath`);`expired_routes` 以 thread id 保存 TTL 退休后的最小墓碑,跨重启拒绝旧话题误建 Session。`cardMessageId` 每 turn 更新,`lastActivityAt` 供路由退休使用。
14
14
  2. **会话本体与事件日志**:宿主代持。插件消费的 `assistant/chunk`、`tool/call`、`tool-workflow/run-*`、`subagent/descriptor` 等全部在持久化日志里,因此 `src/stats.ts` 的 token/缓存统计不单独存,`foldAll()` 重放日志,重启后继续累计。`meta.cwd`、`agentPreset` 随 header 落盘。
15
- 3. **工作区**:目录确定性生成、幂等 mkdir;宿主 `workspace.json` 持久化 path/title/sessionIds。
15
+ 3. **工作区与话题目录**:工作区目录确定性生成、幂等 mkdir,宿主 `workspace.json` 持久化 path/title/sessionIds;话题目录路径记录在路由的 `sessionDir`,实际工作目录同时由 Session 的 `working-directory/change` 事件持久化。
16
16
  4. **凭据**:密钥存宿主 `$DSH_HOME/.credentials.yaml`,插件只持引用名。
17
17
  5. **准入授权**(`src/domain.ts` 域 `channels_routes`,同样保持 `version: 1` 加法新增):`access_groups` 表以 `chat_id` 保存已授权群(群名/来源/授权人/时间);`access_users` 表以 `open_id` 保存全局用户白名单;`access_requests` 表以不透明 token 保存入群/用户授权申请(含 `messageId`,重启后旧授权卡仍可点击结算;过期状态在同一表落审计)。管理员 open_id 与准入模式属于配置层(profile + Web 覆盖),不在这三张表里。
18
18
 
@@ -70,7 +70,7 @@
70
70
  2. **段卡更新**(`streamer.ts`):续卡发送成功、message id 解析后经新钩子 `onSegmentDelivered(mainCardMessageId, index, messageId, mode)` 上报;服务端通过 `table.update()`(宿主原子 read-modify-write)合并进快照。
71
71
  3. **清除**(`streamer.ts` `finalize`):turn 结束即经 `onTurnFinalized(sessionId, turn, mainCardMessageId)` 删除快照。会话创建失败路径(卡片已落「会话创建失败」终态)同样主动清除。
72
72
  4. **修复**(`service.ts` `start()`,长连接建立后后台执行 `repairInflight()`):
73
- - 对每条快照:所有 `mode: 'card'` 的卡 patch 为终态中断卡(主卡带折叠面板,续卡带分段头,正文为「⚠️ 服务已重启,本次回复中断,请重新发送消息。」);移除 Typing、补 DONE(表情操作 best-effort)。
73
+ - 对每条快照:所有 `mode: 'card'` 的卡 patch 为终态中断卡(`buildInterruptionCard`:正文为「⚠️ 服务已重启,本次回复中断,请重新发送消息。」一条 markdown;主卡只有该条正文、**不带折叠面板**,续卡额外带 `📄 回复分段 n · 回复中断` 的 notation 分段头;不带 token 统计);移除 Typing、补 DONE(表情操作 best-effort)。
74
74
  - 成功后删除快照;失败则 `attempts + 1`,达到 2 次后删除(一次性修复 + 有限重试,防止对无法接受 patch 的介质无限重试)。
75
75
 
76
76
  ## 已知取舍(v1)
@@ -1,7 +1,7 @@
1
1
  # 重构计划与进度
2
2
 
3
3
  > 目标:让 dsh-channels **更条理**(分层清晰、单一职责)、**更优雅**(去重、统一抽象、单一事实来源)、**更可用**(模型/权限/图片/历史等能力补齐)。
4
- > 约束:对外行为与公开 API 保持不变;每个阶段结束跑 `tsc --noEmit` + 全量 `vitest`,保持 263 个测试全绿作为安全网。
4
+ > 约束:对外行为与公开 API 保持不变;每个阶段结束跑 `tsc --noEmit` + 全量 `vitest`,保持 576 个测试全绿作为安全网。
5
5
 
6
6
  ## 目标结构
7
7
 
@@ -30,10 +30,11 @@ src/
30
30
  | 4 | `session/manager.ts`:live agent 借用/冷恢复、preset 组合、`/renew` 事务、推理强度挂载 | ✅ |
31
31
  | 5 | `commands/registry.ts` + 注册表驱动分派:命令词表/准入/`/help` 单一事实来源;`/new` 别名 | ✅ |
32
32
  | 6 | `routing/inbound.ts`:入站去重、准入、路由决策树、建/续会话、首触引导从门面拆出 | ✅ |
33
+ | 7 | `cards/streaming.ts` 构成收敛与预算/切分收敛:渲染复用 `cards/primitives.ts` 的 `card/markdown/note/foldPanel`(`buildOnboardingCard` 拆到 `cards/onboarding.ts`);预算常量 4 → 3(删除 `CARD_PANEL_RESERVE_BYTES`);分段按整卡真实渲染测量、续卡可用满 28 KB;切分统一避让代码围栏与表格;卡片构成收敛为「面板 → 活动行 → 正文 → 分割线 + 两行结算页脚」三层(页脚第一行:模型/会话/推理时长,第二行:上下文占比/输入输出/缓存命中);新增 golden 结构测试 | ✅ |
33
34
 
34
- 行数变化:`src/service.ts` **1902 → 1381**;宿主类型 1 文件 → 5 模块;卡片 1 文件 → 4 模块 + 原语;新增共享交互基类、命令注册表、会话管理器、路由存储、重启修复、投递层。测试 240 → **274**。
35
+ 行数变化:`src/service.ts` **1902 → 1381**;宿主类型 1 文件 → 5 模块;卡片 1 文件 → 4 模块 + 原语;新增共享交互基类、命令注册表、会话管理器、路由存储、重启修复、投递层。测试 240 → **274**(阶段 1–6 结束时;当前 576 个测试)。
35
36
 
36
- ## 可用性增强(阶段 7,待做)
37
+ ## 可用性增强(已完成)
37
38
 
38
39
  | 功能 | 状态 | 备注 |
39
40
  |---|---|---|
@@ -44,13 +45,13 @@ src/
44
45
  | 首触引导卡 | ✅ | 私聊首次触达一张,`welcomedChats` 持久化去重,`welcomeOnboarding` 可关 |
45
46
  | 问答卡自由文本回答 | ✅ | 无选项问题直接开放答题;阻塞期间普通文本回复即答案(`custom`) |
46
47
  | `/model` 查看与切换 | ✅ | `session/request-policy.ts` 统一 provider/model/effort;`ctx.llm.listModels` 校验;经 `command/run` 持久化 |
47
- | 附件输入 | ✅ | `feishu/inbound-attachments.ts`:图片/文件流式落盘到工作区 `attachments/`、合并转发记录转存 `.txt`;不触发模型,Agent 用工具按需读取;`acceptAttachments` 可配 |
48
- | 工作区清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,删除插件工作区并把路由退休为墓碑,Session 日志保留 |
48
+ | 附件输入 | ✅ | v2:入站图片/文件默认进 DSH 原生附件库;触发消息图片预取、文件由 `feishu_attachment_fetch` 懒取、纯附件静默登记;`archive` 降级保留旧行为(`feishu/inbound-attachments.ts` 落工作区 `attachments/`,合并转发记录转存 `.txt`);详见 [attachment-pipeline-v2.md](attachment-pipeline-v2.md) |
49
+ | 目录清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,只清理工作区与 Agent 产物(过期话题目录/旧版话题工作区)并把路由退休为墓碑,用户工作区、Session 日志与原生附件库保留 |
49
50
 
50
51
  ## 验证基线
51
52
 
52
53
  ```bash
53
54
  cd /Users/moka/dev/project/dsh_ext/dsh_channels
54
55
  node_modules/.bin/tsc --noEmit
55
- node_modules/.bin/vitest run # 17 files, 287 tests
56
+ node_modules/.bin/vitest run # 25 files, 576 tests
56
57
  ```
@@ -0,0 +1,81 @@
1
+ # 工作区粒度与会话隔离设计
2
+
3
+ > 状态:已确认并实现(v1)。目标:工作区数量随「人」增长而不是随「话题」增长,同时话题之间不互相踩文件。
4
+ >
5
+ > 附件说明(v2 更新):入站图片/文件默认存入 **DSH 原生附件库**(不写入话题目录),仅 `attachmentStore: 'archive'` 降级路径才写 `<话题目录>/attachments/` 与旧版 `_staging/`;详见 [attachment-pipeline-v2.md](attachment-pipeline-v2.md)。下文目录布局中的 `attachments/` 与 `_staging/` 均为 archive / 旧版兼容语义。
6
+
7
+ ## 1. 问题
8
+
9
+ 旧模型(`一个话题 = 一个 DSH 工作区`)把两个独立维度绑在了一起:
10
+
11
+ - **项目维度**:`ctx.workspaceRegistry` 是持久化项目列表,GUI 侧边栏一个分区一项,随话题无上限增长;
12
+ - **隔离维度**:模型工具产生的文件必须按话题隔开。
13
+
14
+ DSH 本身允许这两个维度解耦,见下。
15
+
16
+ ## 2. DSH 的三个关键事实
17
+
18
+ 1. **工作区归属只认不可变的 `SessionHeader.cwd`**:`Workspace.attachSession()` 校验 header.cwd 与工作区路径(realpath 后)完全相等;GUI 分组、重启 bootstrap 同理。
19
+ 2. **实际工作目录可变**:`ctx.workingDirectory.set(agent, dir)` 写 `working-directory/change` 事件、持久化、冷恢复后还原;`bash`、`fs`、`terminal` 工具都从 `workingDirectory` 取 cwd。因此会话可以「归属 A 工作区、实际在 B 目录干活」。DSH 官方 Git worktree 也是靠这个机制保留原项目归属。
20
+ 3. **沙箱写边界 = 不可变 header.cwd**,不随工作目录切换。因此本设计提供的是**行为层隔离**(工具默认在话题目录内工作、互不踩文件),不是防恶意的硬沙箱;硬边界需要 header.cwd 直接指向话题目录,代价是会话失去工作区归属(见 §6)。
21
+
22
+ ## 3. 目录布局
23
+
24
+ ```
25
+ <baseDir>/
26
+ p2p/<open_id>/ # 私聊用户工作区(注册 workspace,header.cwd)
27
+ topics/<topic-token>/ # 话题目录(会话实际工作目录)
28
+ attachments/ # 仅 archive 后端:该话题的入站附件(v2 native 默认不写)
29
+ groups/<chat_id>/<open_id>/ # 群聊中某成员的工作区(注册 workspace)
30
+ topics/<topic-token>/
31
+ attachments/ # 同上
32
+ topics/... # 旧版「话题=工作区」目录,原样保留,不自动迁移
33
+ _staging/<chatType>/<chatId>/<openId>/ # 仅旧版:话题建立前的附件暂存(v2 不再写入)
34
+ ```
35
+
36
+ - 私聊一个用户一个工作区;群聊**一个群 × 一个用户**一个工作区(不能做成「用户全局一个」:否则同一个人在不同群之间会串文件,A 群会话可能把 B 群的文件内容贴给 A 群成员)。
37
+ - `<baseDir>/p2p/...`、`<baseDir>/groups/...` 与旧版「用户 × 聊天」共享工作区路径一致,升级后旧会话仍归属同一个项目。
38
+ - 话题 token:真实话题用 `thread_id`;降级 chat-scoped 会话用其 session id。目录名经过安全字符校验。
39
+ - 入站附件(v2 native):存 DSH 原生附件库(宿主管理、不可变、内容寻址去重),不占话题目录;archive 降级才落 `attachments/`。
40
+
41
+ ## 4. 隔离档位(`topicIsolation`)
42
+
43
+ | 值 | 行为 |
44
+ |---|---|
45
+ | `auto`(默认) | 工作区含 `.git`(即用户把工作区做成了 Git 仓库)时,每个话题用 DSH worktree(分支 `feishu-<token>`,checkout 在 `.agents/worktrees/`);否则用普通话题子目录 |
46
+ | `subdir` | 始终普通话题子目录,零 Git 依赖 |
47
+ | `worktree` | 始终尝试 worktree;服务缺失 / 非 Git 仓库 / 权限不足时告警并回退子目录 |
48
+
49
+ - 子目录模式:`mkdir <workspace>/topics/<token>` 后 `workingDirectory.set` 切过去。文档/数据类工作区零成本、不复制文件。
50
+ - worktree 模式:`ctx.worktrees.create(agent, { name: 'feishu-<token>' })`,返回的 checkout 即会话目录;分支与工作树由 Git 管理,适合「多个话题并发改同一个代码仓库」。
51
+ - 两个模式都保留会话在用户工作区下的 GUI 归属,因为 header.cwd 仍是用户工作区。
52
+ - 降级:宿主没有 `workingDirectory` / `worktrees` 服务、或 worktree 创建失败时,回退为「会话直接以话题目录为 cwd」(此时不再 attach 到工作区,会话在 GUI 中可能显示为未分组;文件隔离仍然成立)。
53
+
54
+ ## 5. 路由记录
55
+
56
+ `channels_routes` 的 v1 记录保持可读,新增可选字段:
57
+
58
+ - `sessionDir`:会话实际工作目录(话题目录 / worktree);缺省表示旧记录——直接用 `workspacePath` 作为会话目录。
59
+ - `worktreeBranch`:worktree 模式下的话题分支,供清理与恢复使用。
60
+
61
+ `workspacePath` 语义变化为「用户工作区(会话 header cwd / 沙箱根)」。
62
+
63
+ ## 6. 取舍与边界
64
+
65
+ - **隔离强度**:话题子目录/worktree 只保证「工具默认在话题目录工作」。同一用户工作区内的绝对路径访问仍然可行;沙箱写边界仍是用户工作区。
66
+ - **硬隔离**(不采用):把 header.cwd 指向话题目录,会话在 GUI 中不再归属工作区。只有当话题来源不可信、必须防互相读取时才值得。
67
+ - **附件**:v2 默认(`attachmentStore: 'native'`)入站图片/文件存 DSH 原生附件库,**不写话题目录**,因此目录清理不会影响 Session 附件;显式 archive 后端(或宿主缺原生附件库)才落当前话题目录的 `attachments/`,话题建立前收到的附件先暂存 `_staging/`,首个 Session 创建时一次性迁入。v2 主路径不再写 staging;`_staging` 仅作旧版兼容(成功迁入时 warn),v2 发布后的下一个 minor 版本移除(D12),且被显式排除在保留期清理之外。
68
+ - **`/renew`**:同一话题内新建 Session,复用同一话题目录(worktree 也复用)。
69
+ - **清理**:保留期只删除过期的话题目录 / 旧版话题工作区,**永不删除用户工作区**;活跃路由先退休为墓碑(旧话题消息收到「话题已过期」提示),Session 日志不删除;native 原生附件库不参与清理(插件不提供附件清理),archive 降级时话题目录内的 `attachments/` 副本会随目录删除,旧版 `_staging/` 显式排除在清理之外(D12)。worktree 模式下只删除 checkout 目录,Git 分支与 worktree 管理记录按 DSH 语义保留(`git worktree prune` / `branch -D` 可回收)。
70
+ - **迁移**:不做自动迁移。旧版 `topics/...` 工作区继续服务旧路由,可由清理卡片按保留期回收;旧版「用户 × 聊天」共享工作区即新的用户工作区,直接复用。
71
+
72
+ ## 7. 实现位置
73
+
74
+ | 文件 | 职责 |
75
+ |---|---|
76
+ | `src/workspaces.ts` | 用户工作区 / 话题目录 / 标题 / 暂存目录解析 |
77
+ | `src/session/isolation.ts` | 隔离档位判定、工作目录进入、worktree 创建与降级恢复 |
78
+ | `src/routing/inbound.ts` | 建会话与续聊时的目录编排、旧版 `_staging` 一次性迁入、路由写入 |
79
+ | `src/session/manager.ts` | 会话创建/恢复/`/renew` 的目录挂载与附件 guidance |
80
+ | `src/cleanup.ts` + `src/service.ts` | 按话题目录/旧版工作区回收的保留期策略与面板接线 |
81
+ | `src/domain.ts` | 路由记录 v1 兼容扩展(`sessionDir` / `worktreeBranch`) |
package/lib/client.js CHANGED
@@ -28,7 +28,7 @@ window.__ModuleLoader__.load({
28
28
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
29
29
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
30
30
  const CATALOG_ROUTE = "/dsh-channels/catalog";
31
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
31
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
32
32
  const CLEANUP_ROUTE = "/dsh-channels/cleanup";
33
33
  /** 群白名单增删(POST/DELETE,body `{ chatId, name? }` / `{ chatId }`)。 */
34
34
  const ACCESS_GROUPS_ROUTE = "/dsh-channels/access/groups";
@@ -78,7 +78,11 @@ window.__ModuleLoader__.load({
78
78
  /** Which editable field groups each tab renders (the tabs own one or two). */
79
79
  const TAB_FIELD_GROUPS = {
80
80
  connection: [],
81
- sessions: ["behavior", "content"],
81
+ sessions: [
82
+ "behavior",
83
+ "content",
84
+ "tools"
85
+ ],
82
86
  model: ["model"],
83
87
  workspace: ["workspace"],
84
88
  access: ["access"],
@@ -89,6 +93,7 @@ window.__ModuleLoader__.load({
89
93
  workspace: "工作区与文件",
90
94
  behavior: "行为",
91
95
  content: "内容与附件",
96
+ tools: "模型工具",
92
97
  model: "模型与推理",
93
98
  streaming: "流式卡片与日志",
94
99
  access: "准入与授权"
@@ -156,9 +161,9 @@ window.__ModuleLoader__.load({
156
161
  appSecretPlaceholder: "输入 App Secret",
157
162
  credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
158
163
  connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
159
- sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
164
+ sessionsTabHelp: "私聊、群聊触发方式与入站内容:附件存储/预取、静默登记保留、模型工具开关与读取预算;「需重连」项在下次连接时读取。",
160
165
  modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
161
- workspaceTabHelp: "每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。",
166
+ workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。入站附件默认存于 DSH 原生附件库,不占用话题目录,也不会被这里的目录清理删除。",
162
167
  accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
163
168
  advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
164
169
  save: "保存更改",
@@ -204,11 +209,12 @@ window.__ModuleLoader__.load({
204
209
  advancedCard: "只读与高级信息",
205
210
  advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
206
211
  readOnlyCard: "部署级参数",
207
- cleanupCard: "工作区清理",
208
- cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
212
+ cleanupCard: "目录清理",
213
+ cleanupCardHelp: "只清理工作区与 Agent 产物:删除超过保留期且不再活跃的飞书话题目录、旧版本按话题建立的工作区目录,以及这些目录内 Agent 产生的文件;不影响 Session 附件——入站图片/文件默认存于 DSH 原生附件库,不随目录清理删除。用户工作区本身与 DSH 会话日志也不会删除。删除不可恢复:相关旧话题会失效并提示已过期。",
209
214
  cleanupRetention: "保留期",
210
215
  cleanupAutoLabel: "自动清理",
211
- cleanupCandidates: "可清理工作区",
216
+ cleanupCandidates: "可清理目录",
217
+ cleanupWorkspaces: "插件工作区",
212
218
  cleanupCandidateBytes: "预计释放空间",
213
219
  cleanupRetentionOff: "关闭(不删除)",
214
220
  cleanupAutoOn: "已开启(启动时 + 每天一次)",
@@ -216,17 +222,18 @@ window.__ModuleLoader__.load({
216
222
  cleanupAutoInactive: "未执行(保留期为关闭)",
217
223
  cleanupNow: "立即清理",
218
224
  cleanupRunning: "清理中…",
219
- cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
220
- cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
221
- cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。",
222
- cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
225
+ cleanupConfirm: "确定清理过期目录吗?只清理工作区与 Agent 产物,不影响 Session 附件;但这些目录内未提交的文件会被删除,且操作不可恢复。",
226
+ cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。",
227
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。",
228
+ cleanupDisabledEmpty: "没有超过保留期的过期目录。",
223
229
  cleanupPreviewFailed: "无法读取清理预览",
224
230
  cleanupDone: "清理完成",
225
231
  cleanupFailed: "清理失败",
226
232
  cleanupDeleted: "已删除工作区",
233
+ cleanupDeletedDirs: "话题目录",
227
234
  cleanupRetired: "已清理话题路由",
228
235
  cleanupFreed: "释放空间",
229
- cleanupFailures: "以下工作区未能删除:",
236
+ cleanupFailures: "以下目录未能删除:",
230
237
  accessCard: "访问与授权",
231
238
  accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
232
239
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
@@ -1870,7 +1877,7 @@ window.__ModuleLoader__.load({
1870
1877
  //#endregion
1871
1878
  //#region src/client/panel/cleanup-card.tsx
1872
1879
  /**
1873
- * 工作区清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
1880
+ * 目录清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
1874
1881
  * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
1875
1882
  * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
1876
1883
  *
@@ -1891,7 +1898,11 @@ window.__ModuleLoader__.load({
1891
1898
  },
1892
1899
  {
1893
1900
  label: PANEL_COPY.cleanupCandidates,
1894
- value: `${cleanup.candidates} / ${cleanup.workspaces} 个`
1901
+ value: `${cleanup.candidates} 个`
1902
+ },
1903
+ {
1904
+ label: PANEL_COPY.cleanupWorkspaces,
1905
+ value: `${cleanup.workspaces} 个`
1895
1906
  },
1896
1907
  {
1897
1908
  label: PANEL_COPY.cleanupCandidateBytes,
@@ -1943,6 +1954,10 @@ window.__ModuleLoader__.load({
1943
1954
  " ",
1944
1955
  result.deletedWorkspaces,
1945
1956
  " 个,",
1957
+ PANEL_COPY.cleanupDeletedDirs,
1958
+ " ",
1959
+ result.deletedSessionDirs,
1960
+ " 个,",
1946
1961
  PANEL_COPY.cleanupRetired,
1947
1962
  " ",
1948
1963
  result.retiredRoutes,
@@ -1982,7 +1997,7 @@ window.__ModuleLoader__.load({
1982
1997
  //#endregion
1983
1998
  //#region src/client/panel/workspace-tab.tsx
1984
1999
  /**
1985
- * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 工作区清理卡。
2000
+ * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
1986
2001
  * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
1987
2002
  *
1988
2003
  * @module dsh-channels/client/panel/workspace-tab