@modesto_ma/dsh-channels 0.1.0 → 0.3.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 (50) hide show
  1. package/README.md +89 -55
  2. package/docs/access-control-design.md +5 -5
  3. package/docs/feasibility-and-design.md +54 -26
  4. package/docs/feishu-onboarding.md +26 -20
  5. package/docs/persistence.md +4 -4
  6. package/docs/refactor-plan.md +6 -5
  7. package/docs/workspace-isolation-design.md +78 -0
  8. package/lib/client.js +1715 -882
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +1556 -680
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/cards/index.ts +1 -0
  14. package/src/cards/onboarding.ts +23 -0
  15. package/src/cards/primitives.ts +20 -0
  16. package/src/cards/streaming.ts +310 -291
  17. package/src/cleanup.ts +256 -119
  18. package/src/client/api.ts +20 -0
  19. package/src/client/catalog.ts +1 -1
  20. package/src/client/index.ts +1 -1
  21. package/src/client/panel/access-card.tsx +294 -0
  22. package/src/client/panel/access-tab.tsx +46 -0
  23. package/src/client/panel/advanced-tab.tsx +80 -0
  24. package/src/client/panel/cleanup-card.tsx +106 -0
  25. package/src/client/panel/connection-tab.tsx +214 -0
  26. package/src/client/panel/credentials-card.tsx +104 -0
  27. package/src/client/panel/index.tsx +862 -0
  28. package/src/client/panel/model-tab.tsx +23 -0
  29. package/src/client/panel/sessions-tab.tsx +21 -0
  30. package/src/client/panel/shared.tsx +488 -0
  31. package/src/client/panel/workspace-tab.tsx +40 -0
  32. package/src/client/styles.ts +24 -6
  33. package/src/config.ts +44 -12
  34. package/src/domain.ts +34 -3
  35. package/src/feishu/agent-context.ts +9 -5
  36. package/src/index.ts +32 -14
  37. package/src/routing/inbound.ts +88 -43
  38. package/src/service.ts +259 -65
  39. package/src/session/isolation.ts +277 -0
  40. package/src/session/manager.ts +63 -20
  41. package/src/session/repair.ts +2 -3
  42. package/src/session/routes.ts +2 -0
  43. package/src/session-commands.ts +41 -14
  44. package/src/settings/contract.ts +223 -47
  45. package/src/settings/directory-picker.ts +121 -0
  46. package/src/settings/routes.ts +49 -2
  47. package/src/settings/surface.ts +171 -25
  48. package/src/streamer.ts +70 -30
  49. package/src/workspaces.ts +50 -28
  50. package/src/client/panel.tsx +0 -1475
@@ -15,12 +15,12 @@
15
15
  | 6 | 立刻基于该条消息创建话题 | ✅ | `im.v1.message.reply` + `reply_in_thread: true`(官方文档明示“以话题方式进行回复(即创建话题)”);响应返回 `thread_id` |
16
16
  | 7 | 私聊也能创建话题 | ⚠️ 大概率 ✅ | `reply_in_thread` 文档未限制 chat_type;飞书客户端支持单聊话题。**需真机验证**,失败则降级为“单聊整体即话题”(见 §7 风险 R1) |
17
17
  | 8 | 基于话题创建 session,话题内后续消息延续该 session(无需再 @) | ✅ | 事件携带 `thread_id`;插件维护 `thread_id → sessionId` 映射,`thread_id` 命中即路由到既有 agent(`agent.followup()`) |
18
- | 9 | 私聊:**每个话题一个工作区** | ✅ | DSH 工作区=磁盘目录:`<data>/topics/p2p/<open_id>/<topic>/`,`ctx.workspaceRegistry.create(path, title)` |
19
- | 10 | 群聊:**每个话题一个工作区**(按 用户 × 群 分组,同群不同用户互不影响) | ✅ | `<data>/topics/groups/<chat_id>/<open_id>/<topic>/`,session 以 `meta.cwd` 落盘归属该工作区 |
18
+ | 9 | 私聊:工作区按人、话题按目录隔离 | ✅ | 工作区 `<baseDir>/p2p/<open_id>/`(`ctx.workspaceRegistry.create`),话题工作目录 `<工作区>/topics/<topic>/`(`ctx.workingDirectory` 切换;Git 仓库可自动用 worktree)。见 `docs/workspace-isolation-design.md` |
19
+ | 10 | 群聊:每个群成员独立工作区,话题按目录隔离 | ✅ | 工作区 `<baseDir>/groups/<chat_id>/<open_id>/`,话题工作目录 `topics/<topic>/`;同群不同用户互不影响 |
20
20
  | 11 | 流式卡片(card schema 2.0),500ms 刷新 | ✅ | `msg_type: "interactive"` 卡片 2.0 JSON;`im.v1.message.patch` 整体替换卡片。单条消息更新限频 **5 QPS**(官方文档),2 QPS(500ms)安全 |
21
21
  | 12 | 折叠面板:带边框、默认折叠、成功三态与失败终态、右侧箭头随折叠翻转 | ✅ | 卡片 2.0 原生组件 `collapsible_panel`:`border`、`expanded: false`、`header.title`、`icon` + `icon_expanded_angle: 180`(展开时箭头旋转向上) |
22
22
  | 13 | 正文富文本 | ✅ | 卡片 2.0 `markdown` 组件(lark markdown 子集) |
23
- | 14 | session 信息两行小字 | ✅ | `note` 组件 + `img` 图标;数据来自 DSH:session id、usage(input/output/cacheRead/cacheWrite)、`ctx.tokenMeter` 上下文压力、模型/推理强度 |
23
+ | 14 | 结算页脚(分割线 + 两行小字 + icon,主卡完成后一次) | ✅ | `note`(markdown + `text_size: "notation"`)两行页脚:第一行 🧠 模型/推理强度、🔑 session id、⏱ 本轮累计推理时长;第二行 📊 上下文压力(含窗口占比)、📩📨 session 累计 input/output token、⚡ 缓存命中;只在回合结束后的主卡渲染一次,首帧/流式中/续卡都没有 |
24
24
  | 15 | 流式结束:移除 Typing;成功改为 DONE,失败改为 No | ✅ | 读取 Harness `turn/end.reason`;`kind: error` 渲染结构化错误并 create `emoji_type: "No"`,其余终态 create `emoji_type: "DONE"` |
25
25
 
26
26
  **结论:第一版可以实现,全部使用飞书官方 SDK,无需任何非官方接口或 hack。**
@@ -79,8 +79,9 @@
79
79
 
80
80
  | 概念 | 映射 | 存储 |
81
81
  |------|------|------|
