第 15 章:Chat、Inbox、通知与外部渠道
15.1 四个相邻但不同的系统
| 系统 | 用户看到什么 | 核心持久对象 |
|---|---|---|
| Chat | 与某个 Agent 的连续对话 | Chat Session、Message、Task |
| Inbox | 与自己相关的工作提醒 | Inbox Item |
| Notification | 浏览器/系统级提示和偏好 | Notification Preference + Event |
| External Channel | Slack/Feishu 中的对话入口 | Installation、Identity、Binding、Dedup |
它们会互相触发,但不能合并为一张 Message 表。Chat 是工作上下文;Inbox 是用户投影视图;外部 Channel 还要处理第三方身份、重放和线程。
15.2 Chat 领域对象
数据库查询位于 server/pkg/db/queries/chat.sql,HTTP Handler 在 server/internal/handler/chat.go。
主要对象:
- Chat Session:Workspace、Agent、Creator、Project、Title、Status;
- Chat Message:Role、Content、Task、Failure、Elapsed、Message Kind;
- Pinned Agent / Pinned Session;
- Pending Chat Task 投影;
- Read Marker 与 Unread Count;
- Draft Restore;
- Channel Chat Session Binding。
Chat Session 是用户可见长会话;每次发送可能创建一个新的 Agent Task。
15.3 发送消息的闭环
Web Chat 的典型流程:
- 验证用户拥有 Session,并可访问 Agent;
- 持久化 User Message 与附件关联;
- Touch Session;
- 创建 Chat Task;
- 发布 Chat/Task 事件;
- Daemon Claim 后读取整个 Session;
- Agent 产出文本和任务事件;
- Server 写 Assistant Message;
- 发布
chat:done; - 各端移除 Pending 并更新消息列表。
消息先持久化再排任务,保证 Agent 离线时用户输入仍在。
15.4 Project Context 与 Pinned Agent
Chat Session 可以绑定 Project,让 Agent 得到项目说明与 Resources;Pinned Agent 则支持用户快速选择常用 Agent。
这些是 Chat 产品层概念,不应塞进 Provider Session:
- Project 可在不同 Provider 间保持;
- Agent 归档后 Session 仍需有可解释状态;
- Provider Resume 失败时 Chat 历史仍然存在。
15.5 Pending 状态是持久投影
前端不能只用“刚按了发送”判断是否有运行中 Chat:
- 用户会刷新;
- 同一个账号可能在另一端查看;
- Task 可以等待本地目录或排队;
- WS 可能断线。
Server 提供 Pending Tasks 和 has-any 快速查询,并按 Session Creator 与 Agent Visibility 过滤。Realtime 只让这些 Query 失效。
15.6 Chat 取消的权限
用户取消入口先用 Task 所属 Agent 验证 Workspace,再叠加两类权限:
- Chat Task:只有创建该 Chat Session 的成员可取消;
- Issue/Autopilot/Quick Create Task:必须能访问该 Agent,尤其是 Private Agent。
CancelTaskByUser 以 Task ID 为入口,因为 run-only 和 Quick Create 可能没有 Issue ID。
15.7 为什么取消后可能恢复输入
用户在 Agent 尚未产生有效 Transcript 时 Cancel,通常希望原问题回到 Composer,便于修改重发。
如果 Task 还没真正开始,Server 可同步判断:
- 删除这条触发 Message;
- 解除附件;
- 在取消响应中返回 Content/Attachments;
- 前端恢复 Draft。
若 Agent 已启动但当前 Transcript 看起来为空,直接删消息有竞态:Daemon 可能正在 Flush 最后一段输出。
15.8 Deferred Cancel Finalization
TaskService 对“已启动但暂时空输出”的取消:
- 标记
chat_finalize_deferred_at; - 先返回取消结果,不立即删 User Message;
- Daemon 的 Terminal Report 到达后调用
FinalizeDeferredCancelledChat; - 若最终仍无输出,在一个事务里删除触发 Message 并写
chat_draft_restore; - 发布
chat:cancel_finalized; - 客户端把内容与附件恢复到 Composer。
若最终已经有 Assistant Output,就保留对话,不制造 Draft Restore。
15.9 Draft Restore 为什么还要落数据库
只发 WS Event 不可靠:
- 用户可能离线;
- Server 可能在 Commit 后、Broadcast 前崩溃;
- 客户端可能刷新。
所以 chat_draft_restore 是可恢复事实,客户端可 List,再以幂等 Consume 删除。Session 删除与 Agent/Runtime 清理也显式 Prune 无 FK 的 Restore Rows。
这体现了统一原则:Event 提速,数据库保底。
15.10 no_response 是显式结果
Agent 完成但没有可展示文本,不应让 UI 永远停在 Pending。Chat Message 有 message_kind:
message:普通文本;no_response:本轮完成但没有文本回复。
旧客户端仍能用非空 Fallback Content 展示;新客户端可本地化专用占位。这是向后兼容的 Additive Field。
15.11 Inbox 是派生视图
Inbox Handler 在 server/internal/handler/inbox.go,查询在 inbox.sql。
Inbox Item 可能由:
- 分配给我;
- @提及;
- 订阅 Issue 的新 Comment;
- 状态或活动变化;
- Agent 结果
产生。一个 Issue 可产生多条原始通知,客户端展示层可能按 Issue 去重并只保留最新项。
因此 Inbox Count 必须使用与列表一致的过滤/去重语义。
15.12 Subscriber、Activity 与 Notification Listener
Server 在业务事件后由订阅者维护:
- Issue Subscriber;
- Activity Feed;
- Inbox Item;
- 可选系统通知;
- 未读摘要。
这些 Listener 消费同一 Domain Event,但各自有不同 Audience。Event Actor 用来避免给操作者本人制造无意义通知,也用于显示“谁做了什么”。
15.13 Notification Preference
Notification Preference 是用户级策略,不等同于 Browser Permission:
- Server Preference 决定哪些业务事件值得提示;
- Browser/OS Permission 决定客户端是否能弹系统通知;
- Workspace 页面内 Inbox 不一定受系统通知开关影响。
前端桥接位于 Web/Desktop 的 System Notification Adapter;权限拒绝时业务事件仍保留在 Inbox。
15.14 为什么需要通用 Channel Engine
Slack 和 Feishu 的 SDK、Event Payload、Thread ID、Markdown、连接方式完全不同,但进入 Multica 后都要做:
- Installation Route;
- Dedup;
- Group Mention Filter;
- 外部身份绑定;
- Workspace Membership;
- Chat Session Binding;
- Message Append;
- Task Enqueue;
- Outbound Reply。
通用抽象在 server/internal/integrations/channel,共享流水线在 channel/engine/router.go。
15.15 Channel 接口
Channel 只要求:
Type;Connect;Disconnect;Send;Capabilities。
Inbound 不通过 Pull 方法;Adapter 自己持有连接循环,将规范化后的 InboundMessage 推给统一 InboundHandler。
这样 Core 不需要轮询平台,也不需要知道 Slack Socket Mode 或 Feishu Long Connection。
15.16 Capability 是声明,不是魔法降级
capability.go 定义位图:
- Text;
- Rich Card;
- Thread Reply;
- Quote Reply;
- Attachment;
- Voice;
- Typing Indicator;
- Message Edit。
Adapter 只声明支持什么;调用方决定 Rich Card 不可用时怎样降级成文本。新增平台不会迫使 Core 的 Capability 类型携带平台 SDK。
15.17 规范化 Inbound Message
统一消息包含:
- Event ID / Message ID;
- Text 或媒体类型;
- Reply Context;
- 是否明确 Addressed To Bot;
- Force Fresh;
- Source:Channel Type、Chat ID、Chat Type、Sender ID、Thread ID;
- Raw:仅供平台 Resolver 读取的原始扩展。
平台 Adapter 负责去掉 Bot Mention Token、过滤 Bot 自己消息和不可摄入的 Edit/System Event。
15.18 固定顺序的入站流水线
Router 源码把顺序写得很清楚:
换顺序会改变安全语义。例如先查 Identity 再过滤 Group Chatter,会不断给未绑定用户发送绑定卡。
15.19 Two-phase Dedup
第三方平台会因重连或 ACK 超时重放事件。Dedup 使用 Installation + Message ID,并带 Owner Fence Token:
- Claim:占有处理权;
- Append Message 与 Mark 尽量在同一事务;
- 产品性 Drop 也 Mark,避免重复提示;
- 基础设施错误、事务回滚则 Release;
- Claim Lost 当 Duplicate 处理。
这比“处理完最后插一条 Seen ID”安全,因为并发 Worker 不会同时完成副作用。
15.20 Group Mention Policy
Slack Adapter 的当前策略:
- 一对一 DM:每条用户消息都可摄入;
- Group/Channel/Multi-party DM:必须明确 @Bot;
- Bot 自己与其他 Bot Message 被过滤;
- Edit/Delete/Join 等 Subtype 不当新问题;
- Thread 内无 Mention 的 Follow-up 当前不会自动视为对 Bot 说话。
最后一条是有意保守:是否属于已参与 Thread 需要 Session-aware 状态,不能靠单连接内存猜。
15.21 Identity Binding 与 Membership
外部 User ID 先映射到 Multica User,再重新确认 Workspace Membership。Binding 本身不依赖一个永远存在的 Member FK,因此每次入口都要检查成员关系。
结果可能是:
- Ingested;
- Needs Binding;
- Dropped: Non-member;
- Dropped: Revoked Installation;
- Dropped: Invalid/Duplicate/Not Addressed。
这些是产品结果,InboundHandler 返回 nil;只有 DB、路由、配置等基础设施失败才返回 Error,让 Supervisor 重连或报警。
15.22 Session Binding 与线程隔离
Channel Chat/Thread 绑定到 Multica Chat Session:
- P2P Session Creator 是真实 Sender;
- Group Session Creator 是 Installation Installer,保持稳定;
- Thread Root 参与 Binding Key,避免同一频道不同 Thread 混到一个 Session;
- 并发创建依赖 Unique Constraint,输掉竞态的一方重新读取。
Reply Target 也会更新到 Binding,Outbound 才能回到正确 Thread/Quote。
15.23 /issue 命令
消息第一条非空行可写:
/issue 标题;- 下一行以后作为 Description;
- 只有
/issue时可从上一条用户消息提取标题。
/issuetracker 或句中出现 /issue 不匹配。
Issue 创建者是真实 Sender,Assignee 是 Installation 对应 Agent,Origin 指回 Channel Chat Session。Message 已持久化后才创建 Issue,因此命令失败不会让输入消失。
15.24 三秒 Debounce
batcher.go 默认等待同一 Chat Session 安静 3 秒:
- 每条消息立即 Dedup 并持久化;
- 只延迟 Task Trigger;
- 新消息重置计时器;
- 最后一条消息的 Initiator 归因胜出;
- Agent 运行时读取完整 Session;
- Graceful Shutdown 调
FlushAll。
它把“先转发一段记录,再补一句要求”合成一次 Agent Run。
硬崩溃若发生在窗口内,消息不会丢,但要等下一条消息再次触发,这是当前内存 Debouncer 的明确取舍。
15.25 Typing 与快速 ACK
平台通常要求很快 ACK,因此:
- Inbound Durable Path 同步完成;
- Typing Indicator 在独立 Goroutine;
- Binding Card、Offline Notice 等 Outbound Reply 独立执行;
- 单次 Reply 默认最多 2.5 秒;
- 真正 Agent Reply 由后续 Task Event/Outbound 路径发送。
若 Debounce Flush 发现 Agent Offline/Archived,系统必须主动清理 Typing,否则没有 Task Lifecycle Event 帮它收尾。
15.26 Slack 与 Feishu Adapter
代码入口:
Slack 负责 Socket/Event 规范化、Mrkdwn、Thread TS、Binding 和 Reply;Feishu 负责区域化凭据、Long Connection、消息扁平化、Rich Card、Typing 与 Reply Target。
两者共享 Router,但保留平台专属 Resolver 与 Outbound Renderer。
15.27 增加新 Channel 的步骤
- 定义稳定的 Channel Type;
- 实现 Channel + Factory;
- 将原始事件规范化为 InboundMessage;
- 实现 Installation、Identity、Dedup、Session、Audit Resolver Set;
- 实现可选 Replier 与 Typing;
- 声明 Capability;
- 注册 Factory 与 Resolver;
- 建立 Installation Lease/Supervisor;
- 覆盖重放、Group Mention、Unbound、Non-member、Thread Isolation;
- 验证 ACK Deadline 内没有慢 I/O;
- 验证输出降级和消息长度限制;
- 验证关闭时 Drain Debounce 与 Reply Goroutine。
15.28 本章结论
Multica 把各种消息入口收敛为同一个可执行工作模型,但保留了清晰层次:
- Chat 保存连续对话;
- Task 保存一次 Agent 执行;
- Inbox/Notification 保存“谁需要注意什么”;
- Channel Adapter 隔离第三方协议;
- Channel Engine 统一身份、去重、会话与触发规则。
真正难点不在收发文本,而在重放、线程、权限、取消竞态和跨端恢复。