@modesto_ma/dsh-channels 0.2.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.
package/README.md CHANGED
@@ -5,15 +5,15 @@ DeepSeek Harness(DSH)飞书渠道插件:**一个 dsh 实例对应一个飞
5
5
  - 对话入口:私聊、群聊 @ 机器人、话题群 @ 机器人
6
6
  - 收到触发消息:立刻加 **“Typing”** 表情 → 以话题形式回复(**创建话题**)→ 绑定 DSH session → 卡片 2.0 **流式回复** → 成功时更新为 **“DONE”**,失败时更新为 **“No”**
7
7
  - 话题内后续消息**无需再 @**,自动延续该 session
8
- - 工作区归属:**每个话题一个独立工作区**(按用户 × 聊天的上层目录分组,同群不同用户互不影响);话题之间不共享文件
9
- - **附件落盘(话题工作区内)**:飞书图片、文件(含富文本消息中附带、普通消息里看不到的 `files`)、合并转发的聊天记录由插件流式保存到**当前话题工作区的 `attachments/`**(只发附件、不带说明的消息不触发模型请求);Agent 用 `read_image` / `read` / `bash` 按需读取,未引用的附件不消耗 token。每个话题独立工作区,因此其它话题的附件不会出现在本话题
8
+ - 工作区归属:**每用户一个工作区**(私聊 = 用户;群聊 = 群 × 用户,同群不同用户互不影响);每个话题在工作区内拥有独立工作目录(默认 `topics/<话题 id>/`,Git 仓库工作区可自动改用 worktree),因此话题之间的文件互不干扰,而工作区数量只随用户增长
9
+ - **附件落盘(话题目录内)**:飞书图片、文件(含富文本消息中附带、普通消息里看不到的 `files`)、合并转发的聊天记录由插件流式保存到**当前话题工作目录的 `attachments/`**(只发附件、不带说明的消息不触发模型请求);Agent 用 `read_image` / `read` / `bash` 按需读取,未引用的附件不消耗 token。每个话题独立目录,因此其它话题的附件不会出现在本话题
10
10
  - **HITL 审批**:当模型请求需要授权的动作(如 bash 沙箱升级 `sandbox_permissions`)时,插件监听主机 `approval/request` 事件,在同一话题里**新发一张独立审批决策卡**(✅ 允许 / ❌ 拒绝 按钮),用户点击后回填主机审批结果;超时(`approvalTimeoutMs`,默认 5 分钟)自动拒绝(fail-closed),非会话发起人点击无效
11
11
  - **结构化提问**:模型调用 `ask_user_question` 时,插件监听主机 `user-questions/request` 瀑布并发一张问答卡(每个问题一组选项按钮,多选先勾选再「提交本题选择」),答案回填给模型;主机自带的应答者只有 Web 客户端,没有本插件时纯飞书会话会以 `NO_PROVIDER` 失败。超时(`questionTimeoutMs`,默认 5 分钟)或回合取消时本轮提问失败(fail-closed);没有可选项的问题直接提示用文字作答,**阻塞期间普通文本回复即作为该问题的自由文本答案**(同时支持多选后的自定义文本)
12
12
  - **准入与授权(默认开启)**:**管理员不受白名单限制**(私聊、任意群聊——包括尚未授权的群——都能直接发起会话);其他人私聊只收到一条固定提示(`/userid` 例外),群聊需要「群已授权 + 用户在全局白名单」两层同时命中。机器人被拉进群后私聊管理员发**群信息卡**(未授权群附授权按钮,用于放行白名单用户);已授权群里未授权用户 @ 会触发用户授权卡。授权入口:飞书 `/auth` 命令、授权卡、Web 设置面板;管理员 open_id 在面板配置(私聊 `/userid` 获取)