82
- | 工作区(私聊) | `(bot, open_id, topic)` → 目录 `<data>/topics/p2p/<open_id>/<topic>/` | `ctx.workspaceRegistry` + 磁盘 |
83
- | 工作区(群聊) | `(bot, chat_id, open_id, topic)` → 目录 `<data>/topics/groups/<chat_id>/<open_id>/<topic>/` | 同上 |
82
+ | 工作区(私聊) | `(bot, open_id)` → 目录 `<baseDir>/p2p/<open_id>/`(header cwd) | `ctx.workspaceRegistry` + 磁盘 |
83
+ | 工作区(群聊) | `(bot, chat_id, open_id)` → 目录 `<baseDir>/groups/<chat_id>/<open_id>/` | 同上 |
84
+ | 话题工作目录 | `<工作区>/topics/<topic>/`(实际 cwd,附件落 `<目录>/attachments/`) | Session 日志 `working-directory/change` + 路由记录 `sessionDir` |
84
85
  | session | `thread_id` → DSH `sessionId`(每话题一个) | 插件 `storageDomain` 域 `channels_routes` |
85
86
  | 会话延续 | 事件 `thread_id` 命中映射 → `agent.followup()` | — |
86
87
 
@@ -89,9 +90,9 @@
89
90
  **首次触发(私聊任意消息 / 群聊 @ 消息):**
90
91
 
91
92
  1. 收到事件 → 异步 `reaction.create("Typing")`(非阻塞,记录 reaction_id)。
92
- 2. 确定工作区目录(不存在则 mkdir + `workspaceRegistry.create`)。
93
+ 2. 确保用户工作区(不存在则 mkdir + `workspaceRegistry.create`),规划话题工作目录(普通子目录或 Git worktree)。
93
94
  3. 通过 `im.message.reply(message_id, { reply_in_thread: true, msg_type: "interactive", content: 卡片初始 JSON })` 创建话题并发出卡片第一帧(折叠面板标题“推理中”),拿到 `thread_id` + `message_id`。
94
- 4. `ctx.agents.create({ sessionId: <uuid>, meta: { cwd: 工作区目录 }, agentOptions: { provider, model, maxTokens }, setup: 安装 reasoning effort / preset 等 })`;`setup` 中注入首个 user 消息(`seed` 或创建后 `followup`)。
95
+ 4. `ctx.agents.create({ sessionId: <uuid>, meta: { cwd: 用户工作区 }, ... })`,随后 `ctx.workingDirectory.set(agent, 话题目录)`(worktree 模式由 `ctx.worktrees.create` 完成切换),再投递首个 user 消息。
95
96
  5. 持久化 `thread_id → sessionId` 映射。
96
97
 
97
98
  **话题内续聊(无需 @):**
@@ -104,11 +105,11 @@
104
105
  **流式渲染:**
105
106
 
106
107
  - 订阅 `session/event`,按 `(sessionId, turn)` 聚合:`assistant/chunk` 里 `text-delta` → 正文,`reasoning-delta` → 折叠面板内容。
107
- - **活动状态两行**(互相独立、互不顶替):**状态行**折叠 `tool-workflow/run-start / agent-start / agent-end / run-end`(workflow 进度:完成数/总数、阶段、活动成员、结束原因),run 结束转为 `结束(完成)` 并**保留到最终卡片**(后续工具调用/正文不顶掉);子代理完成后汇总为 `子代理完成:a、b` 同样留在最终卡片;**工具行**显示父会话当前 `tool/call`(subagent 取 description、workflow 取 meta.name),`tool/result` 即清、最终卡片收起。两行可同屏(如 `🧵 工作流 xxx 结束(完成)` + `🔧 调用工具:bash`);无正文输出的等待期以 `statusRefreshMs`(默认 5s)慢心跳刷新“已运行 Ns”计时——进入 subagent / workflow 时卡片不再静默等待。
108
+ - **活动状态两行**(**只在当前正在流式输出的那张卡上渲染**——已冻结的续卡/主卡不再重复;互相独立、互不顶替):**状态行**折叠 `tool-workflow/run-start / agent-start / agent-end / run-end`(workflow 进度:完成数/总数、阶段、活动成员、结束原因),run 结束转为 `结束(完成)` 并**保留到最终卡片**(后续工具调用/正文不顶掉);子代理完成后汇总为 `子代理完成:a、b` 同样留在最终卡片;**工具行**显示父会话当前 `tool/call`(subagent 取 description、workflow 取 meta.name),`tool/result` 即清、回合结束后收起(完成态只保留结算状态行:工作流结论 / 子代理完成摘要)。两行可同屏(如 `🧵 工作流 xxx 结束(完成)` + `🔧 调用工具:bash`);无正文输出的等待期以 `statusRefreshMs`(默认 5s)慢心跳刷新“已运行 Ns”计时——进入 subagent / workflow 时卡片不再静默等待。
108
109
  - **子代理进度(跨会话映射)**:子代理是独立 session(header `parentSession` 指向主会话、`origin: 'subagent'`),其 `subagent/descriptor`(label)、`tool/call`(当前工具)、`turn/end`(结束移除)经全局 `session/event` 总线折叠进主卡片的同一状态行;workflow 成员经 `tool-workflow/agent-start` 的 `childId` 登记在 run 折叠里、跳过通用子代理折叠以免重复。**只显示状态不显示输出**:子代理正文是父模型的中间产物(工具契约“You receive its result, not its intermediate steps”),绝不进卡片正文。
109
110
  - 500ms 节流器(仅内容变化时 patch),拼接卡片 2.0 JSON 全量 `im.v1.message.patch`。
110
- - 折叠面板成功三态:首个 reasoning 增量前“推理中”;推理结束、正文开始后“回复中”;成功 `turn/end` 后“回复结束”。失败 `turn/end` 则进入“回复失败”。
111
- - 终态:`turn/end.reason.kind === "error"` 时卡片进入「回复失败」并展示 `error.message` / `error.code`,再执行 `reaction.delete(Typing)` → `reaction.create("No")`;其他终态进入「回复结束」并添加 `DONE`。每轮模型头与 usage 单独折叠,失败发生在 `request/header` 前时不显示上一轮的模型、推理强度或缓存命中。
111
+ - 折叠面板成功三态:首个 reasoning 增量前“推理中”(此时面板兼作阶段指示灯与占位);推理结束、正文开始后“回复中”;成功 `turn/end` 后“回复结束”。失败 `turn/end` 则进入“回复失败”。`thinking` 阶段面板始终渲染;进入 `replying` / `done` / `error` 后仅当存在推理内容才渲染——无推理的回合结算后不出现空面板。
112
+ - 终态:`turn/end.reason.kind === "error"` 时错误只写入正文一次(`❌ **本轮运行失败**` + `error.message`,有 `error.code` 时追加 `错误码:<code>`),不再重复 🔴 状态行,再执行 `reaction.delete(Typing)` → `reaction.create("No")`;其他终态进入「回复结束」并添加 `DONE`。每轮模型头与 usage 单独折叠,失败发生在 `request/header` 前时页脚中模型/缓存命中显示 `—`、推理强度省略,不沿用上一轮;推理时长由插件按「每个 step 首个推理增量 → 该 step 输出结束(首个正文增量或 step end)」的墙钟时间跨 step 累加,工具执行时间不计入。
112
113
 
