跳到主要内容

第 15 章:Chat、Inbox、通知与外部渠道

15.1 四个相邻但不同的系统

系统用户看到什么核心持久对象
Chat与某个 Agent 的连续对话Chat Session、Message、Task
Inbox与自己相关的工作提醒Inbox Item
Notification浏览器/系统级提示和偏好Notification Preference + Event
External ChannelSlack/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 的典型流程:

  1. 验证用户拥有 Session,并可访问 Agent;
  2. 持久化 User Message 与附件关联;
  3. Touch Session;
  4. 创建 Chat Task;
  5. 发布 Chat/Task 事件;
  6. Daemon Claim 后读取整个 Session;
  7. Agent 产出文本和任务事件;
  8. Server 写 Assistant Message;
  9. 发布 chat:done
  10. 各端移除 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 对“已启动但暂时空输出”的取消:

  1. 标记 chat_finalize_deferred_at
  2. 先返回取消结果,不立即删 User Message;
  3. Daemon 的 Terminal Report 到达后调用 FinalizeDeferredCancelledChat
  4. 若最终仍无输出,在一个事务里删除触发 Message 并写 chat_draft_restore
  5. 发布 chat:cancel_finalized
  6. 客户端把内容与附件恢复到 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:

  1. Claim:占有处理权;
  2. Append Message 与 Mark 尽量在同一事务;
  3. 产品性 Drop 也 Mark,避免重复提示;
  4. 基础设施错误、事务回滚则 Release;
  5. 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 的步骤

  1. 定义稳定的 Channel Type;
  2. 实现 Channel + Factory;
  3. 将原始事件规范化为 InboundMessage;
  4. 实现 Installation、Identity、Dedup、Session、Audit Resolver Set;
  5. 实现可选 Replier 与 Typing;
  6. 声明 Capability;
  7. 注册 Factory 与 Resolver;
  8. 建立 Installation Lease/Supervisor;
  9. 覆盖重放、Group Mention、Unbound、Non-member、Thread Isolation;
  10. 验证 ACK Deadline 内没有慢 I/O;
  11. 验证输出降级和消息长度限制;
  12. 验证关闭时 Drain Debounce 与 Reply Goroutine。

15.28 本章结论

Multica 把各种消息入口收敛为同一个可执行工作模型,但保留了清晰层次:

  • Chat 保存连续对话;
  • Task 保存一次 Agent 执行;
  • Inbox/Notification 保存“谁需要注意什么”;
  • Channel Adapter 隔离第三方协议;
  • Channel Engine 统一身份、去重、会话与触发规则。

真正难点不在收发文本,而在重放、线程、权限、取消竞态和跨端恢复。