13
13
  - 全部基于飞书官方 SDK [`@larksuiteoapi/node-sdk`](https://www.npmjs.com/package/@larksuiteoapi/node-sdk)(WebSocket 长连接,免公网)
14
14
  - **Web 设置面板**:Web/桌面端 **设置 → 飞书渠道**,顶部状态头 + 6 个标签页(连接 / 会话与行为 / 模型 / 工作区 / 访问授权 / 高级);凭据不完整时插件不连接、面板显示三步引导,填写保存后自动连接(保存写入本机覆盖文件,部分字段需重连生效);详见下文「Web 设置面板」章节
15
15
 
16
- 设计与可行性论证见 [docs/feasibility-and-design.md](docs/feasibility-and-design.md)。
16
+ 设计与可行性论证见 [docs/feasibility-and-design.md](docs/feasibility-and-design.md);工作区粒度与会话隔离的取舍见 [docs/workspace-isolation-design.md](docs/workspace-isolation-design.md)。
17
17
 
18
18
  ## 斜杠命令
19
19
 
@@ -37,17 +37,19 @@ DeepSeek Harness(DSH)飞书渠道插件:**一个 dsh 实例对应一个飞
37
37
 
38
38
  ## 流式卡片
39
39
 
40
- 卡片 2.0,三段式,默认 500ms 刷新(`refreshMs` 可调):
40
+ 卡片 2.0,按**过程 → 正文 → 页脚**三层组织,默认 500ms 刷新(`refreshMs` 可调):
41
41
 
42
- 1. **推理折叠面板**(`collapsible_panel`):带边框、默认折叠;成功链路 **推理中 → 回复中 → 回复结束**,失败终态为 **回复失败**;右侧箭头随折叠状态翻转(折叠向下、展开向上,`icon_expanded_angle: 180`)
43
- 2. **活动状态两行**(小字,仅在存在临时活动时渲染,**互相独立、互不顶替**),让卡片在“模型不吐字”的阶段(工具执行、subagent、workflow)**不再静默**——按 `statusRefreshMs`(默认 5s)慢节奏刷新计时:
44
- - **状态行**(委托进度):`tool-workflow/run-start / agent-start / agent-end / run-end`(工作流进度:完成数/总数、阶段、活动成员)→ run 结束转为 `🧵 工作流 xxx 结束(完成)` 并**保留到最终卡片**(后续工具调用、正文输出都不会顶掉它);**子代理进度**(跨会话映射):子代理是独立 session(`header.parentSession` 指向主会话),其 `subagent/descriptor`(label)、`tool/call`(当前工具)被折叠为 `🤖 子代理执行中:检索实现方案 (read) · 已运行 45s`,并行子代理显示计数,完成后在最终卡片汇总为 `🤖 子代理完成:a、b`。**只显示状态,不显示输出**——子代理正文是父模型的中间产物,绝不进卡片正文
45
- - **工具行**(父会话当前工具调用):`tool/call` → `🔧 调用工具:bash · 已运行 Ns`(subagent 工具显示其 description、workflow 工具显示 meta.name),`tool/result` 返回即清除,**回复结束后随最终卡片收起**
46
- - 两者可同屏:如 `🧵 工作流 xxx 结束(完成)` + `🔧 调用工具:bash`
47
- 3. **回复正文**:富文本 markdown;超长正文**自动分段投放**——单卡片预算用满后按干净断点(段落 / 标题 / 代码块边界)切分,且**每张卡最多 5 个表格**(飞书硬限制,错误 11310),表格绝不会被从中间切断;续片以「📄 回复分段 n」卡片跟进同一话题,**全文无丢失**;续卡被飞书拒收时自动降级为纯文本消息(内容仍送达)
48
- 4. **session 信息**(两行小字):
49
- - `🔑 {session id} · ⬆ input token {input} · ⬇ output token {output}`(session 累计口径)
50
- - `🧠 模型 {model}({effort}) · ⚡ 缓存命中 {cache} · 📊 上下文 {tokens}/{window}`(模型、推理强度、缓存命中按**当前轮次**;若本轮在请求头产生前失败则显示 `—`,不会沿用上一轮)
42
+ 1. **过程**(推理折叠面板 + 活动状态两行):
43
+ - **推理折叠面板**(`collapsible_panel`):带边框、默认折叠,右侧箭头随折叠状态翻转(折叠向下、展开向上,`icon_expanded_angle: 180`)。有推理内容时,面板标题沿成功链路 **推理中 → 回复中 → 回复结束** 切换,失败终态为 **回复失败**;**推理中阶段始终渲染**(兼作阶段指示灯与占位),**回复中 / 回复结束 / 回复失败阶段仅当存在推理内容时渲染**——无推理的完成/失败回合不会留下空面板
44
+ - **活动状态两行**(小字,**只在当前正在流式输出的那张卡上渲染**,已冻结的续卡/主卡不再重复;**互相独立、互不顶替**),让卡片在“模型不吐字”的阶段(工具执行、subagent、workflow)**不再静默**——按 `statusRefreshMs`(默认 5s)慢节奏刷新计时:
45
+ - **状态行**(委托进度):`tool-workflow/run-start / agent-start / agent-end / run-end`(工作流进度:完成数/总数、阶段、活动成员)→ run 结束转为 `🧵 工作流 xxx 结束(完成)` 并**保留到最终卡片**(后续工具调用、正文输出都不会顶掉它);**子代理进度**(跨会话映射):子代理是独立 session(`header.parentSession` 指向主会话),其 `subagent/descriptor`(label)、`tool/call`(当前工具)被折叠为 `🤖 子代理执行中:检索实现方案 (read) · 已运行 45s`,并行子代理显示计数,完成后在最终卡片汇总为 `🤖 子代理完成:a、b`。**只显示状态,不显示输出**——子代理正文是父模型的中间产物,绝不进卡片正文
46
+ - **工具行**(父会话当前工具调用):`tool/call` → `🔧 调用工具:bash · 已运行 Ns`(subagent 工具显示其 description、workflow 工具显示 meta.name),`tool/result` 返回即清除,**回合结束后收起**(完成态只保留结算状态行:工作流结论 / 子代理完成摘要)
47
+ - 两者可同屏:如 `🧵 工作流 xxx 结束(完成)` + `🔧 调用工具:bash`
48
+ 2. **正文**:富文本 markdown;超长正文**自动分段投放**——单卡片预算用满后按干净断点(段落 / 标题 / 代码块边界)切分,且**每张卡最多 5 个表格**(飞书硬限制,错误 11310);断点会避开代码围栏与表格,**单个表格/代码块小于单卡预算时不会被从中间切断**(单块本身超过单卡预算时只能硬切,内容仍不丢失)。续片以「📄 回复分段 n」卡片跟进同一话题,**全文无丢失**;续卡被飞书拒收时自动降级为纯文本消息(内容仍送达)。**续卡只留正文**:`📄 回复分段 n` → 可选活动行(仅当它是当前活动卡)→ 正文,无面板、无页脚、无分割线
49
+ 3. **结算页脚**(分割线 + 两行小字):**只在主卡、且只在回合结束(`done` / `error`)后渲染一次**——首帧、流式中、续卡都没有,因此不会出现 0/— 的空统计。
50
+ - 第一行:`🧠 模型 {model}({effort}) · 🔑 会话 {sessionId} · ⏱ 推理 {推理时长}`。推理时长为**本轮所有推理阶段的累计墙钟时间**(每个 step 从首个推理增量开始,到该 step 输出结束——首个正文增量或 step 结束——为止;工具执行时间不计入;回合在推理中结束时计到 turn 结束),无推理时为 `—`
51
+ - 第二行:`📊 上下文 {tokens}/{window} ({占比}) · 📩 输入 {n} · 📨 输出 {n} · ⚡ 缓存命中 {pct}`。占比 = 上下文 tokens / 窗口(<10% 保留一位小数,≥10% 取整);没有窗口时只显示 tokens
52
+ - 输入/输出为 session 累计口径;模型、推理强度、缓存命中按**当前轮次**,本轮在请求头产生前失败时模型/缓存命中显示 `—`、推理强度省略,不会沿用上一轮
51
53
 
52
54
  ## Web 设置面板(设置 → 飞书渠道)
53
55
 
@@ -66,7 +68,7 @@ DeepSeek Harness(DSH)飞书渠道插件:**一个 dsh 实例对应一个飞
66
68
  | 连接 | 连接状态与连接信息(长连接状态、Bot open_id、运行时长、话题路由 / 活动会话 / 在途回复)、未配置时的三步引导、飞书应用凭据、最近一次连接失败原因、需重连的待生效提醒 |
67
69
  | 会话与行为 | 私聊模式、普通群 @ 要求、话题兜底、首次私聊引导、权限切换、附件接收 |
68
70
  | 模型 | 模型服务商、模型 id、推理档位、Agent 预设(宿主目录驱动的级联下拉) |
69
- | 工作区 | 工作区根目录(可点「选择目录…」调用系统目录选择框)、过期工作区保留期、自动清理、「工作区清理」预览与手动执行 |
71
+ | 工作区 | 工作区根目录(可点「选择目录…」调用系统目录选择框)、过期目录保留期、自动清理、「目录清理」预览与手动执行;话题目录隔离方式(`topicIsolation`)在 profile 配置中设置 |
70
72
  | 访问授权 | 管理员 open_id、准入模式、申请有效期;已授权群 / 用户白名单与待处理申请 |
71
73
  | 高级 | 流式刷新 / 状态行 / SDK 日志级别、部署级只读参数、Web 覆盖文件路径与「清除 Web 覆盖」 |
72
74
 
@@ -78,11 +80,11 @@ DeepSeek Harness(DSH)飞书渠道插件:**一个 dsh 实例对应一个飞
78
80
 
79
81
  **模型选择器**:模型与推理在面板里是**独立的「模型」标签页**:宿主 LLM 服务可用时,`provider` / `model` / `reasoningEffort` 与 `agentPreset` 渲染为**下拉选择**而不是手填:provider 来自 `ctx.llm.listProviders()`,model 来自所选 provider 的 `listModels()`,推理档位来自 `resolveModelInfo(provider, model).reasoning.efforts`,Agent 预设来自 `agentPresets.list()`。每个选择器的首项是「留空 = 部署默认 / 适配器默认 / roster 默认」,有部署默认模型(`agentDefaultModel`)或 roster 默认预设时会在标签里带上具体值。选择按依赖级联:先选 provider 才会加载 model,选了 model 才会加载推理档位;宿主暂时无法枚举的项(服务未挂载、适配器查询失败/超时)自动退回原来的自由文本框,已保存但当前列表里没有的值会作为「当前配置,不在宿主机列表中」选项保留,不会在打开面板时被静默改掉。级联查询走只读的 `GET /dsh-channels/catalog?provider=…&model=…`(Host 侧短 TTL 缓存,单次查询 4 秒上限)。`provider` 与 `model` 在宿主侧是一条完整的请求路由,因此面板保存时要求**两者同时设置或同时留空**,不允许只选一半。
80
82
 
81
- `baseDir` 是**模型/工具产生文件的落点**:**每个飞书话题拥有一个独立工作区**(私聊 `<baseDir>/topics/p2p/<open_id>/<topic>/`,群聊 `<baseDir>/topics/groups/<chat_id>/<open_id>/<topic>/`,topic 来自话题 id;无话题的降级会话用 session id。旧版本留下的「用户 × 聊天」共享工作区仍在原路径,与新话题工作区互不嵌套)。面板里只接受**绝对路径**(`~` 开头按家目录展开,拒绝文件系统根目录),保存时会创建并校验该目录,不可用时当场报错,不会等到飞书里提示「会话创建失败」。已有 Session / 工作区不会被迁移,因此切到新目录后旧话题继续用旧工作区(话题内 `/renew` 也一样),只有之后新开的话题使用新目录。
83
+ `baseDir` 是**模型/工具产生文件的落点**:**每个用户拥有一个独立工作区**(私聊 `<baseDir>/p2p/<open_id>/`,群聊 `<baseDir>/groups/<chat_id>/<open_id>/`,同一群里不同用户互不影响;旧版本的「用户 × 聊天」共享工作区正好复用为这个新工作区)。每个话题在工作区内拥有独立工作目录 `<工作区>/topics/<话题 id>/`(无话题的降级会话用 session id),所以话题之间的文件互不干扰,而工作区数量只随用户增长。面板里只接受**绝对路径**(`~` 开头按家目录展开,拒绝文件系统根目录),保存时会创建并校验该目录,不可用时当场报错,不会等到飞书里提示「会话创建失败」。已有 Session / 目录不会被迁移,因此切到新目录后旧话题继续用旧目录(话题内 `/renew` 也一样),只有之后新开的会话使用新目录。
82
84
 
83
85
  **可视化选择工作区根目录**:输入框旁的「选择目录…」按钮会调用**宿主机的系统目录选择框**(macOS 的 NSOpenPanel / Windows 的文件夹对话框),选中后把绝对路径填进草稿,仍需点「保存更改」才落盘。该能力转发自宿主可选的 `ctx.directoryPicker` 服务:桌面端/本机 GUI 会话(native 后端)提供按钮;headless、远程或 SSH 会话没有可服务的显示会话(browse 后端),面板不显示按钮、保留手写输入,插件在缺少该服务的旧宿主上照常加载。选择框打开期间请求保持挂起;取消是正常结果,不会报错。
84
86
 
85
- **工作区清理**:「工作区」标签页内提供保留期与「工作区清理」卡片,用于回收长期不用的飞书工作区。保留期可选 `off / 7 天 / 30 天 / 90 天 / 180 天`(默认 `off`);卡片实时预览「插件工作区总数 / 可清理数量 / 预计释放空间」,点「立即清理」需二次确认;也可开启 `workspaceRetentionAuto` 在启动后与每日自动执行一次。清理动作只针对 `<baseDir>` 下由本插件创建(标题以 `飞书私聊 ·` / `飞书群聊 ·` 开头)的工作区:删除目录、注销 workspace registry,并把仍指向它的活跃路由退休为墓碑(该旧话题收到消息会明确提示「话题已过期」,不会恢复到一个已删除的目录)。**Harness Session 日志与事件记录不删除**;删除不可恢复,`off` 时按钮禁用。未建立飞书连接时(存储域未打开)预览不可用,卡片会提示先连接。
87
+ **目录清理**:「工作区」标签页内提供保留期与「目录清理」卡片,用于回收长期不用的话题目录。保留期可选 `off / 7 天 / 30 天 / 90 天 / 180 天`(默认 `off`);卡片实时预览「插件工作区数量 / 可清理目录 / 预计释放空间」,点「立即清理」需二次确认;也可开启 `workspaceRetentionAuto` 在启动后与每日自动执行一次。清理动作针对 `<baseDir>` 下的话题工作目录(当前布局)以及旧版本按话题建立、标题以 `飞书私聊 ·` / `飞书群聊 ·` 开头的工作区:删除目录,旧版工作区额外注销 workspace registry;并把仍指向它们的活跃路由退休为墓碑(该旧话题收到消息会明确提示「话题已过期」,不会恢复到一个已删除的目录)。**用户工作区本身永不删除,Harness Session 日志与事件记录也不删除**;删除不可恢复,`off` 时按钮禁用。未建立飞书连接时(存储域未打开)预览不可用,卡片会提示先连接。
86
88
 
87
89
  **访问授权**:「访问授权」标签页集中管理准入:管理员 open_id(`ou_` 开头,私聊 `/userid` 获取)、准入模式、授权申请有效期;「已授权群」「已授权用户」列表支持增删(群 ID 可从入群群信息卡或 `/groupid` 获得,用户 open_id 可用 `/userid` 查看);「待处理申请」可一键授权/忽略。白名单只约束非管理员:管理员在任何私聊/群聊都直接放行。`guarded` 且管理员未配置时标签页顶部会红字提示“当前会拒绝所有对话”。授权白名单与申请记录保存在插件 `storageDomain`(`channels_routes` 域),重启后保留;未建立飞书连接时白名单尚未加载,标签页会提示「连接建立后加载授权列表」并禁用写操作。
88
90
 
@@ -117,8 +119,9 @@ src/
117
119
  │ └── services.ts # 工作区 / 存储域 / 凭据 / token / llm / 投影 / HostContext
118
120
  ├── cards.ts # 卡片 barrel → src/cards/
119
121
  ├── cards/ # 卡片 2.0 JSON 渲染
120
- │ ├── primitives.ts # 信封、markdown、按钮、分栏等原语
121
- │ ├── streaming.ts # 流式回复卡、分段、字节/表格预算
122
+ │ ├── primitives.ts # 信封、markdown、折叠面板、按钮、分栏等原语
123
+ │ ├── streaming.ts # 流式回复卡构成(面板/活动行/正文/结算页脚)、分段、字节/表格预算
124
+ │ ├── onboarding.ts # 私聊首次触达引导卡
122
125
  │ ├── approval.ts # 审批决策卡
123
126
  │ └── question.ts # 结构化问答卡
124
127
  ├── approvals.ts # 审批 barrel → src/interactions/
@@ -131,6 +134,7 @@ src/
131
134
  │ ├── routes.ts # 路由存储 + TTL 墓碑
132
135
  │ ├── repair.ts # inflight 快照 + 重启修复
133
136
  │ ├── manager.ts # live Agent、冷恢复、preset、/renew 事务
137
+ │ ├── isolation.ts # 话题工作目录编排:workingDirectory 切换 / Git worktree + 降级
134
138
  │ └── request-policy.ts # 会话级 provider/model/reasoningEffort 覆盖(/model、/effort)
135
139
  ├── feishu/
136
140
  │ ├── delivery.ts # 出站投递:首帧话题卡、结果卡、分段降级、表情生命周期
@@ -144,7 +148,7 @@ src/
144
148
  ├── stats.ts # 宿主投影优先的 token / 缓存 / 上下文折叠
145
149
  ├── domain.ts # storageDomain 域声明与路由记录 schema
146
150
  ├── health.ts # 健康快照与卡片
147
- ├── cleanup.ts # 过时工作区清理策略(纯逻辑,无 I/O)
151
+ ├── cleanup.ts # 过期话题目录 / 旧版工作区清理策略(纯逻辑,无 I/O)
148
152
  ├── session-export.ts # 逻辑日志导出
149
153
  ├── feishu-channel.ts # 官方 SDK Channel 适配(可注入 stub)
150
154
  ├── client/ # 浏览器半区:设置 →「飞书渠道」页面(构建到 lib/client.js)
@@ -166,7 +170,7 @@ src/
166
170
  │ ├── directory-picker.ts # 系统目录选择框适配(ctx.directoryPicker native 后端)
167
171
  │ ├── surface.ts # 状态 / 面板视图模型 + 保存 / 清除 / 重连 / 选目录
168
172
  │ └── routes.ts # HTTP 路由 + 同源写保护
169
- └── workspaces.ts # 话题 → 独立工作区目录 + 附件 staging 目录
173
+ └── workspaces.ts # 用户工作区 / 话题目录 / 附件 staging 路径解析
170
174
  ```
171
175
 
172
176
  `lib/index.js` 与 `lib/client.js` 是构建产物(`pnpm build`):前者是安装后 profile 直接加载的 Host 入口(普通 ESM,不依赖 TypeScript 运行时转换),后者是设置面板的浏览器 bundle。`scripts/verify-host-loader.mjs` / `scripts/verify-client-loader.mjs` 分别做冒烟。
@@ -200,7 +204,7 @@ npm 发行包已包含构建产物:Host 入口是 `lib/index.js`(profile 直
200
204
  config:
201
205
  appId: 'FEISHU_APP_ID' # 凭据引用名(非密钥本身),经 ctx.credentials 解析
202
206
  appSecret: 'FEISHU_APP_SECRET'
203
- baseDir: './channels-data' # 工作区根目录(按 私聊/群聊/用户 组织;可在 Web 面板覆盖,面板要求绝对路径)
207
+ baseDir: './channels-data' # 工作区根目录(按 私聊用户 / 群×用户 组织;可在 Web 面板覆盖,面板要求绝对路径)
204
208
  provider: 'deepseek-official' # 可选:省略则用部署默认
205
209
  model: 'deepseek-v4-flash' # 可选
206
210
  reasoningEffort: 'high' # 可选:模型声明的精确档位 id;省略则用适配器/provider 默认
@@ -210,12 +214,13 @@ npm 发行包已包含构建产物:Host 入口是 `lib/index.js`(profile 直
210
214
  requireMention: true # 普通群需 @ 机器人
211
215
  dmMode: 'open'
212
216
  threadFallback: 'degrade' # 话题被拒时降级为会话级路由
217
+ topicIsolation: 'auto' # 话题目录隔离:auto(Git 仓库用 worktree,否则子目录)/ subdir / worktree
213
218
  routeTtlMs: 2592000000 # 话题路由 30 天无活动即过期;后续消息明确拒绝,不新建 Session(0 = 关闭)
214
219
  approvalTimeoutMs: 300000 # HITL 审批决策卡等待时长(默认 5 分钟;超时自动拒绝)
215
220
  questionTimeoutMs: 300000 # ask_user_question 问答卡等待时长(默认 5 分钟;超时本轮提问失败)
216
221
  acceptAttachments: true # 把飞书图片/文件/合并转发记录落盘到工作区 attachments/(不触发模型)
217
- workspaceRetention: 'off' # 过时工作区清理阈值:off / 7d / 30d / 90d / 180d
218
- workspaceRetentionAuto: false # 每日自动执行一次清理(默认关;删除不可恢复)
222
+ workspaceRetention: 'off' # 过期话题目录清理阈值:off / 7d / 30d / 90d / 180d
223
+ workspaceRetentionAuto: false # 每日自动执行一次清理(默认关;删除不可恢复;用户工作区永不删除)
219
224
  welcomeOnboarding: true # 私聊首次触达发一张引导卡(按 chat 持久化去重)
220
225
  allowPermissionSwitch: false # 是否允许 /permissions <name> 从飞书侧切换权限预设(安全敏感)
221
226
  accessMode: 'guarded' # 准入模式:guarded(默认,仅管理员+白名单)| open(旧行为,不安全)
@@ -246,7 +251,7 @@ FEISHU_APP_SECRET: xxxxxxxx
246
251
 
247
252
  启动成功标志(控制台):`[dsh-channels] feishu long connection established, bot open_id: ou_xxx`;凭据不完整时不会连接,日志会提示缺少的凭据引用(如 `飞书应用凭据未配置完整(缺少 FEISHU_APP_SECRET…)`),打开 Web **设置 → 飞书渠道 → 连接** 补齐即可。
248
253
 
249
- 宿主 profile 已内置所需服务(agent-loop、llm-deepseek、session 持久化、storage-domain、workspace);推荐确认 `dsh-token-meter` 已启用(卡片上下文占用显示)。
254
+ 宿主 profile 已内置所需服务(agent-loop、llm-deepseek、session 持久化、storage-domain、workspace、working-directory);推荐确认 `dsh-token-meter` 已启用(卡片上下文占用显示)。Git worktree 隔离模式额外依赖可选的 `@deepseek-ai/dsh-experimental-worktree` 实验插件,缺失时自动回退为普通话题子目录。
250
255
 
251
256
  **工具与 agent preset(web profile)**:web profile 把 bash/fs/todo 等工具行全部标为 `disabled: true`,工具只存在于 agent preset 里,且 `agents.create()` 不会自动应用 preset——插件因此在创建/恢复会话时通过 `setup` 回调挂载 preset(默认 `standard`,可用 `agentPreset` 指定更精简的 preset,如只含 bash/fs 的自定义项),并把 preset id 记录进会话头。若机器人回复把 `<tool_calls>…` 当纯文本吐出,即该会话没挂上 preset;修好后再看会话的 `request/header` 应带 `tools` 字段、日志出现 `tool/call` 事件。无 preset roster 的部署(如 headless)不做此步骤,工具本就位于宿主层。
252
257
 
@@ -265,9 +270,27 @@ FEISHU_APP_SECRET: xxxxxxxx
265
270
  3. 事件订阅方式选**长连接**(WebSocket,免公网)。
266
271
  4. 发布版本并把用户加入可用范围;将机器人拉入测试群。
267
272
 
273
+ ## 工作区与会话隔离
274
+
275
+ 工作区(DSH workspace registry 中的「项目」)按**人**建立,只在首次需要时创建;每个话题在工作区内拥有独立的**实际工作目录**:
276
+
277
+ | 场景 | 工作区目录(header cwd) | 每个话题的工作目录(实际 cwd) |
278
+ |---|---|---|
279
+ | 私聊 | `<baseDir>/p2p/<open_id>/` | `<工作区>/topics/<话题 id>/` |
280
+ | 群聊 | `<baseDir>/groups/<chat_id>/<open_id>/` | 同上 |
281
+
282
+ - 群聊必须是「群 × 用户」:同一个人在不同群之间不共享文件,避免 A 群会话把 B 群内容带出来。
283
+ - 会话创建时以工作区为 header cwd(保证 GUI 里归属该项目、沙箱边界稳定),随后通过 `ctx.workingDirectory` 把实际工作目录切到话题目录;冷恢复会从 Session 日志还原该目录。因此话题之间的文件互不干扰,而工作区数量只随用户增长。
284
+ - `topicIsolation` 控制隔离形式:`auto`(默认)在用户工作区是 Git 仓库(含 `.git`)时给每个话题建一个 `feishu-<话题>` 分支 + worktree(适合多个话题并发改同一个代码仓库),否则用普通子目录;`subdir` 一律子目录;`worktree` 一律尝试 worktree,失败告警并回退子目录。
285
+ - 隔离强度:这是**默认工作目录层面**的隔离(bash / fs / 终端都从话题目录出发),不是防恶意的硬沙箱——沙箱写边界仍是用户工作区。若需要硬边界,可把话题目录直接作为 Session cwd,但会话会失去工作区归属(GUI 显示为未分组)。
286
+ - 附件落在当前话题目录的 `attachments/`;话题建立前收到的附件先暂存 `<baseDir>/_staging/...`,首个 Session 创建时迁入。`/renew` 复用同一话题目录。
287
+ - worktree 模式下清理只删除 checkout 目录;Git 分支与管理记录按 DSH 语义保留(需要时可在仓库里 `git worktree prune` / `git branch -D` 回收)。
288
+ - 旧版本「一个话题一个工作区」(`<baseDir>/topics/...`)不会自动迁移:旧话题继续用原目录,保留期清理可回收;旧版「用户 × 聊天」共享工作区正好作为新的用户工作区复用。
289
+ - 设计取舍与约束详见 [docs/workspace-isolation-design.md](docs/workspace-isolation-design.md)。
290
+
268
291
  ## 行为与降级
269
292
 
270
- - **附件输入(图片 / 文件 / 会话记录,话题工作区内)**:飞书图片和文件经通道**流式**写入当前话题工作区的 `attachments/`(文件名前缀为收到时间 `YYYYMMDD-HHmmss-`),合并转发的聊天记录原样保存为 `*-forwarded-record.txt`。话题建立之前收到的附件先落到 `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/`,该话题的第一个 Session 创建时自动迁入其工作区;`/renew` 在**同一话题**内新建 Session,沿用同一工作区与附件。**每个新话题都会新建独立工作区**,因此话题之间看不到彼此的文件。旧版(共享工作区)的话题继续使用原工作区,不会自动迁移。除独立图片/文件消息外,**富文本(post)消息附带在 `files` 数组里的文件也会被恢复并落盘**——官方 SDK 的 post 归一化只遍历正文元素、会丢弃 `files`,插件通过原始事件补回。**只发附件(不带说明)的消息不触发模型请求**;插件在 agent 作用域注册一段 system prompt,说明附件目录约定,Agent 需要时用 `read_image` / `read` / `bash` 按需读取(未被引用的附件零 token)。写入成功给消息加 `DONE` 表情、全部失败加 `No`;失败时额外回一条**纯文字提示**(不是卡片),列出附件名与原因(例如资源已过期、文件夹消息暂不支持),避免 Agent 静默读到旧附件。**超过 100 MB 的文件也能保存**:飞书资源接口拒绝单次超过 100 MB 的完整下载(错误 234037),但对 `Range` 请求返回 206,插件因此先探测总大小、再按 32 MiB 分块下载,超大文件(已验证 294 MB)同样能落盘。单条附件失败只影响该附件,不阻塞其它附件。`acceptAttachments: false` 时附件消息保持惰性(不落盘、不触发)。附件数量不设插件侧上限,文件大小也仅受飞书平台上传限制(下载通过分块 Range 绕过单次响应上限),磁盘增长由「工作区清理」面板兜底;超大文件下载耗时较长,会占用该话题的处理队列。
293
+ - **附件输入(图片 / 文件 / 会话记录,话题目录内)**:飞书图片和文件经通道**流式**写入当前话题工作目录的 `attachments/`(文件名前缀为收到时间 `YYYYMMDD-HHmmss-`),合并转发的聊天记录原样保存为 `*-forwarded-record.txt`。话题建立之前收到的附件先落到 `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/`,该话题的第一个 Session 创建时自动迁入其话题目录;`/renew` 在**同一话题**内新建 Session,沿用同一话题目录与附件。**每个新话题都会新建独立工作目录**,因此话题之间看不到彼此的文件。旧版(每话题一个工作区)的话题继续使用原目录,不会自动迁移。除独立图片/文件消息外,**富文本(post)消息附带在 `files` 数组里的文件也会被恢复并落盘**——官方 SDK 的 post 归一化只遍历正文元素、会丢弃 `files`,插件通过原始事件补回。**只发附件(不带说明)的消息不触发模型请求**;插件在 agent 作用域注册一段 system prompt,说明附件目录约定,Agent 需要时用 `read_image` / `read` / `bash` 按需读取(未被引用的附件零 token)。写入成功给消息加 `DONE` 表情、全部失败加 `No`;失败时额外回一条**纯文字提示**(不是卡片),列出附件名与原因(例如资源已过期、文件夹消息暂不支持),避免 Agent 静默读到旧附件。**超过 100 MB 的文件也能保存**:飞书资源接口拒绝单次超过 100 MB 的完整下载(错误 234037),但对 `Range` 请求返回 206,插件因此先探测总大小、再按 32 MiB 分块下载,超大文件(已验证 294 MB)同样能落盘。单条附件失败只影响该附件,不阻塞其它附件。`acceptAttachments: false` 时附件消息保持惰性(不落盘、不触发)。附件数量不设插件侧上限,文件大小也仅受飞书平台上传限制(下载通过分块 Range 绕过单次响应上限),磁盘增长由「目录清理」面板兜底;超大文件下载耗时较长,会占用该话题的处理队列。
271
294
  - **DSH 0.2.0 兼容**:会话日志读取改用 `Session.snapshotEvents()`(整段 `Session.events` getter 已被 DSH 删除);正文/推理增量改用瞬时事件 `agent/assistant-stream`(持久化 `assistant/chunk` 已不再产生,改为把流内嵌进 `assistant/message.stream`),并处理 `abandoned` 尝试的文本回滚;卡片 token / 缓存命中 / 上下文窗口优先读宿主 `sessionProjections`(`sessionStats` / `tokenUsage` / `contextPressure`),未挂载时回退到本地增量折叠 + `llm.resolveModelInfo`。完整评估、证据与实施状态见 [docs/review-dsh-0.2.0.md](docs/review-dsh-0.2.0.md)。
272
295
  - **话题创建**:通过回复消息接口 `reply_in_thread: true`(官方文档明示的“以话题方式回复(即创建话题)”)。响应后经 `im.v1.message.get` 取回 `thread_id` 写入路由表。
273
296
  - **话题延续**:`im.message.receive_v1` 事件自带 `thread_id`,命中路由表即 `agent.followup()`,无需 @。同一 (chat, user) 的**并发首消息会被串行化**:后到的消息等待在途的会话建立完成,再按串行语义续聊同一 session 或另起新话题——不会重复建 session、覆盖路由,也不会落入“无路由、无 @”的丢弃窗口。
@@ -275,18 +298,19 @@ FEISHU_APP_SECRET: xxxxxxxx
275
298
  - **重启恢复**:路由表持久化在 `storageDomain`;消息到达时 `ctx.agents.resume()` 懒恢复 session。若会话已被其他 owner 恢复为 live(如 Web UI 打开过该会话、并发冷恢复竞态),插件**借用**该 live agent 直接 `followup()`,不再报 "cannot prepare session … while it is live"。借用的 agent 不归插件 dispose;shutdown 与在途 resume 竞态时,刚发布的 agent 会被立即 dispose 以避免泄漏进下一实例的 live 注册表。
276
299
  - **重启修复(流式中断)**:每条正在流式的 turn 会把自己的修复凭据(trigger 消息、Typing reaction id、全部段卡 id)写入 `channels_routes` 域的 `inflight` 表(键 = 主卡 message id,turn 结束即删)。重启后长连接一建立,插件就把上一实例留下的中途卡片 patch 成「⚠️ 服务已重启,本次回复中断,请重新发送消息。」终态并补 DONE 表情;patch 失败重试一次(上限 2 次)后放弃。持久化盘点与缺口清单见 [docs/persistence.md](docs/persistence.md)。
277
300
  - **消息去重(WS 重投幂等)**:飞书长连接重连会重投未 ack 的事件;每条消息在任何副作用之前先落一条 `processed` 记录(键 = message id,飞书全局唯一),重投消息被直接丢弃——同进程靠内存守卫、跨重启靠持久化记录,每条消息至多处理一次。记录保留 24 小时后由 GC 清扫。
278
- - **路由表 GC 与过期话题拒绝**:超过 `routeTtlMs`(默认 30 天)无活动的真实话题路由会先写入最小持久化墓碑,再删除活动路由;此后该话题中的普通消息和 Session 命令会收到「话题已过期」提示,插件不会恢复旧 Session,也不会在可见历史仍属于旧 Session 的话题里新建 Session(`/help`、`/userid`、`/groupid`、`/health` 仍可用)。墓碑跨重启保留,仅含 thread/chat/owner/工作区路径/最后活动时间;旧 DSH Session 与日志不删除,工作区目录仅在「工作区清理」显式启用时按保留期删除。降级的 chat-scoped 路由没有独立话题边界,仍直接清理。陈年 in-flight 快照同时在启动时及每 6 小时清扫。`routeTtlMs: 0` 关闭后续路由过期处理,已产生的墓碑仍保持拒绝语义。
279
- - **准入与授权**:`accessMode: 'guarded'`(默认)下,入站先过频道准入:**管理员不受任何白名单限制**,私聊/任意群聊(含未授权群)都可直接开会话;**非管理员**私聊回固定提示(`/userid` 例外),群聊要求 **群在白名单** 且 **open_id 在全局用户白名单**,两者缺一不可。未授权消息不建 Session、不建工作区、不落附件、不调模型;群里静默、私聊回固定文案。机器人被拉进任意群时(订阅 `im.chat.member.bot.added_v1`),插件私聊管理员发群信息卡(群名/`chat_id`/成员数/邀请人),未授权群附「授权本群 / 忽略」按钮;已授权群里未授权用户 @ 机器人时,管理员会收到用户授权申请卡(含昵称/open_id)。授权状态持久化在 `channels_routes` 域的 `access_groups` / `access_users` / `access_requests` 表,撤销立即生效。管理员 open_id 在 Web 面板配置;`/auth` 命令、授权卡、面板三种入口等价。
301
+ - **路由表 GC 与过期话题拒绝**:超过 `routeTtlMs`(默认 30 天)无活动的真实话题路由会先写入最小持久化墓碑,再删除活动路由;此后该话题中的普通消息和 Session 命令会收到「话题已过期」提示,插件不会恢复旧 Session,也不会在可见历史仍属于旧 Session 的话题里新建 Session(`/help`、`/userid`、`/groupid`、`/health` 仍可用)。墓碑跨重启保留,仅含 thread/chat/owner/工作区路径/会话目录/最后活动时间;旧 DSH Session 与日志不删除,话题目录仅在「目录清理」显式启用时按保留期删除(用户工作区永不删除)。降级的 chat-scoped 路由没有独立话题边界,仍直接清理。陈年 in-flight 快照同时在启动时及每 6 小时清扫。`routeTtlMs: 0` 关闭后续路由过期处理,已产生的墓碑仍保持拒绝语义。
302
+ - **准入与授权**:`accessMode: 'guarded'`(默认)下,入站先过频道准入:**管理员不受任何白名单限制**,私聊/任意群聊(含未授权群)都可直接开会话;**非管理员**私聊回固定提示(`/userid` 例外),群聊要求 **群在白名单** 且 **open_id 在全局用户白名单**,两者缺一不可。未授权消息不建 Session、不建话题目录、不落附件、不调模型;群里静默、私聊回固定文案。机器人被拉进任意群时(订阅 `im.chat.member.bot.added_v1`),插件私聊管理员发群信息卡(群名/`chat_id`/成员数/邀请人),未授权群附「授权本群 / 忽略」按钮;已授权群里未授权用户 @ 机器人时,管理员会收到用户授权申请卡(含昵称/open_id)。授权状态持久化在 `channels_routes` 域的 `access_groups` / `access_users` / `access_requests` 表,撤销立即生效。管理员 open_id 在 Web 面板配置;`/auth` 命令、授权卡、面板三种入口等价。
280
303
  - **HITL 审批**:插件以 `prepend: true` 注册为宿主 `approval/request` 瀑布的应答者——本频道路由的 session 的审批请求**优先于** Web 网关的通用应答(其面板对飞书用户不可见),非本频道的请求原样 `next()` 交给其他应答者。审批卡是**独立消息**(锚定到该 session 最新流式卡、同话题),绝不嵌入流式卡(其全量替换 patch 会冲掉按钮)。待审批时允许/拒绝按钮以两列等宽方式排列在同一行;决策仅在会话发起人点击时生效:允许 → `allowed-once`、拒绝 → `rejected`、超时 → `rejected`(fail-closed,卡面显示「已超时」)、请求被撤回(abort)→ `cancelled`、卡片发送失败 → `unavailable`(fail-closed);每次决策后卡片更新为终态并移除两个按钮。审批记录(`approval/asked`/`decided`)由宿主会话日志负责,插件不做持久化——重启后遗留问题随 turn 消亡,与 Web 网关一致。需要飞书应用订阅 **card.action.trigger** 回调(见「飞书侧准备」);启动日志 `HITL 审批应答已就绪` 表示生效,`主机未挂载 approval 服务` 表示该部署未启用 HITL。
281
- - **终态与表情**:成功轮显示「回复结束」并把 `Typing` 换成 `DONE`;`turn/end.kind = error` 时显示「回复失败」,正文附带结构化错误消息与错误码,并把 `Typing` 换成 `No`。模型/推理强度/本轮缓存样本不会继承上一轮。
304
+ - **终态与表情**:成功轮的面板标题变为「回复结束」(无推理内容时不渲染面板),并把 `Typing` 换成 `DONE`;`turn/end.kind = error` 时面板标题为「回复失败」(同样仅当有推理内容),错误只在正文出现一次(`❌ **本轮运行失败**` + 结构化错误消息与错误码),并把 `Typing` 换成 `No`。模型/推理强度/本轮缓存样本不会继承上一轮。
282
305
  - **限频**:卡片更新单条消息 5 QPS、应用级 1000 次/分钟;500ms(2 QPS)默认安全,`refreshMs` 下限 200ms;活动状态行的保活心跳默认 5s(`statusRefreshMs` 下限 1s),远低于限频。
283
- - **卡片体积**:单卡片 JSON 目标 ≤ 28 KB(飞书上限 30 KB,留 2 KB 冗余);正文超长自动分段为多张续卡(按段落/标题/代码块边界切分,每卡 ≤ 5 个表格,全文保留)。推理面板另有独立的 **5 KB 上限**(`REASONING_MAX_BYTES`,含截断标记;约 1.65 K 中文字 / 4.96 K ASCII 字),不占用也不被正文挤占——窗口长度与正文长短无关,超出部分保留头部并追加「…(内容过长,已截断)」。
306
+ - **卡片体积**:预算常量收敛为三个——`CARD_TARGET_BYTES`(28 KB,分段目标)、`CARD_MAX_BYTES`(30 KB,飞书硬上限,仅作 `buildCard` 的绝对兜底)、`REASONING_MAX_BYTES`(5 KB,推理面板内容窗口);**不再有面板预留**(原 5.5 KB 的 `CARD_PANEL_RESERVE_BYTES` 已删除),分段改为按整卡真实渲染测量:主卡探测面板最大窗口 + 完成态页脚,续卡无面板/页脚、可用满 28 KB(约 27.8 K ASCII 字符,100 KB ASCII 正文约 4 张卡)。正文超长自动分段为多张续卡,断点统一避开代码围栏与表格(段落/标题/行断点;单个代码块/表格小于单卡预算时整体换卡,超过单卡预算时只能硬切、内容不丢;每卡 ≤ 5 个表格,全文保留)。推理面板超出 5 KB 时保留头部并追加「…(内容过长,已截断)」(可见窗口约 4.96 K ASCII 字 / 1.65 K 中文字 / 1.24 K emoji),面板内表格省略;窗口长度与正文长短无关,不占用也不被正文挤占。
284
307
  - **续卡降级**:分段卡片发送被飞书拒绝(如表格数量超限 230099/11310)时,自动改发纯文本消息;卡片更新失败按指数退避重试,不刷屏报错。
285
308
 
286
309
  ## 已知取舍(v1)
287
310
 
288
311
  - patch 为全量替换:流式期间用户手动展开折叠面板会被下一次刷新收起;流式结束后不再刷新,可自由展开。
289
312
  - 超长正文拆成多张卡片(分段投放):推理内容不跨卡片,始终留在首卡;超出 5 KB 的部分被截断(只保留开头),不追加投放。
313
+ - 活动状态行只在当前正在流式输出的那张卡上渲染:若最后一张续卡被飞书拒收、降级为纯文本(无法再 patch),结算状态行(工作流结论 / 子代理完成摘要)不会显示;主卡结算页脚不受影响。
290
314
  - 子代理只显示状态(label / 当前工具 / 计时 / 并行计数),不显示其输出正文;后台子代理(`run_in_background`)在父 turn 结束后不再继续显示进度(卡片随父 turn 收尾)。
291
315
  - 私聊 `reply_in_thread` 需真机验证(文档未明示限制,失败走降级路径,见上文)。
292
316
  - 会话的 agent 常驻内存,不做空闲回收(重启后按需懒恢复)。
@@ -84,7 +84,7 @@ SDK 支持 `groupAllowlist` / `dmMode: 'allowlist'` + `dmAllowlist`,但插件*
84
84
  独立审计还补充了三个同源缺口,都应在 P1/P2 顺手处理:
85
85
 
86
86
  - **route-free / 未知 slash 在 owner 门之前就有副作用**:`/help` 会建话题发卡、`/health` 会泄露路由数、live Agent 数、bot open_id 尾号;非法命令名也会回输入错误卡(`src/service.ts:868-882`、`:942-994`)。
87
- - **HITL 审批卡在群话题内对群成员可见**:审批卡携带工具名、reason、sessionId(`src/cards/approval.ts:62-90`),流式卡底部的 session 信息同样在群里可见(`src/cards/streaming.ts:205`)。新模型下群内只有管理员和已授权用户,属“已授权成员可见”的可接受范围,后续可改私聊投递。
87
+ - **HITL 审批卡在群话题内对群成员可见**:审批卡携带工具名、reason、sessionId(`src/cards/approval.ts:62-90`),流式卡结算后的页脚第一行仍带完整 session id、在群里可见(`buildModelLine`,`src/cards/streaming.ts:291-295`)。新模型下群内只有管理员和已授权用户,属“已授权成员可见”的可接受范围,后续可改私聊投递。
88
88
  - **错误回显未脱敏**:入站异常会原样回 `dsh-channels 处理失败:<error>`(`src/service.ts:754-761`),可能带本地路径/Session 信息。
89
89
 
90
90
  ### 2.6 身份发现能力已经有了
@@ -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 的单元测试
@@ -106,6 +106,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
106
106
  requireMention: true # 普通群需 @ 机器人
107
107
  dmMode: 'open' # 私聊通道开关
108
108
  threadFallback: 'degrade' # 话题被拒时降级为会话级路由
109
+ topicIsolation: 'auto' # 话题目录隔离:auto(Git 仓库用 worktree,否则子目录)/ subdir / worktree
109
110
  approvalTimeoutMs: 300000 # HITL 审批决策卡等待时长(默认 5 分钟)
110
111
  accessMode: 'guarded' # 准入模式:guarded(默认)| open(旧行为,不安全)
111
112
  adminOpenId: 'ou_xxx' # 管理员 open_id(可留空,留空时 guarded 下拒绝一切对话)
@@ -118,7 +119,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
118
119
  | 配置项 | 默认 | 说明 |
119
120
  |--------|------|------|
120
121
  | `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 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的话题 |
122
+ | `baseDir` | `./channels-data` | 工作区目录树根:每个用户一个工作区(私聊 `p2p/<open_id>/`,群聊 `groups/<chat_id>/<open_id>/`),每个话题的工作目录为 `<工作区>/topics/<话题>/`(模型/工具产生文件的落点);Web 面板可覆盖,只接受绝对路径,`~` 展开,保存时创建并校验目录,只影响之后新建的会话 |
122
123
  | `provider` / `model` | 部署默认 | 新建 session 的路由;省略时继承部署默认 |
123
124
  | `reasoningEffort` | 适配器/provider 默认 | Session 初始/重置推理档位;可在话题内用 `/effort` 覆盖 |
124
125
  | `maxTokens` | 适配器默认 | 每次模型请求输出上限 |
@@ -126,6 +127,7 @@ chmod 600 $DSH_HOME/.credentials.yaml
126
127
  | `dmMode` | `open` | 私聊开关 |
127
128
  | `requireMention` | `true` | 普通群是否需要 @ 机器人(话题内续聊不受影响) |
128
129
  | `threadFallback` | `degrade` | 话题回复被拒时:`degrade` 降级为会话级延续 / `strict` 直接失败 |
130
+ | `topicIsolation` | `auto` | 话题工作目录形式:`auto`(工作区含 `.git` 时用 Git worktree,否则普通子目录)/ `subdir` / `worktree`(失败回退子目录) |
129
131
  | `approvalTimeoutMs` | `300000` | HITL 审批决策卡等待时长(10s–1h);超时自动拒绝 |
130
132
  | `accessMode` | `guarded` | 准入模式:`guarded` 仅管理员+白名单;`open` 保留旧行为(任何可用范围用户可对话,不安全) |
131
133
  | `adminOpenId` | 未设置 | 唯一管理员 open_id(`ou_` 开头);留空 + guarded = 拒绝一切对话(`/userid` 引导仍可用)。面板可改 |
@@ -196,13 +198,13 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
196
198
 
197
199
  | # | 场景 | 操作 | 预期 |
198
200
  |---|------|------|------|
199
- | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;卡片底部两行 session 信息 |
200
- | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片变 **回复失败**,显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
201
+ | 1 | 私聊 | 给机器人发一条消息 | 消息下立刻出现 **Typing** 表情 → 以话题形式回复卡片(**推理中** 折叠面板;无推理内容的回合结算后面板省略)→ 正文流式输出(500ms 刷新,面板变 **回复中**)→ 成功变 **回复结束**、Typing 变 **DONE**;**完成后主卡底部出现分割线与两行元数据**(第一行:模型/会话/推理时长;第二行:上下文占比/输入输出 token/缓存命中),首帧与流式中没有这两行 |
202
+ | 1a | 错误推理强度 | 配置该模型不支持的 `reasoningEffort` 后发消息 | 卡片进入 **回复失败** 终态(有推理内容时面板标题为「回复失败」,否则不渲染面板),正文显示 Harness 返回的错误消息和错误码(如 `UNSUPPORTED_REASONING_EFFORT`),Typing 变 **No**;模型/推理强度/缓存不沿用上一轮 |
201
203
  | 1b | Session 推理强度切换 | 话题内依次执行 `/effort`、`/effort tiny`、发普通消息、重启后续聊、`/effort reset` | 首次显示配置来源;切换后下一轮请求使用 `tiny`,重启恢复后仍保持;reset 后恢复配置值。若模型不支持 `tiny`,下一轮按 1a 失败,不会静默回退 |
202
- | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:卡片底部 session id 不变,input/output token 累计增长 |
204
+ | 2 | 私聊续聊 | 在刚建的话题里直接再发消息(**不 @**) | 继续同一 session:完成后主卡结算页脚第一行的 session id 不变,input/output token 累计增长 |
203
205
  | 3 | 群聊 @ | 在普通群 @ 机器人发消息 | 同 #1;该消息成为新话题根 |
204
206
  | 4 | 群聊话题内 | 在上一步的话题里直接发消息(不 @) | 延续该 session,不再要求 @ |
205
- | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**独立的第二个工作区**(`topics/groups/<chat_id>/<用户 open_id>/<topic>/`),与原用户的话题工作区互不可见 |
207
+ | 5 | 同群第二个用户 | 换另一个人 @ 机器人 | 新话题 + 新 session;dsh 工作区列表中出现**该用户的独立工作区**(`groups/<chat_id>/<用户 open_id>/`),会话实际工作在它自己的 `topics/<话题>/` 下,与原用户的话题目录互不可见 |
206
208
  | 6 | 话题群 | 在话题群某话题中 @ 机器人 | 机器人直接在该话题内回复卡片(不新建话题) |
207
209
  | 7 | 折叠面板 | 点面板标题 | 展开推理内容,右侧箭头翻转向上;再点收起 |
208
210
  | 8 | 重启恢复 | 重启 dsh 后在旧话题里发消息 | 机器人懒恢复原 session,token 统计继续累计 |
@@ -214,7 +216,7 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
214
216
  | 14 | 管理员直通(免群授权) | 刚被拉进未授权群时,管理员直接 @ 机器人发消息 | 正常开会话(管理员不受白名单限制);不授权该群也不影响管理员使用 |
215
217
  | 14a | 群授权与群内准入 | 管理员点「授权本群」(或在群内发 `/auth allow`);未授权用户 @;管理员 `/auth user add <open_id>` 后该用户再 @ | 授权前非管理员 @ 无响应(管理员除外);授权后白名单用户可分别开会话;未授权用户 @ 不建会话且管理员收到用户授权卡;授权该用户后可正常开会话 |
216
218
  | 15 | 撤销与持久化 | `/auth revoke`(群内)/ `/auth user remove <open_id>`;重启 dsh 后再试 | 撤销后对应主体立即无法开启对话;重启后已授权群/用户仍有效(持久化) |
217
- | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的工作区;A 自己发言正常 |
219
+ | 16 | 话题越权防护 | 授权群里用户 A 创建话题后,用户 B 在 A 的话题里发普通消息/附件/回答提问 | B 的消息被静默丢弃,不会进入 A 的 Session、不会写入 A 的话题目录;A 自己发言正常 |
218
220
 
219
221
  ### 5.0 HITL 审批验证点(重点)
220
222
 
@@ -228,16 +230,16 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
228
230
 
229
231
  飞书官方文档没有明示 `reply_in_thread` 对**私聊**的约束。联调时在私聊里观察第 1 步:消息是否真的聚合成“话题”形态(话题气泡)。若飞书拒绝话题回复,插件按 `threadFallback: 'degrade'` 自动改为普通回复,此时私聊内所有消息延续同一个 session(等价于“私聊整体即话题”),功能不受阻。若你希望私聊必须话题形态,把 `threadFallback` 设为 `strict` 并反馈错误码。
230
232
 
231
- ### 5.2 验证工作区归属
233
+ ### 5.2 验证工作区与话题目录
232
234
 
233
- 打开 dsh Web GUI → 工作区列表,应看到:
235
+ 打开 dsh Web GUI → 工作区列表,应看到**每人一个**项目:
234
236
 
235
237
  ```
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/)
238
+ 飞书私聊 · ou_xxx (对应 <baseDir>/p2p/ou_xxx/)
239
+ 飞书群聊 · oc_yyy · ou_xxx (对应 <baseDir>/groups/oc_yyy/ou_xxx/)
238
240
  ```
239
241
 
240
- 每个新话题会创建新的工作区目录;同一话题内 `/renew` 仍复用该工作区。
242
+ 每个新话题在该工作区内创建独立的话题工作目录 `<工作区>/topics/<话题 id>/`,会话列表仍归属该项目;同一话题内 `/renew` 复用同一话题目录。也可在飞书里用 `/workspace` 查看当前会话的实际工作目录。
241
243
 
242
244
  ## 6. 常见问题排查
243
245
 
@@ -250,13 +252,13 @@ DSH_HOME=$DSH_HOME pnpm dsh web # 默认 http://127.0.0.1:3080
250
252
  | `/auth` 无反应 | `/auth` 仅管理员可用;非管理员在群里会被静默忽略、私聊会收到固定提示 |
251
253
  | 群聊 @ 无响应 | 事件订阅是否勾选“接收群聊中 @ 机器人消息”;机器人是否在群内且未被群主禁言 |
252
254
  | 有 Typing 但无卡片 | 权限 `im:message` / `im:message:update`;卡片 2.0 需要较新的飞书客户端(升级客户端后重试) |
253
- | 卡片正文只到某长度 | 旧版行为;新版单卡预算 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 上限,超出的推理只保留开头并追加「已截断」提示 |
254
256
  | 报错 230099 / `ErrCode: 11310 card table number over limit` | 飞书单卡表格数量超限(真机验证上限 5 个);新版已按“每卡最多 5 个表格”自动切分,不再触发;历史版本请重装插件并重启 dsh |
255
257
  | 流式期间手动展开面板又被收起 | v1 已知取舍:patch 是全量替换卡片,500ms 内的下一次刷新会重置折叠状态;流式结束后可自由展开 |
256
258
  | 错误码 `230013` | 用户不在可用范围 / 被禁用 |
257
259
  | 错误码 `230071` | 该群不支持话题回复 → 自动降级路径(见 §5.1) |
258
260
  | 错误码 `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 |
261
+ | 折叠面板不显示 / 正文不是富文本 | 无推理内容的回合结算后省略面板属**正常行为**;若旧版插件连推理内容也不显示,是旧卡片的两个已知问题(`icon.tag: "img"` 非法、1.0 结构 markdown 语法受限),新版插件已切 2.0 结构 + 内置 `standard_icon` 箭头:重装插件并重启 dsh |
260
262
  | 审批卡不出现 | 确认该部署启用了 HITL:启动日志应有「HITL 审批应答已就绪」;若显示「主机未挂载 approval 服务」则该部署不会派发审批请求;另外确认当前会话的审批策略不是 `never`(`/permission danger-full-access` 会关闭审批) |
261
263
  | 审批卡点击无反应 | 未订阅 **card.action.trigger** 回调(或订阅后未重新发布版本),见 §1 第 4 步;按 §5.0 重新核对 |
262
264
  | 错误码 `231001` / `231002` | 表态表情失败:表情名(`Typing`/`DONE`/`No`)必须一字不差;机器人需在会话内 |