113
114
  ### 3.3 卡片 JSON(卡片 JSON 2.0 结构)
114
115
 
@@ -116,44 +117,71 @@
116
117
  > - `schema: "2.0"` 与顶层 `elements` 混用会被拒(`230099 / 200621: unknown property, property: elements`)——2.0 的组件必须在 `body` 里;
117
118
  > - 折叠面板 header 的 `icon` 只支持 `standard_icon`(图标库 token)或 `custom_icon`,`tag: "img"` 非法(面板会被客户端丢弃);此处用内置 `down-small-ccm_outlined` 下箭头,无需上传图片。
118
119
 
120
+ 主卡(结算后)的元素顺序为 **推理折叠面板 → 可选状态行 → 可选工具行 → 正文 → 分割线 → 两行结算页脚**;正文内部(正文上下)不再使用 `hr` 分割线,正文与页脚之间有一条用于分层的 `hr`。页脚只在主卡、且只在回合结束后渲染一次:
121
+
119
122
  ```jsonc
120
123
  {
121
124
  "schema": "2.0",
122
125
  "config": { "width_mode": "fill" },
123
126
  "body": {
124
127
  "elements": [
125
- { // 第一段:推理折叠面板
128
+ { // ① 推理折叠面板:thinking 阶段始终渲染(兼阶段指示灯/占位);replying/done/error 仅在存在推理内容时渲染
126
129
  "tag": "collapsible_panel",
127
130
  "expanded": false,
128
131
  "border": { "color": "grey", "corner_radius": "8px" },
129
132
  "header": {
130
- "title": { "tag": "plain_text", "content": "推理中" }, // 推理中|回复中|回复结束
133
+ "title": { "tag": "plain_text", "content": "回复结束" }, // 推理中|回复中|回复结束|回复失败
131
134
  "icon": { "tag": "standard_icon", "token": "down-small-ccm_outlined" }, // 折叠向下
132
135
  "icon_position": "right",
133
136
  "icon_expanded_angle": 180 // 展开时旋转 180° → 箭头向上
134
137
  },
135
138
  "elements": [ { "tag": "markdown", "content": "<推理内容>" } ]
136
139
  },
137
- { "tag": "hr" },
138
- { // 第二段:回复正文(富文本 markdown,2.0 全语法)
140
+ { // ② 可选活动行(小字):结算状态行保留到最终卡片;工具行只在流式中出现,结算后收起
141
+ "tag": "markdown",
142
+ "text_size": "notation",
143
+ "content": "🧵 工作流 audit 结束(完成) · 🤖 子代理完成:检索实现方案"
144
+ },
145
+ { // ③ 正文(富文本 markdown,2.0 全语法)
139
146
  "tag": "markdown", "content": "<正文>"
140
147
  },
141
- { "tag": "hr" },
142
- { // 第三段:session 信息,两行小字(2.0 无 note 组件 → markdown + notation 字号)
148
+ { "tag": "hr" }, // ④ 正文与元数据之间的分割线(仅结算后的主卡出现)
149
+ { // ⑤ 结算页脚第一行(小字):模型 · 会话 · 推理时长
143
150
  "tag": "markdown",
144
151
  "text_size": "notation",
145
- "content": "🔑 om_session_xxxx · ⬆ input token 12,345 · ⬇ output token 678"
152
+ "content": "🧠 模型 deepseek-v4-pro(max) · 🔑 会话 sess-42 · ⏱ 推理 12.3s"
146
153
  },
147
- { "tag": "markdown", "text_size": "notation",
148
- "content": "🧠 模型 deepseek-v4-pro(max) · ⚡ 缓存命中 87.3% · 📊 上下文 24.1k/128k" }
154
+ { // ⑥ 结算页脚第二行(小字):上下文(含占比)· 输入/输出 token · 缓存命中
155
+ "tag": "markdown",
156
+ "text_size": "notation",
157
+ "content": "📊 上下文 24,100/128,000 (19%) · 📩 输入 12,345 · 📨 输出 678 · ⚡ 缓存命中 87.3%"
158
+ }
159
+ ]
160
+ }
161
+ }
162
+ ```
163
+
164
+ 续卡(正文超出单卡预算时自动投放):`📄 回复分段 n` → 可选活动行(仅当该卡是当前活动卡)→ 正文,无面板、无页脚、无分割线:
165
+
166
+ ```jsonc
167
+ {
168
+ "schema": "2.0",
169
+ "config": { "width_mode": "fill" },
170
+ "body": {
171
+ "elements": [
172
+ { "tag": "markdown", "text_size": "notation", "content": "📄 回复分段 2" },
173
+ // 可选活动行(小字):仅当该卡正在流式输出时出现,冻结后不再重复
174
+ { "tag": "markdown", "content": "<续卡正文>" }
149
175
  ]
150
176
  }
151
177
  }
152
178
  ```
153
179
 
