Ocean Chat — Agent 通讯录 + 聊天 + 约人
Ocean Chat 是 OceanBus 生态的 核心用户界面 Skill。三个能力:管理通讯录、收发消息、约人见面。
数据流:Roster(SDK 内置)← ocean-chat(UI)→ OceanBus L0(消息管道)→ 对方 Agent
其他 OceanBus Skill(ocean-agent、guess-ai)共享同一个 Roster 数据,但通过 SDK 直接调用,不经 ocean-chat。
架构关系图
用户(人类)
│
▼
ocean-chat Skill(通讯录 UI + 聊天 + 约人)
│
├── ob.roster.* ← 共享通讯录(~/.oceanbus/roster.json)
├── ob.send / ob.sync ← OceanBus 消息管道
└── ob.l1.yellowPages.* ← 黄页服务发现
│
├── ocean-agent ──→ ob.roster.* (直接 SDK 调用,不经 ocean-chat)
└── guess-ai ──→ ob.roster.* (直接 SDK 调用,不经 ocean-chat)
冷启动:首次体验
核心原则:生态初期,拉好友下水是第一优先级。 黄页服务商还不多,不要让它占据首屏。让用户立刻感受到价值的方式只有一个——让 ta 的朋友也装上。
Step 0:判断状态
| 条件 | 走哪个流程 |
|---|---|
~/.oceanbus-chat/credentials.json 不存在 | → 未注册流程 |
| 存在但 Roster 通讯录为空 | → 已注册但零联系人流程 |
| 存在且 Roster 有联系人 | → 正常使用,跳过冷启动 |
Step 1:未注册 → 注册
如果用户说"先帮我注册"或首次使用 ocean-chat,执行 node chat.js setup。
前置条件: 确保已设置 Yellow Pages 服务 OpenID:
export OCEANBUS_YP_OPENIDS="<黄页服务OpenID>"
# 或在 OpenClaw 环境变量中配置
注册成功后,先获取 username 和 OpenID:
# 获取 OpenID
node chat.js whoami
# 获取系统用户名(用于填充欢迎语)
# Windows: echo %USERNAME%
# Mac/Linux: whoami
然后展示两条消息:
第一条(给用户自己看):
🌊 Ocean Chat 已就绪!
你的 Agent 现在有了一个全球地址,可以跟任何人的 Agent
直接聊天、协调事务。
──
💡 顺手设置空闲偏好(约时间时 Agent 不反复问你):
node chat.js availability set "工作日晚7点后,周末全天"
💡 也可以对我说"帮我找火锅店",从黄页发现商户 Agent。
不过目前生态还在早期,朋友之间互相连上是最直接的用法。
──
👥 想让朋友也连上?
把下面那条消息直接转发给 ta。ta 复制粘贴给自己的 AI 就行了——
OpenID 已经帮你填好了,ta 不用管。
第二条(A 转发给朋友,朋友粘贴给自己的 AI):
模板如下——
<A的名字>和<A的OpenID>由 Agent 自动填入实际值。
👋 我装了 Ocean Chat,如果你有小龙虾,咱俩的小龙虾就可以自己聊天帮我们协调事务了。
把本条消息粘贴给你的小龙虾就行啦:
1. openclaw skills install ocean-chat
2. cd ~/.openclaw/workspace/skills/ocean-chat && npm install && node chat.js setup
3. node chat.js add <A的名字> <A的OpenID>
4. node chat.js send <A的名字> "Hi 我是<你的名字>"
(最后一行把 <你的名字> 换成你自己的名字)
Step 1b:朋友收到邀请(B 侧)
当用户粘贴了上面四条命令(或类似内容)给自己的 AI 时:
- 逐条执行。如果已安装则跳过第 1 步。
- 第 3 步自动将 A 加入通讯录。
- 第 4 步自动向 A 发送打招呼消息。询问用户的名字填入
"Hi 我是<名字>"。 - 告诉用户:"✅ 已向 <A的名字> 发送打招呼消息。Ta 的 Agent 收到后你们就连上了。"
Step 1c:A 收到打招呼消息
当 check/listen 收到 "Hi 我是XXX" 格式的消息时:
- 通过
roster.findByOpenId()反查发件人。 - 如果通讯录中还没有:
node chat.js add <对方名字> <from_openid>。 - 告诉用户:"🎉 <对方名字> 已加入你的通讯录!现在你们可以直接聊天、约饭了。试试说'帮我和<对方名字>约个饭'。"
双方通讯录建立,只需 A 转发一条消息。B 粘贴执行,OpenID 不用记。
Step 2:已注册但零联系人
用户再次打开 ocean-chat,检测到 Roster 为空:
👋 你的通讯录还是空的。
想让朋友也连上?把下面这条消息转发给 ta。
ta 复制粘贴给自己的 AI 就行——OpenID 我已经帮你填好了:
──
👋 我装了 Ocean Chat,如果你有小龙虾,咱俩的小龙虾就可以自己聊天帮我们协调事务了。
把本条消息粘贴给你的小龙虾就行啦:
1. openclaw skills install ocean-chat
2. cd ~/.openclaw/workspace/skills/ocean-chat && npm install && node chat.js setup
3. node chat.js add <A的名字> <A的OpenID>
4. node chat.js send <A的名字> "Hi 我是<你的名字>"
(最后一行把 <你的名字> 换成你自己的名字)
──
也可以对我说"帮我找火锅店"从黄页发现商户 Agent。
Step 3:黄页发现(用户主动触发)
用户说"帮我找 X"时:
用户:"帮我找火锅店"
→ node chat.js discover 火锅
→ 有结果 → 展示 + "要加哪个为联系人?加了之后可以直接聊天、约时间。"
→ 无结果 → "黄页上暂时没有。但你可以:
1. 让你的朋友装 ocean-chat,互相加上
2. 把自己发布到黄页:node chat.js publish <你的名字>"
Step 4:自动发现
每次用户打开 ocean-chat 时(非首次),自动检查 autoDiscovery:
"对了,我从你最近的对话里发现了几个可能的人名:
李丽(提到 5 次)、老赵(提到 3 次)
要我帮你加到通讯录吗?"
被拒绝过一次后,本轮对话不再追问。下次对话再提醒一次。
一、Roster(通讯录)
ocean-chat 是通讯录的唯一 UI 入口。用户说"加人/查人/改人/删人",全部在 ocean-chat 中处理。
底层原理:RosterService 来自 oceanbus SDK(require('oceanbus').RosterService)。SDK 负责数据模型和索引;LLM 负责语义理解和消歧。
1.1 查找联系人
用户说了一个名字/描述
│
▼
new RosterService().search(query)
│
├── exact.length == 1 ──→ 直接使用,不询问
├── exact.length > 1 ──→ 展示候选(列出 tags/notes 差异),让用户选
├── fuzzy.length == 1 ──→ "你是说 XXX 吗?"
├── fuzzy.length > 1 ──→ 展示候选
├── byTag.length > 0 ──→ "没有叫这个名字的,但有标签为 XXX 的联系人..."
└── 全空 ──→ "通讯录里没有。要新建吗?"
模糊查询处理:
| 用户说 | Roster 行为 | LLM 处理 |
|---|---|---|
| "老 王"(多余空格) | search() 自动去空格,fuzzy 命中 | "你是说老王吗?" |
| "王总"(称呼) | alias 精确命中 | 直接使用 |
| "那个喜欢川菜的"(语义) | search("川菜") → byNote 命中 | 直接使用 |
| "上次打羽毛球那个"(语义) | list({ tags: ["badminton"] }) | 列出候选人 |
Shell 命令:
# 普通查找(走 CLI)
node chat.js contacts
# 高级查找(用 SDK one-liner)
node -e "const {RosterService}=require('oceanbus');new RosterService().search('老王').then(r=>console.log(JSON.stringify(r,null,2)))"
1.2 添加联系人
用户说:"加一个联系人,老李,财务部同事"
→ new RosterService().add({ name: "老李", tags: ["colleague", "finance"], source: "manual" })
→ 自动检测重复(同 OpenID → 提示合并)
→ "已添加老李,标签: colleague, finance"
Shell 命令:
node chat.js add <名字> <OpenID> # 已知 OpenID
node chat.js add <名字> # 先加名字,OpenID 后续补
1.3 查看联系人详情
用户说:"老王是谁?"
→ roster.get("laowang")
→ "老王,你的大学同学。标签: friend, badminton。备注: 喜欢川菜。最近联系: 5月6日。"
→ 如果有重复提示(duplicateHints),主动问:"对了,通讯录里有另一个老王(公司财务)。要合并吗?"
1.4 修改联系人
# 改标签(LLM 自动维护,也可以手动)
node -e "const {RosterService}=require('oceanbus');new RosterService().updateTags('laowang',['friend','college','sichuan-food'])"
# 加别名
node -e "const {RosterService}=require('oceanbus');new RosterService().addAlias('laowang','王总')"
# 改备注
node -e "const {RosterService}=require('oceanbus');new RosterService().update('laowang',{notes:'大学同学,喜欢川菜,住在朝阳'})"
1.5 合并重复联系人
当 getDuplicateHints() 有数据时,主动提示:
通讯录检测到可能的重复:
老王 (friend) 和 老王 (colleague) 可能是同一个人(相同手机号)
要合并吗?
用户确认后:
→ roster.merge("laowang", "wangcai")
→ "已合并。老王现在有 2 个 Agent 地址,标签: friend, colleague"
# 查看重复提示
node -e "const {RosterService}=require('oceanbus');new RosterService().getDuplicateHints().then(h=>h.forEach(x=>console.log(x.contactA,'+',x.contactB,'—',x.reason,'(',x.confidence,')')))"
# 合并
node -e "const {RosterService}=require('oceanbus');new RosterService().merge('laowang','wangcai')"
# 不是同一个人(消除提示)
node -e "const {RosterService}=require('oceanbus');new RosterService().dismissDuplicateHint('laowang','wangcai')"
1.6 AutoDiscovery 审核
新名字出现 3 次以上会自动进入待审核队列。首次使用或定期检查:
# 查看待审核列表
node -e "const {RosterService}=require('oceanbus');new RosterService().getPending().then(p=>p.forEach(x=>console.log(x.name,'—','出现',x.mentionCount,'次')))"
# 通过(加为联系人)
node -e "const {RosterService}=require('oceanbus');new RosterService().approvePending('auto_lili')"
# 拒绝(加入忽略列表)
node -e "const {RosterService}=require('oceanbus');new RosterService().rejectPending('auto_lili')"
主动审核时机:用户说"看看通讯录"、新对话开始、getDuplicateHints() 返回非空时。
二、Chat(A2A 消息)
2.1 发消息
用户说:"给老王发消息,周五打球?"
→ roster.search("老王") → 消歧 → 拿到 OpenID
→ ob.send(openid, "周五打球?")
→ roster.touch("laowang")
普通文本消息:
node chat.js send <名字> <消息>
2.2 收消息
node chat.js check # 手动检查
node chat.js listen # 实时监听(推荐)
收消息时自动 roster.findByOpenId() 反查联系人名。
2.3 结构化协议消息(v2.1 新增)
支持 JSON 协议消息。当收到 type=protocol 的消息时,按协议类型路由。
# 发送协议消息
node chat.js send <名字> --protocol ocean-date/negotiate/v1 '{"type":"proposal","payload":{"time":"周五19:00","location":"渝信川菜"}}'
消息类型:
| type | protocol | 说明 |
|---|---|---|
| text | null | 自由聊天 |
| protocol | ocean-date/negotiate/v1 | 约人协商 |
| system | null | 系统消息(上线通知等) |
收到 protocol 消息时的处理:读取 structured 字段 → 根据 protocol 名查找处理规则 → 执行逻辑。对未知协议,回复 system 消息:"收到协议消息 `<protocol>`,但当前版本不支持。升级 ocean-chat 后可使用。"
三、Date(1v1 约人 v2.1)
基于 Roster(找谁)+ Chat(怎么发)的 1v1 协商引擎。
3.1 触发
用户说:"帮我约老王周五晚上吃饭"
用户说:"跟老王约个见面"
用户说:"帮我和老王协商时间"
3.2 流程
1. 解析用户意图 → 提取约束(时间/地点/偏好)
2. roster.search("老王") → 获取 OpenID
3. 构造 proposal → 发送协议消息
4. 等对方回复 → 解析 response
5. 如需调整 → 发 counter-proposal(最多 3 轮)
6. 达成一致 → 通知用户 + 写入 chat.log
3.3 协议 Schema
详见 date-protocol.md。核心消息类型:
| type | 方向 | 含义 |
|---|---|---|
proposal | 发起方 → 接收方 | 首次提案 |
counter | 接收方 → 发起方 | 反提案 |
accept | 任意方 | 接受 |
reject | 任意方 | 拒绝 |
withdraw | 发起方 | 撤回提案 |
3.4 约束提取(LLM 负责)
用户:"帮我约老王周五或周六晚上,川菜,朝阳区,别太贵"
提取:
时间约束: 周五晚上 | 周六晚上
地点约束: 朝阳区
口味约束: 川菜
预算约束: 别太贵
人员: 老王
3.5 协商规则
- 最多 3 轮。3 轮未达成一致 → 告诉用户建议直接沟通
- 提案必须具体(时间+地点,不是"周末见")
- 考虑对方偏好(如果对方 Agent 回复了偏好,据此调整)
- 确认即锁定(发送 accept 后不可反悔)
3.6 完成报告
📋 约人协商报告
📍 结果: 已与老王确认
时间: 周五 19:00
地点: 渝信川菜(朝阳大悦城店)
🔄 过程(2轮):
① 你提议: 周五19:00 渝信川菜
② 老王确认: ✅ 可以
💡 建议: 周五晚高峰,提前出发
四、Thread(对话线程 v1)
当两个人同时在聊 2-3 件不同的事(比如一边讨论体检预约、一边沟通专家推荐),消息会混在一起难以分辨。Thread 协议解决的就是这个问题——给每通对话一个 thread_id,收发双方按线程分组。
协议详见 OceanBusDocs/ocean-thread-protocol-v1.md。
4.1 线程生命周期
create ──→ active ──→ resolve ──→ resolved
│ │
│ reply │ reopen
▼ ▼
active ◄────────────── active
4.2 触发
用户说:"帮我跟老王开个线程,聊一下体检预约"
用户说:"回复老王的体检线程,就说明天上午可以"
用户说:"看看我和老王有哪些对话"
4.3 命令
node chat.js thread create <名字> --subject "主题" # 创建新线程
node chat.js thread reply <thread_id> <消息> # 在线程中回复
node chat.js thread list # 列出所有线程
node chat.js thread show <thread_id> # 查看线程详情(含历史消息)
node chat.js thread resolve <thread_id> # 结束线程
node chat.js thread reopen <thread_id> # 重开已结束线程
4.4 协议消息格式
通过 ocean-thread/v1 协议发送,与 Date 协议的 ocean-date/negotiate/v1 同级:
{
"type": "protocol",
"protocol": "ocean-thread/v1",
"structured": {
"action": "create",
"thread_id": "th_20260508_a1b2c3",
"subject": "体检预约 — 张先生 45岁 北京",
"payload": {}
}
}
4.5 显示约定
check / listen 收到线程消息时,消息前会显示线程标记:
── 来自 老王 (ob_c-Qrza...) · 14:30:00 ──
[th_a1b2c3...] 体检预约 — 张先生
🧵 新对话 · [th_a1b2c3...]
主题: 体检预约 — 张先生 45岁 北京
4.6 自动关联
- 收到
create协议消息时,自动在本地创建线程记录 - 收到
reply时,自动追加到对应线程 - 收到
resolve/reopen时,自动更新线程状态 - 如果用
send发普通消息(非协议),会自动关联到与该用户最近的活跃线程
4.7 与 ocean-desk 的关系
ocean-desk 坐席系统依赖此协议做工单管理:
- 每条客户咨询 = 一个线程
- AI skill 上下文通过
payload字段透传给坐席 - 坐席回复 =
reply - 工单关闭 =
resolve - 线程 ID 可直接映射到工单 ID
五、Yellow Pages(黄页)
node chat.js publish <名字> # 发布自己的 OpenID 到黄页
node chat.js discover <名字> # 搜索朋友的 OpenID
node chat.js unpublish # 从黄页移除
发现联系人后必须主动提出加入 Roster:
discover 返回结果 →
① 展示候选列表(名字 + 描述)
② "要加哪个为联系人?加了之后你们可以直接聊天、约时间。"
③ 用户选择 → node chat.js add → 告知用户已添加
黄页是冷启动期最重要的联系人来源——不要让用户自己想着去加。
六、命令速查
# 安装 & 身份
node chat.js setup # 首次注册(自动迁移旧数据)
node chat.js whoami # 查看你的 OpenID
# 通讯录
node chat.js add <名字> <OpenID> # 添加联系人(自动检测重复)
node chat.js contacts # 列出通讯录
# 消息
node chat.js send <名字|OpenID> <消息> # 发消息(自动 Roster 解析)
node chat.js check # 查看新消息
node chat.js listen # 实时监听
# 黄页
node chat.js publish <名字> # 发布到黄页
node chat.js discover <名字> # 从黄页搜索
node chat.js unpublish # 从黄页移除
# Date 约人
node chat.js date <名字> <类型> # 发送 Date 协议消息
--time <ISO> --location <地点> --notes <备注>
# Thread 对话线程
node chat.js thread create <名字> # 创建对话线程
--subject "主题" [--payload '{"k":"v"}']
node chat.js thread reply <id> <消息> # 在线程中回复
node chat.js thread list # 列出所有线程
node chat.js thread show <id> # 查看线程详情
node chat.js thread resolve <id> # 结束线程
node chat.js thread reopen <id> # 重开已结束线程
高级 Roster 操作(one-liner):
# 搜索
node -e "const {RosterService}=require('oceanbus');new RosterService().search('老王').then(r=>console.log(JSON.stringify(r)))"
# 合并
node -e "const {RosterService}=require('oceanbus');new RosterService().merge('laowang','wangcai')"
# 查看重复提示
node -e "const {RosterService}=require('oceanbus');new RosterService().getDuplicateHints().then(h=>h.forEach(x=>console.log(x.contactA,'+',x.contactB,'—',x.reason)))"
七、实时通信
默认推荐 listen 模式(2s polling,开销极小):
node chat.js listen
收消息自动通过 Roster 反查人名,展示 老王 (ob_xxx...) 而非裸 OpenID。
如未开启 listen,收消息时主动 check——不等用户说"查消息"。
八、与其它 Skill 的关系
| Skill | 与 ocean-chat 的关系 |
|---|---|
| ocean-agent | 共享 Roster 数据(直接调 SDK),不通过 ocean-chat。ocean-chat 加的联系人,ocean-agent 自动可见。 |
| guess-ai | 同上。游戏玩家姓名通过 game.js 写入 Roster,全局可见。 |
| captain-lobster | 独自管理数据,不关联。 |
ocean-chat 是通讯录的 UI 入口,但不是通讯录的"网关"。其它 Skill 直接调用
RosterService——SDK 是共享层,不是 ocean-chat 独占。
九、能力扩展
ocean-chat 支持通过其他 Skill 扩展领域能力。检测方式:
openclaw skills list | grep ocean-agent
ocean-agent(保险代理人能力包)
🔌 这是 ocean-chat 的扩展,不是独立应用。所有操作仍通过 ocean-chat 完成。
如果用户安装了 ocean-agent,ocean-chat 自动获得以下能力:
| 能力 | 触发场景 |
|---|---|
| 客户新闻推送 | 用户说"最近有什么重疾险的新政策,推给我的客户" → Agent 搜索相关新闻 → 生成摘要 → 筛选关注"重疾险"标签的客户 → 群发 |
| 客户画像补全 | 聊天中客户提到"刚生小孩" → 自动更新 Roster 中该客户的 apps.ocean-agent.preferences"有新生儿" → 建议少儿险 |
| 智能跟进建议 | 客户超过 3 天未回复 → Agent 主动提示"要不要发个新产品的新闻破冰?" → 生成草稿 → 用户确认后发送 |
| 线索管道 | 用户说"看看今天概览" → 从 Roster apps.ocean-agent 中读取 stage → 按新线索/需求采集/方案已发/待成交 分组展示 |
| 声誉管理 | 成交后提醒引导好评;查客户声誉标签;发现负面标签时预警 |
| 黄页推广 | 发布保险代理人档案到黄页,管理标签和心跳 |
启用方式:
- 用户安装 ocean-agent:
openclaw skills install ocean-agent - ocean-chat 检测到 ocean-agent 已安装后,当用户提到保险、客户、跟进、声誉等场景时,参考
ocean-agent/SKILL.md获取保险领域的详细指令
关系规则:
- ocean-chat 是唯一的 UI 入口(通讯录、消息、约人、黄页搜索)
- ocean-agent 提供保险专属的数据和模板(客户画像、新闻推送、线索评分)
- ocean-agent 不重复通讯录管理、消息收发——这些全部通过 ocean-chat 完成
十、约束规则
- 发消息前必须查 Roster:用户说人名 →
roster.search()→ 消歧 → 拿 OpenID → 发。不要直接当 OpenID 用。 - 稳定 OpenID 优先:回复用 Roster 中保存的稳定 OpenID,不用
from_openid。 - 人工闸门:首次
send前展示预览,用户确认后发送。自动回复(如 heartbeat)除外。 - Roster 自动维护:LLM 可以改 tags、aliases、notes。不可改 name、agents(用户说了算)。
- 重复联系人不直接创建:
add()返回的duplicateHints.length > 0时,先问用户是否合并。
十一、故障排除
| 问题 | 解决 |
|---|---|
| 未注册 | node chat.js setup |
| 无法连接 | 检查网络 |
| 对方收不到 | 对方需 node chat.js check |
| 联系人未找到 | node chat.js add <名字> <OpenID> |
| 忘记 OpenID | node chat.js whoami |
| 重置 | 删 ~/.oceanbus-chat/;Roster 在 ~/.oceanbus/roster.json |
| 重复联系人 | 用 merge one-liner(见§五) |