154
- - 第一行:`session id · input token · output token`(session 累计;可配置为按本轮回复合计)。
155
- - 第二行:`模型 model(effort) · 缓存命中 cacheRead/(cacheRead+input) · 上下文 pressureTokens/contextWindow`。
156
- - 单卡片 JSON 目标 ≤ 28 KB(飞书上限 30 KB,留 2 KB 冗余给切分后 token 数字增长);正文超长时**自动分段投放**——在段落/标题/代码块边界切分,**每张卡最多 5 个表格**(飞书硬限制 230099/11310,表格绝不从中间切断),续片以「回复分段 n」卡片跟进同一话题,全文无丢失;续卡被拒收时自动降级为纯文本消息;推理不跨卡,面板有独立的 5 KB 上限(`REASONING_MAX_BYTES`,含截断标记,超出保留头部 + 截断提示)且面板内表格省略;分段时预留 `CARD_PANEL_RESERVE_BYTES`(5.5 KB)给面板,正文与面板合计不超单卡目标。
180
+ - 主卡构成:推理折叠面板(`thinking` 阶段始终渲染;`replying` / `done` / `error` 仅当存在推理内容时渲染)→ 可选状态行(小字)→ 可选工具行(小字)→ 正文 markdown → 分割线 → 结算页脚(两行小字)。
181
+ - 活动两行互相独立、互不顶替,且只在当前正在流式输出的那张卡上渲染;冻结的续卡/主卡不再重复当前活动行。完成态只保留结算状态行(工作流结论 / 子代理完成摘要),工具行收起。
182
+ - 结算页脚内容分两行:第一行 `🧠 模型 {model}({effort}) · 🔑 会话 {sessionId} · ⏱ 推理 {时长}`;第二行 `📊 上下文 {tokens}/{window} ({占比}) · 📩 输入 {n} · 📨 输出 {n} · ⚡ 缓存命中 {pct}`。推理时长为本轮所有推理阶段的累计墙钟时间(每个 step 从首个推理增量到该 step 输出结束——首个正文增量或 step 结束;工具执行时间不计入;跨 step 累加;回合在推理中结束时计到 turn 结束;无推理为 `—`);占比 <10% 保留一位小数、≥10% 取整,无窗口时只显示 tokens。输入/输出为 session 累计口径;模型、推理强度、缓存命中按当前轮次(本轮在请求头产生前失败时模型/缓存命中显示 `—`、推理强度省略,不沿用上一轮);首帧与流式中不出现 0/— 空统计。
183
+ - 失败:错误只在正文出现一次(`❌ **本轮运行失败**` + 错误消息,有错误码时追加 `错误码:<code>`),不再有重复的状态行;无推理内容时也没有「回复失败」面板。
184
+ - 单卡片 JSON 目标 ≤ 28 KB(`CARD_TARGET_BYTES`),飞书硬上限 30 KB(`CARD_MAX_BYTES`,仅作绝对兜底);正文超长时**自动分段投放**——按整卡真实渲染测量(主卡探测面板最大窗口 + 完成态页脚,续卡无面板/页脚可用满 28 KB,约 27.8 K ASCII 字符、100 KB ASCII 正文约 4 张卡),断点统一避开代码围栏与表格(单个代码块/表格小于单卡预算时整体换卡,超过单卡预算时只能硬切、内容不丢),**每张卡最多 5 个表格**(飞书硬限制 230099/11310,单个表格小于单卡预算时不会被从中间切断),续片以「📄 回复分段 n」卡片跟进同一话题,全文无丢失;续卡被拒收时自动降级为纯文本消息;推理不跨卡,面板有独立的 5 KB 上限(`REASONING_MAX_BYTES`,含截断标记,超出保留头部 + 截断提示)且面板内表格省略。
157
185
 
158
186
  ## 4. DSH 侧集成点(全部为公开扩展点)
159
187
 
@@ -164,7 +192,7 @@
164
192
  | 续聊入队 | `agent.followup(message)`(next-turn FIFO,自动唤醒驱动) |
165
193
  | 流式增量 | 订阅 `session/event`,`assistant/chunk`(`StreamChunk` 含 `text-delta` / `reasoning-delta`),`assistant/message` 锚点含 usage |
166
194
  | token / 缓存 / 上下文 | usage:`{ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens }`;上下文:`ctx.tokenMeter.measure(session)` + `ctx.llm.resolveModelInfo().context` |
167
- | 工作区 | `ctx.workspaceRegistry.create(path, title)`(目录存在才可注册) |
195
+ | 工作区 | `ctx.workspaceRegistry.create(path, title)`(目录存在才可注册);`ctx.get('workingDirectory')` 切换会话实际目录;`ctx.get('worktrees')`(可选)创建 Git worktree |
168
196
  | 插件自身状态 | `ctx.storageDomain`(defineDomain + sqlite/json 后端) |
169
197
  | 加载方式 | 用户 cordis.yml 增加 `- name: '/path/to/plugin/src/index.ts'`(教程 07 确认本地文件路径加载,支持 HMR) |
170
198
 
@@ -176,7 +204,7 @@
176
204
  |---|------|------|------|
177
205
  | R1 | 私聊 `reply_in_thread` 是否生效未经文档明示 | 中 | 真机验证;失败自动降级:私聊整体视为“话题”,续聊继续同一 session(行为退化为常规 DM 机器人),不阻塞主流程 |
178
206
  | R2 | patch 全量替换卡片:用户手动展开折叠面板后,下一次 patch(500ms 内)会把它重新折叠 | 低(体验) | v1 接受:流式期间保持默认折叠;流式结束后不再 patch,用户可自由展开。备选:CardKit 2.1 局部更新(需评估申请门槛),列入 v2 |
179
- | R3 | 卡片 30 KB 上限 / 单卡表格数量上限(230099/11310) | 低 | 单卡预算 28 KB(2 KB 冗余);正文超长按干净断点分段投放(每卡 ≤ 5 表,表格不断裂,全文保留),分段预算预留面板 5.5 KB;续卡被拒降级纯文本;推理面板独立 5 KB 上限,与正文长度无关 |
207
+ | R3 | 卡片 30 KB 上限 / 单卡表格数量上限(230099/11310) | 低 | 单卡预算 28 KB(`CARD_TARGET_BYTES`,2 KB 冗余);正文超长按干净断点分段投放(每卡 ≤ 5 表;单个表格/代码块小于单卡预算时整体换卡不被切断,超过单卡预算时只能硬切但内容不丢);分段按整卡真实渲染测量——主卡探测面板最大窗口 + 完成态页脚,续卡无面板/页脚预留、可用满 28 KB;续卡被拒降级纯文本;推理面板独立 5 KB 上限,与正文长度无关 |
180
208
  | R4 | 消息/表情/更新的 5 QPS 限频 | 低 | patch 2 QPS 天然安全;多个并发 session 时加全局节流与 429 退避重试(错误码 230020) |
181
209
  | R5 | 机器人收不到自己发的消息(需过滤) | 低 | 事件 `sender.sender_type` / open_id 过滤 |
182
210
  | R6 | 用户撤回触发消息 | 低 | reaction 调用失败静默容忍(错误码 231002 等),不阻断流程 |
@@ -189,7 +217,7 @@
189
217
  | M0 脚手架 | pnpm workspace、`src/index.ts` 插件骨架(apply(ctx) + Config schema)、类型声明合并 | 可加载的空插件 |
190
218
  | M1 飞书接入 | WSClient 长连接、事件订阅、文本/post 解析、@ 判定、凭据配置(`ctx.credentials` 凭据库引用,不落明文) | 能收消息并回文本 |
191
219
  | M2 话题与路由 | Typing/DONE reaction、reply_in_thread 建话题、storageDomain 路由表、懒恢复、工作区目录创建 | 私聊/群聊/话题群三通道贯通 |
192
- | M3 流式卡片 | 卡片 2.0 渲染器、session/event 订阅、500ms 节流 patch、折叠面板三态、session 信息两行 | 完整流式体验 |
220
+ | M3 流式卡片 | 卡片 2.0 渲染器、session/event 订阅、500ms 节流 patch、折叠面板三态、两行结算页脚(完成后主卡一次) | 完整流式体验 |
193
221
  | M4 联调加固 | 真机自建应用全流程验证(含 R1 私聊话题)、限频/退避/异常兜底、README + 配置文档 | 可交付 v1 |
194
222
 
195
223
  ## 7. 仓库结构(计划)
@@ -205,7 +233,7 @@ dsh_channels/
205
233
  │ ├── feishu/reactions.ts # Typing/DONE 控制器
206
234
  │ ├── feishu/threads.ts # reply_in_thread 建话题/回复
207
235
  │ ├── feishu/card.ts # 卡片 2.0 JSON 渲染器(折叠面板/正文/信息行)
208
- │ ├── core/workspace.ts # (chat,user) → 工作区目录
236
+ │ ├── core/workspace.ts # 用户工作区 / 话题目录解析
209
237
  │ ├── core/sessions.ts # thread_id → sessionId 路由 + 懒恢复
210
238
  │ └── core/streamer.ts # session/event 聚合 + 500ms 节流 patch
211
239
  ├── tests/ # 以 stub 官方 SDK 的单元测试
@@ -84,7 +84,9 @@ FEISHU_APP_SECRET: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
84
84
  chmod 600 $DSH_HOME/.credentials.yaml
85
85
  ```
86
86
 
87
- 解析优先级(由 `dsh-credentials-local` 提供,热加载):进程环境变量 > `.credentials.yaml` > 启动目录 `.env` > `$DSH_HOME/.env`。不想用凭据库时,直接导出环境变量也能生效。凭据改动后重启 dsh 生效。
87
+ 解析优先级(由 `dsh-credentials-local` 提供,热加载):进程环境变量 > `.credentials.yaml` > 启动目录 `.env` > `$DSH_HOME/.env`。不想用凭据库时,直接导出环境变量也能生效。凭据改动后不必重启 dsh:面板保存会即时写入并自动连接/提示重连;手改 `.credentials.yaml` 由凭据层热加载,但运行中的连接不会自动换用新值,需在面板点「重启飞书连接」。
88
+
89
+ **连接时机**:启用插件时不会无条件连接飞书。插件加载后先检查 `appId` / `appSecret` 两个凭据引用是否都能解析:都就绪才建立长连接;缺任意一项时插件照常加载(设置面板可用),状态为「待配置」且不连接;连接失败只记录在面板里,插件入口保持加载。因此可以「先装插件 → 在面板里填凭据 → 自动连接」,也可以在装插件前就把凭据写进凭据库。
88
90
 
89
91
  ### 3.2 写补丁层
90
92
 
@@ -104,6 +106,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
104
106
  requireMention: true # 普通群需 @ 机器人
105
107
  dmMode: 'open' # 私聊通道开关
106
108
  threadFallback: 'degrade' # 话题被拒时降级为会话级路由
109
+ topicIsolation: 'auto' # 话题目录隔离:auto(Git 仓库用 worktree,否则子目录)/ subdir / worktree
107
110
  approvalTimeoutMs: 300000 # HITL 审批决策卡等待时长(默认 5 分钟)
108
111
  accessMode: 'guarded' # 准入模式:guarded(默认)| open(旧行为,不安全)
109
112
  adminOpenId: 'ou_xxx' # 管理员 open_id(可留空,留空时 guarded 下拒绝一切对话)
@@ -115,8 +118,8 @@ chmod 600 $DSH_HOME/.credentials.yaml
115
118
 
116
119
  | 配置项 | 默认 | 说明 |
117
120
  |--------|------|------|
118
- | `appId` / `appSecret` | 必填 | 凭据引用名(shell 标识符),经 `ctx.credentials` 解析;明文密钥会被拒绝 |
119
- | `baseDir` | `./channels-data` | 工作区目录树根:每个话题一个独立工作区,`topics/p2p/<open_id>/<topic>/`、`topics/groups/<chat_id>/<open_id>/<topic>/`(模型/工具产生文件的落点);Web 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的话题 |
121
+ | `appId` / `appSecret` | `FEISHU_APP_ID` / `FEISHU_APP_SECRET` | 凭据引用名(shell 标识符),经 `ctx.credentials` 解析;仅当凭据库使用其它变量名时才需要覆盖。明文密钥会被拒绝 |
122
+ | `baseDir` | `./channels-data` | 工作区目录树根:每个用户一个工作区(私聊 `p2p/<open_id>/`,群聊 `groups/<chat_id>/<open_id>/`),每个话题的工作目录为 `<工作区>/topics/<话题>/`(模型/工具产生文件的落点);Web 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的会话 |
120
123
  | `provider` / `model` | 部署默认 | 新建 session 的路由;省略时继承部署默认 |
121
124
  | `reasoningEffort` | 适配器/provider 默认 | Session 初始/重置推理档位;可在话题内用 `/effort` 覆盖 |
122
125
  | `maxTokens` | 适配器默认 | 每次模型请求输出上限 |
@@ -124,6 +127,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
124
127
  | `dmMode` | `open` | 私聊开关 |
125
128
  | `requireMention` | `true` | 普通群是否需要 @ 机器人(话题内续聊不受影响) |
126
129
  | `threadFallback` | `degrade` | 话题回复被拒时:`degrade` 降级为会话级延续 / `strict` 直接失败 |
130
+ | `topicIsolation` | `auto` | 话题工作目录形式:`auto`(工作区含 `.git` 时用 Git worktree,否则普通子目录)/ `subdir` / `worktree`(失败回退子目录) |
127
131
  | `approvalTimeoutMs` | `300000` | HITL 审批决策卡等待时长(10s–1h);超时自动拒绝 |
128
132
  | `accessMode` | `guarded` | 准入模式:`guarded` 仅管理员+白名单;`open` 保留旧行为(任何可用范围用户可对话,不安全) |
129
133
  | `adminOpenId` | 未设置 | 唯一管理员 open_id(`ou_` 开头);留空 + guarded = 拒绝一切对话(`/userid` 引导仍可用)。面板可改 |
@@ -135,7 +139,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
135
139
  插件默认 `accessMode: 'guarded'`:**没有配置管理员时,所有对话都会被拒绝**(这是 fail-closed 的安全默认值)。初始化顺序:
136
140
 
137
141
  1. 启动 dsh 后在飞书**私聊机器人**发送 `/userid`(未配置管理员时任何私聊用户都可以用,只回固定文本,不建会话)→ 复制返回的 `ou_...`;
138
- 2. 打开 Web **设置 → 飞书渠道 → 常用配置**,把 `ou_...` 填进「管理员 open_id」并保存(立即生效);也可以直接写 profile 的 `adminOpenId`;
142
+ 2. 打开 Web **设置 → 飞书渠道 → 访问授权**,把 `ou_...` 填进「管理员 open_id」并保存(立即生效);也可以直接写 profile 的 `adminOpenId`;
139
143
  3. 现在管理员私聊机器人即可正常对话。**其他用户私聊会收到一条固定提示**(“非管理员禁用私聊功能哦…”),不会建会话;
140
144
  4. **给群授权**(三种入口任选;目的是放行**非管理员**,管理员本人在任何群都能直接对话):
141
145
  - 机器人被拉进群 → 管理员私聊收到**群信息卡**(群名/`chat_id`/成员数/邀请人)→ 点「授权本群」;
@@ -175,30 +179,32 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
175
179
 
176
180
  > 第二行只在宿主挂载了 `approval` 服务时出现;若显示 `主机未挂载 approval 服务,HITL 审批应答关闭`,说明该部署未启用 HITL(`approval/request` 事件永远不会派发),并非插件故障。
177
181
 
178
- **常见启动失败**:
182
+ **未配置凭据(不是启动失败)**:日志会提示 `飞书应用凭据未配置完整(缺少 FEISHU_APP_…)`,插件照常加载但**不连接**;打开 Web **设置 → 飞书渠道 → 连接**,按三步引导填写 App ID / App Secret 并保存后自动连接。
183
+
184
+ **常见连接失败**(插件保持加载,面板「连接」标签页显示原因,可重试):
179
185
 
180
186
  | 输出 | 原因 |
181
187
  |------|------|
182
188
  | `could not resolve bot identity via /open-apis/bot/v3/info` | App ID/Secret 错误,或未开启机器人能力 |
183
189
  | `permission_denied` / `invalid tenant access token` | 同上,先核对凭证再查权限 |
184
- | `credential "FEISHU_APP_ID" is not configured` | `.credentials.yaml` 缺对应条目(或环境变量未导出),见 §3.1 |
190
+ | `credential "FEISHU_APP_ID" is not configured` | 连接时解析不到凭据(例如刚手改了凭据库但面板状态尚未刷新,或环境变量缺失),见 §3.1;面板保存后会自动重连 |
185
191
  | `is readable beyond its owner (mode 644)` | `.credentials.yaml` 权限过宽,`chmod 600` 后重启 |
186
192
  | `dsh: plugin tree failed to load … dsh-channels` | 插件本身加载失败(多为 `name:` 拼写或未安装),看后续 cause |
187
193
  | 无任何 `dsh-channels` 输出 | 插件未装载:检查 `--dump-config` 是否含条目、补丁层是否用了 `insert:` 格式 |
188
194
 
189
- > 说明:插件内部其余日志走 dsh 的 cordis logger,web 组合下部分早期日志可能只进缓冲不落终端;上面两条**生命周期日志**(连接成功 / 启动失败)额外直接输出到控制台,联调时以它们为准。
195
+ > 说明:插件内部其余日志走 dsh 的 cordis logger,web 组合下部分早期日志可能只进缓冲不落终端;**生命周期日志**(连接成功 / 连接失败 / 凭据未配置)额外直接输出到控制台,联调时以它们为准。
190
196
 
191
197
  ## 5. 三通道联调清单
192
198
 
193
199
  | # | 场景 | 操作 | 预期 |
194
200
  |---|------|------|------|
195
- | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;卡片底部两行 session 信息 |
196
- | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片变 **回复失败**,显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
201
+ | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板;无推理内容的回合结算后面板省略)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;**完成后主卡底部出现分割线与两行元数据**(第一行:模型/会话/推理时长;第二行:上下文占比/输入输出 token/缓存命中),首帧与流式中没有这两行 |
202
+ | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片进入 **回复失败** 终态(有推理内容时面板标题为「回复失败」,否则不渲染面板),正文显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
197
203
  | 1b | Session 推理强度切换 | 话题内依次执行 `/effort`、`/effort tiny`、发普通消息、重启后续聊、`/effort reset` | 首次显示配置来源;切换后下一轮请求使用 `tiny`,重启恢复后仍保持;reset 后恢复配置值。若模型不支持 `tiny`,下一轮按 1a 失败,不会静默回退 |
198
- | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:卡片底部 session id 不变,input/output token 累计增长 |
204
+ | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:完成后主卡结算页脚第一行的 session id 不变,input/output token 累计增长 |
199
205
  | 3 | 群聊 @ | 在普通群 @ 机器人发消息 | 同 #1;该消息成为新话题根 |
200
206
  | 4 | 群聊话题内 | 在上一步的话题里直接发消息(不 @) | 延续该 session,不再要求 @ |
201
- | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**独立的第二个工作区**(`topics/groups/<chat_id>/<用户 open_id>/<topic>/`),与原用户的话题工作区互不可见 |
207
+ | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**该用户的独立工作区**(`groups/<chat_id>/<用户 open_id>/`),会话实际工作在它自己的 `topics/<话题>/` 下,与原用户的话题目录互不可见 |
202
208
  | 6 | 话题群 | 在话题群某话题中 @ 机器人 | 机器人直接在该话题内回复卡片(不新建话题) |
203
209
  | 7 | 折叠面板 | 点面板标题 | 展开推理内容,右侧箭头翻转向上;再点收起 |
204
210
  | 8 | 重启恢复 | 重启 dsh 后在旧话题里发消息 | 机器人懒恢复原 session,token 统计继续累计 |
@@ -210,7 +216,7 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
210
216
  | 14 | 管理员直通(免群授权) | 刚被拉进未授权群时,管理员直接 @ 机器人发消息 | 正常开会话(管理员不受白名单限制);不授权该群也不影响管理员使用 |
211
217
  | 14a | 群授权与群内准入 | 管理员点「授权本群」(或在群内发 `/auth allow`);未授权用户 @;管理员 `/auth user add <open_id>` 后该用户再 @ | 授权前非管理员 @ 无响应(管理员除外);授权后白名单用户可分别开会话;未授权用户 @ 不建会话且管理员收到用户授权卡;授权该用户后可正常开会话 |
212
218
  | 15 | 撤销与持久化 | `/auth revoke`(群内)/ `/auth user remove <open_id>`;重启 dsh 后再试 | 撤销后对应主体立即无法开启对话;重启后已授权群/用户仍有效(持久化) |
213
- | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的工作区;A 自己发言正常 |
219
+ | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的话题目录;A 自己发言正常 |
214
220
 
215
221
  ### 5.0 HITL 审批验证点(重点)
216
222
 
@@ -224,16 +230,16 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
224
230
 
225
231
  飞书官方文档没有明示 `reply_in_thread` 对**私聊**的约束。联调时在私聊里观察第 1 步:消息是否真的聚合成“话题”形态(话题气泡)。若飞书拒绝话题回复,插件按 `threadFallback: 'degrade'` 自动改为普通回复,此时私聊内所有消息延续同一个 session(等价于“私聊整体即话题”),功能不受阻。若你希望私聊必须话题形态,把 `threadFallback` 设为 `strict` 并反馈错误码。
226
232
 
227
- ### 5.2 验证工作区归属
233
+ ### 5.2 验证工作区与话题目录
228
234
 
229
- 打开 dsh Web GUI → 工作区列表,应看到:
235
+ 打开 dsh Web GUI → 工作区列表,应看到**每人一个**项目:
230
236
 
231
237
  ```
232
- 飞书私聊 · ou_xxx · omt_xxx (对应 <baseDir>/topics/p2p/ou_xxx/omt_xxx/)
233
- 飞书群聊 · oc_yyy · ou_xxx · omt_xxx (对应 <baseDir>/topics/groups/oc_yyy/ou_xxx/omt_xxx/)
238
+ 飞书私聊 · ou_xxx (对应 <baseDir>/p2p/ou_xxx/)
239
+ 飞书群聊 · oc_yyy · ou_xxx (对应 <baseDir>/groups/oc_yyy/ou_xxx/)
234
240
  ```
235
241
 
236
- 每个新话题会创建新的工作区目录;同一话题内 `/renew` 仍复用该工作区。
242
+ 每个新话题在该工作区内创建独立的话题工作目录 `<工作区>/topics/<话题 id>/`,会话列表仍归属该项目;同一话题内 `/renew` 复用同一话题目录。也可在飞书里用 `/workspace` 查看当前会话的实际工作目录。
237
243
 
238
244
  ## 6. 常见问题排查
239
245
 
@@ -242,17 +248,17 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
242
248
  | 私聊无响应 | 用户是否在应用**可用范围**内;权限 `im:message.p2p_msg:readonly` 是否随版本发布;若返回的是“非管理员禁用私聊功能哦”,说明准入生效(把该用户加入白名单或让管理员对话) |
243
249
  | 私聊提示“机器人尚未配置管理员” | `accessMode: 'guarded'` 且未配置 `adminOpenId`:按 §3.3 在面板填写管理员 open_id |
244
250
  | 机器人被拉进群但管理员没收到群信息卡 | 是否订阅 **im.chat.member.bot.added_v1** 事件并重新发布版本;群信息读取权限是否已开通(`getChatInfo` 失败只会缺群名,不影响卡片本体) |
245
- | 群内 @ 机器人无响应(登录台无 access 报错) | 管理员不受白名单限制,可直接对话;**非管理员**需要该群已授权且自己在用户白名单里。可用 `/auth status`(管理员)或 Web 面板「访问与授权」确认 |
251
+ | 群内 @ 机器人无响应(登录台无 access 报错) | 管理员不受白名单限制,可直接对话;**非管理员**需要该群已授权且自己在用户白名单里。可用 `/auth status`(管理员)或 Web 面板「访问授权」标签页确认 |
246
252
  | `/auth` 无反应 | `/auth` 仅管理员可用;非管理员在群里会被静默忽略、私聊会收到固定提示 |
247
253
  | 群聊 @ 无响应 | 事件订阅是否勾选“接收群聊中 @ 机器人消息”;机器人是否在群内且未被群主禁言 |
248
254
  | 有 Typing 但无卡片 | 权限 `im:message` / `im:message:update`;卡片 2.0 需要较新的飞书客户端(升级客户端后重试) |
249
- | 卡片正文只到某长度 | 旧版行为;新版单卡预算 28 KB(30 KB 上限留 2 KB 冗余),超长正文自动分段为多张续卡(「回复分段 n」,每卡 ≤ 5 个表格),全文保留;推理面板独立 5 KB 上限,超出的推理只保留开头并追加「已截断」提示 |
255
+ | 卡片正文只到某长度 | 旧版行为;新版单卡目标 28 KB(`CARD_TARGET_BYTES`,飞书上限 30 KB 留 2 KB 冗余),超长正文自动分段为多张续卡(「📄 回复分段 n」,每卡 ≤ 5 个表格,断点避开代码围栏与表格),续卡无面板/页脚、可用满 28 KB,全文保留;推理面板独立 5 KB 上限,超出的推理只保留开头并追加「已截断」提示 |
250
256
  | 报错 230099 / `ErrCode: 11310 card table number over limit` | 飞书单卡表格数量超限(真机验证上限 5 个);新版已按“每卡最多 5 个表格”自动切分,不再触发;历史版本请重装插件并重启 dsh |
251
257
  | 流式期间手动展开面板又被收起 | v1 已知取舍:patch 是全量替换卡片,500ms 内的下一次刷新会重置折叠状态;流式结束后可自由展开 |
252
258
  | 错误码 `230013` | 用户不在可用范围 / 被禁用 |
253
259
  | 错误码 `230071` | 该群不支持话题回复 → 自动降级路径(见 §5.1) |
254
260
  | 错误码 `230099`(`parse card json err` / `unknown property, property: elements`) | 卡片结构混用:2.0 声明下把组件放在顶层 `elements`(应在 `body.elements`)。新版插件已改为规范 2.0 结构:重装插件并重启 dsh |
255
- | 折叠面板不显示 / 正文不是富文本 | 旧版卡片的两个已知问题(`icon.tag: "img"` 非法、1.0 结构 markdown 语法受限),新版插件已切 2.0 结构 + 内置 `standard_icon` 箭头:重装插件并重启 dsh |
261
+ | 折叠面板不显示 / 正文不是富文本 | 无推理内容的回合结算后省略面板属**正常行为**;若旧版插件连推理内容也不显示,是旧卡片的两个已知问题(`icon.tag: "img"` 非法、1.0 结构 markdown 语法受限),新版插件已切 2.0 结构 + 内置 `standard_icon` 箭头:重装插件并重启 dsh |
256
262
  | 审批卡不出现 | 确认该部署启用了 HITL:启动日志应有「HITL 审批应答已就绪」;若显示「主机未挂载 approval 服务」则该部署不会派发审批请求;另外确认当前会话的审批策略不是 `never`(`/permission danger-full-access` 会关闭审批) |
257
263
  | 审批卡点击无反应 | 未订阅 **card.action.trigger** 回调(或订阅后未重新发布版本),见 §1 第 4 步;按 §5.0 重新核对 |
258
264
  | 错误码 `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
  |---|---|---|
@@ -45,12 +46,12 @@ src/
45
46
  | 问答卡自由文本回答 | ✅ | 无选项问题直接开放答题;阻塞期间普通文本回复即答案(`custom`) |
46
47
  | `/model` 查看与切换 | ✅ | `session/request-policy.ts` 统一 provider/model/effort;`ctx.llm.listModels` 校验;经 `command/run` 持久化 |
47
48
  | 附件输入 | ✅ | `feishu/inbound-attachments.ts`:图片/文件流式落盘到工作区 `attachments/`、合并转发记录转存 `.txt`;不触发模型,Agent 用工具按需读取;`acceptAttachments` 可配 |
48
- | 工作区清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,删除插件工作区并把路由退休为墓碑,Session 日志保留 |
49
+ | 目录清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,删除过期话题目录/旧版话题工作区并把路由退休为墓碑,用户工作区与 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,78 @@
1
+ # 工作区粒度与会话隔离设计
2
+
3
+ > 状态:已确认并实现(v1)。目标:工作区数量随「人」增长而不是随「话题」增长,同时话题之间不互相踩文件。
4
+
5
+ ## 1. 问题
6
+
7
+ 旧模型(`一个话题 = 一个 DSH 工作区`)把两个独立维度绑在了一起:
8
+
9
+ - **项目维度**:`ctx.workspaceRegistry` 是持久化项目列表,GUI 侧边栏一个分区一项,随话题无上限增长;
10
+ - **隔离维度**:模型工具产生的文件必须按话题隔开。
11
+
12
+ DSH 本身允许这两个维度解耦,见下。
13
+
14
+ ## 2. DSH 的三个关键事实
15
+
16
+ 1. **工作区归属只认不可变的 `SessionHeader.cwd`**:`Workspace.attachSession()` 校验 header.cwd 与工作区路径(realpath 后)完全相等;GUI 分组、重启 bootstrap 同理。
17
+ 2. **实际工作目录可变**:`ctx.workingDirectory.set(agent, dir)` 写 `working-directory/change` 事件、持久化、冷恢复后还原;`bash`、`fs`、`terminal` 工具都从 `workingDirectory` 取 cwd。因此会话可以「归属 A 工作区、实际在 B 目录干活」。DSH 官方 Git worktree 也是靠这个机制保留原项目归属。
18
+ 3. **沙箱写边界 = 不可变 header.cwd**,不随工作目录切换。因此本设计提供的是**行为层隔离**(工具默认在话题目录内工作、互不踩文件),不是防恶意的硬沙箱;硬边界需要 header.cwd 直接指向话题目录,代价是会话失去工作区归属(见 §6)。
19
+
20
+ ## 3. 目录布局
21
+
22
+ ```
23
+ <baseDir>/
24
+ p2p/<open_id>/ # 私聊用户工作区(注册 workspace,header.cwd)
25
+ topics/<topic-token>/ # 话题目录(会话实际工作目录)
26
+ attachments/ # 该话题的入站附件
27
+ groups/<chat_id>/<open_id>/ # 群聊中某成员的工作区(注册 workspace)
28
+ topics/<topic-token>/
29
+ attachments/
30
+ topics/... # 旧版「话题=工作区」目录,原样保留,不自动迁移
31
+ _staging/<chatType>/<chatId>/<openId>/ # 话题建立前的附件暂存
32
+ ```
33
+
34
+ - 私聊一个用户一个工作区;群聊**一个群 × 一个用户**一个工作区(不能做成「用户全局一个」:否则同一个人在不同群之间会串文件,A 群会话可能把 B 群的文件内容贴给 A 群成员)。
35
+ - `<baseDir>/p2p/...`、`<baseDir>/groups/...` 与旧版「用户 × 聊天」共享工作区路径一致,升级后旧会话仍归属同一个项目。
36
+ - 话题 token:真实话题用 `thread_id`;降级 chat-scoped 会话用其 session id。目录名经过安全字符校验。
37
+
38
+ ## 4. 隔离档位(`topicIsolation`)
39
+
40
+ | 值 | 行为 |
41
+ |---|---|
42
+ | `auto`(默认) | 工作区含 `.git`(即用户把工作区做成了 Git 仓库)时,每个话题用 DSH worktree(分支 `feishu-<token>`,checkout 在 `.agents/worktrees/`);否则用普通话题子目录 |
43
+ | `subdir` | 始终普通话题子目录,零 Git 依赖 |
44
+ | `worktree` | 始终尝试 worktree;服务缺失 / 非 Git 仓库 / 权限不足时告警并回退子目录 |
45
+
46
+ - 子目录模式:`mkdir <workspace>/topics/<token>` 后 `workingDirectory.set` 切过去。文档/数据类工作区零成本、不复制文件。
47
+ - worktree 模式:`ctx.worktrees.create(agent, { name: 'feishu-<token>' })`,返回的 checkout 即会话目录;分支与工作树由 Git 管理,适合「多个话题并发改同一个代码仓库」。
48
+ - 两个模式都保留会话在用户工作区下的 GUI 归属,因为 header.cwd 仍是用户工作区。
49
+ - 降级:宿主没有 `workingDirectory` / `worktrees` 服务、或 worktree 创建失败时,回退为「会话直接以话题目录为 cwd」(此时不再 attach 到工作区,会话在 GUI 中可能显示为未分组;文件隔离仍然成立)。
50
+
51
+ ## 5. 路由记录
52
+
53
+ `channels_routes` 的 v1 记录保持可读,新增可选字段:
54
+
55
+ - `sessionDir`:会话实际工作目录(话题目录 / worktree);缺省表示旧记录——直接用 `workspacePath` 作为会话目录。
56
+ - `worktreeBranch`:worktree 模式下的话题分支,供清理与恢复使用。
57
+
58
+ `workspacePath` 语义变化为「用户工作区(会话 header cwd / 沙箱根)」。
59
+
60
+ ## 6. 取舍与边界
61
+
62
+ - **隔离强度**:话题子目录/worktree 只保证「工具默认在话题目录工作」。同一用户工作区内的绝对路径访问仍然可行;沙箱写边界仍是用户工作区。
63
+ - **硬隔离**(不采用):把 header.cwd 指向话题目录,会话在 GUI 中不再归属工作区。只有当话题来源不可信、必须防互相读取时才值得。
64
+ - **附件**:仍落在当前话题目录的 `attachments/`;话题建立前收到的附件先暂存到 `_staging/`,首个 Session 创建时迁入。
65
+ - **`/renew`**:同一话题内新建 Session,复用同一话题目录(worktree 也复用)。
66
+ - **清理**:保留期只删除过期的话题目录 / 旧版话题工作区,**永不删除用户工作区**;活跃路由先退休为墓碑(旧话题消息收到「话题已过期」提示),Session 日志不删除。worktree 模式下只删除 checkout 目录,Git 分支与 worktree 管理记录按 DSH 语义保留(`git worktree prune` / `branch -D` 可回收)。
67
+ - **迁移**:不做自动迁移。旧版 `topics/...` 工作区继续服务旧路由,可由清理卡片按保留期回收;旧版「用户 × 聊天」共享工作区即新的用户工作区,直接复用。
68
+
69
+ ## 7. 实现位置
70
+
71
+ | 文件 | 职责 |
72
+ |---|---|
73
+ | `src/workspaces.ts` | 用户工作区 / 话题目录 / 标题 / 暂存目录解析 |
74
+ | `src/session/isolation.ts` | 隔离档位判定、工作目录进入、worktree 创建与降级恢复 |
75
+ | `src/routing/inbound.ts` | 建会话与续聊时的目录编排、附件迁移、路由写入 |
76
+ | `src/session/manager.ts` | 会话创建/恢复/`/renew` 的目录挂载与附件 guidance |
77
+ | `src/cleanup.ts` + `src/service.ts` | 按话题目录/旧版工作区回收的保留期策略与面板接线 |
78
+ | `src/domain.ts` | 路由记录 v1 兼容扩展(`sessionDir` / `worktreeBranch`) |