ROMP 聊天 API
ROMP(Real-time Open Messaging Protocol)聊天系统接口,支持私聊会话、消息收发、AI Agent 集成、推送 Token 管理和 Novu 认证。
路由前缀:/romp
源码:apps/backend/src/routes/romp/(index.ts、conversations.ts、messages.ts、files.ts、push.ts、novu-auth.ts、webhooks.ts——用户 Webhook 注册 CRUD;webhook-trigger.ts——内部投递工具函数,供 Agent Webhook / User Webhook 触发时调用,自身不注册路由)
参考文档:内部文档 docs/romp/api-reference.md
认证
所有端点需要 Authorization 头:
Authorization: Bearer <TOKEN>支持四种认证方式:
| 方式 | Token 格式 | origin 值 | 适用场景 |
|---|---|---|---|
| JWT(用户登录态) | Supabase JWT | client | 前端/App 用户操作 |
| API Key | wn- 前缀密钥 | api | 服务端/机器人调用 |
| Agent Token | wna- 前缀永久密钥 | api | AI Agent 在会话中收发消息 |
| OAuth | wno- 前缀 access token | client | 移动端 mobile:full scope 业务接口 |
Agent Token 是永久有效的 opaque token,创建 Agent 时签发(明文仅展示一次),数据库仅存 SHA-256 哈希。允许访问 ROMP 消息收发和文档操作端点。
Agent 还可选择内置 UI Workflow:系统把会话文本转换为 HTML UI Create/Update,并用标准 text + webpage parts 在原会话回复,不新增 UI 专属 ROMP 消息类型。
移动端 OAuth token 仅能访问 mobile:full scope 白名单内的业务接口。
错误格式
{
"error": {
"code": "invalid_request",
"message": "conversation_id is required",
"param": "conversation_id"
}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
code | string | 是 | 机器可读错误码 |
message | string | 是 | 人类可读错误描述 |
param | string | 否 | 出错的参数名 |
错误码一览:
| code | HTTP | 含义 |
|---|---|---|
unauthorized | 401 | 未认证或 Token 无效/过期 |
invalid_request | 400 | 请求参数错误(含文件大小超限、MIME 类型不允许等) |
forbidden | 403 | 无权限(非参与者等) |
not_found | 404 | 资源不存在 |
internal_error | 500 | 服务端错误 |
service_unavailable | 503 | ROMP 服务不可用(站点总开关 site_settings.romp.enabled 关闭,或检查失败) |
用户级禁用:即使站点开关开启,若当前用户被打上 romp:disabled 权限标记(管理后台可配置),其所有 ROMP 请求也会被拦截,返回 403 forbidden(与"非会话参与者"共用同一 code,但语义是账号级全局禁用而非单会话权限)。
会话端点
1. 创建私聊会话
POST /romp/v1/conversations创建与另一个用户的私聊。若已存在则直接返回(幂等)。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
type | string | 是 | 固定为 "private" |
participants | string[] | 是 | 对方的 user_id,恰好 1 个 |
{
"type": "private",
"participants": ["<对方的 user_id>"]
}响应 201 Created(新建)或 200 OK(已存在):
{
"id": "conv-uuid",
"type": "private",
"title": null,
"participants": [
{
"id": "user-a-uuid",
"type": "user",
"name": "张三",
"avatar": "https://example.com/avatar-a.jpg",
"role": "owner"
},
{
"id": "user-b-uuid",
"type": "user",
"name": "李四",
"avatar": "https://example.com/avatar-b.jpg",
"role": "member"
}
],
"created_at": "2026-02-20T10:00:00.000Z",
"updated_at": "2026-02-20T10:00:00.000Z"
}错误码:
| 场景 | HTTP | code |
|---|---|---|
type 不是 "private" | 400 | invalid_request |
| participants 长度不为 1 | 400 | invalid_request |
| 不能和自己创建私聊 | 400 | invalid_request |
| 对方用户不存在 | 404 | not_found |
2. 获取会话列表
GET /romp/v1/conversations获取当前用户参与的所有会话,按最近活跃时间降序排列。
查询参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
limit | number | 否 | 20 | 每页数量,最大 50 |
cursor | string | 否 | -- | 上一页最后一条的 id |
响应 200 OK:
{
"data": [
{
"id": "conv-uuid",
"type": "private",
"title": null,
"peer": {
"id": "user-b-uuid",
"type": "user",
"name": "李四",
"avatar": "https://example.com/avatar-b.jpg"
},
"last_message": {
"id": "msg-uuid",
"sender_name": "李四",
"summary": "你好,在吗?",
"created_at": "2026-02-20T10:30:00.000Z"
},
"unread_count": 3,
"is_pinned": false,
"pinned_at": null,
"updated_at": "2026-02-20T10:30:00.000Z"
}
],
"has_more": true,
"cursor": "conv-uuid"
}last_message.summary 生成规则:
summary 基于消息第一个 part 生成;当首个 part 非 text / thinking 时,会附加文本类 part 预览:
| parts[0].kind | summary |
|---|---|
text | 文本内容(超过 50 字截断并加 ...) |
thinking | [思考中...] |
image | [图片] |
audio | [语音] |
video | [视频] |
file | [文件] 文件名 |
tool_call | [工具] 工具名 |
tool_result(is_error=false) | [工具结果] |
tool_result(is_error=true) | [工具失败] |
webpage(有 title) | [网页] {title} |
webpage(无 title) | [链接] {url} |
| 其他 | [消息] |
若消息包含多个 part 且首个 part 是 text / thinking,summary 仅反映第一个 part 的内容;首个 part 是非文本类型(如 image / tool_call)时,会自动拼接后续 text part 预览(例如 [图片] 文字内容、[工具] search_web 搜索完成)。
排序规则:
会话列表按以下优先级排序:
- 置顶会话排在最前(按
pinned_at降序) - 非置顶会话按
updated_at降序
新增字段:
| 字段 | 类型 | 说明 |
|---|---|---|
is_pinned | boolean | 当前用户是否置顶了该会话 |
pinned_at | string | null | 置顶时间(ISO 8601),未置顶为 null |
3. 置顶会话
POST /romp/v1/conversations/:id/pin将指定会话置顶。每个用户独立管理置顶状态,不影响其他参与者。
路径参数:
| 参数 | 说明 |
|---|---|
id | 会话 UUID |
响应 200 OK:
{
"ok": true,
"pinned_at": "2026-03-12T10:00:00.000Z"
}错误码:
| 场景 | HTTP | code |
|---|---|---|
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
4. 取消置顶
DELETE /romp/v1/conversations/:id/pin取消指定会话的置顶状态。
路径参数:
| 参数 | 说明 |
|---|---|
id | 会话 UUID |
响应 200 OK:
{ "ok": true }错误码:
| 场景 | HTTP | code |
|---|---|---|
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
5. 标记已读
POST /romp/v1/conversations/:id/read将指定会话标记为已读(到某条消息为止)。只能向前推进,不会倒退。
路径参数:
| 参数 | 说明 |
|---|---|
id | 会话 UUID |
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
message_id | string | 是 | 已读到的最新消息 ID |
响应 200 OK:
{
"last_read_message_id": "msg-uuid"
}行为说明:
- 如果
message_id比当前已读位置旧,不会倒退,返回当前值 - 如果
message_id更新,向前推进到该消息 - 原子操作,行级锁保证并发安全
错误码:
| 场景 | HTTP | code |
|---|---|---|
| message_id 缺失 | 400 | invalid_request |
| 消息不存在或不属于此会话 | 400 | invalid_request |
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
6. 发送输入状态
POST /romp/v1/conversations/:id/typing向指定会话广播当前用户的输入状态。通过自建 WebSocket 网关(/ws/romp)广播给所有已订阅该会话的连接(broadcastToConversation 不排除发送者自己),不经过 Supabase Realtime。发送者自己的连接也会收到这条 typing 事件,需要客户端自行按 user_id 过滤掉自己(不要在服务端假设已经排除)。
路径参数:
| 参数 | 说明 |
|---|---|
id | 会话 UUID |
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
status | string | 是 | 输入状态枚举值 |
状态枚举:
| 值 | 含义 |
|---|---|
thinking | AI 正在思考 |
typing | 用户/AI 正在输入 |
tool_calling | AI 正在调用工具 |
idle | 空闲(停止输入) |
服务端节流: 同一用户在同一会话中发送相同状态,1 秒内仅广播一次。重复请求不会报错,返回 { "ok": true } 但不触发广播。
响应 200 OK:
{ "ok": true }WebSocket 推送格式(/ws/romp):
{
"type": "typing",
"data": {
"user_id": "user-a-uuid",
"status": "typing",
"user_name": "张三"
}
}仅推送给已对该 conversation_id 发送过 subscribe 指令的连接,无 Realtime channel 概念。完整协议见 docs/romp/realtime.md。
错误码:
| 场景 | HTTP | code |
|---|---|---|
| status 缺失或无效 | 400 | invalid_request |
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
消息端点
7. 发送消息
POST /romp/v1/messages向指定会话发送一条消息。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
conversation_id | string | 是 | 目标会话 ID |
parts | Part[] | 是 | 消息内容,至少 1 个 Part |
reply_to | string | 否 | 回复的消息 ID |
metadata | object | 否 | 自定义元数据,透传存储 |
{
"conversation_id": "conv-uuid",
"parts": [
{ "kind": "text", "text": "你好" }
],
"reply_to": null,
"metadata": {
"client_msg_id": "local-uuid"
}
}Part 类型:
| kind | 服务端强制校验字段 | 示例 |
|---|---|---|
text | text: string(非空) | {"kind":"text","text":"你好"} |
thinking | content: string(非空) | {"kind":"thinking","content":"让我想想..."} |
image | 无强制(见下方说明) | {"kind":"image","file_id":"uuid","url":"...","name":"photo.jpg"} |
audio | 无强制(见下方说明) | {"kind":"audio","file_id":"uuid","url":"...","duration":5.2,"transcript":"语音转写文本","role":"source_audio"} |
video | 无强制(见下方说明;前端暂无渲染实现,仅后端透传) | {"kind":"video","file_id":"uuid","url":"..."} |
file | 无强制(见下方说明) | {"kind":"file","file_id":"uuid","url":"...","name":"report.pdf","size":102400} |
webpage | url: string(非空)、title: string(非空) | {"kind":"webpage","url":"https://...","title":"页面标题","description":"...","icon":"...","screenshot":"...","context":{"document_id":"..."}} |
tool_call | tool_call_id: string、name: string、status: 'pending'|'running'|'done'|'error' | {"kind":"tool_call","tool_call_id":"call_1","name":"search_web","arguments":{"q":"..."},"status":"running"} |
tool_result | tool_call_id: string | {"kind":"tool_result","tool_call_id":"call_1","result":"...","is_error":false,"duration_ms":1234} |
| 其他 kind | 无强制校验 | 直接存储(前向兼容) |
多媒体 part(image/audio/video/file)中的
url为签名 URL,有效期 1 小时。过期后可通过「刷新文件 URL」端点获取新 URL。image/audio/video/file的 part 结构本身不强制要求file_id——validateParts的字段级校验不覆盖这四种 kind(不同于text/thinking/webpage/tool_call/tool_result),缺失file_id的 part 能通过结构校验。但POST /v1/messages在结构校验之后还有一段独立的文件绑定逻辑:只要 part 携带了非空file_id,就会校验其存在性/归属/所属会话/绑定状态(对应下方错误码表的file_id 不存在等四行),校验失败直接 400/403,不会静默忽略。
audio.transcript/audio.role(2026-06-29 起):客户端可携带transcript(语音转写文本);role由服务端在 Agent Webhook 归一化时标记为source_audio(若客户端已设置则保留原值)。详见下方「Agent Webhook 音频转写归一化」。
webpage用于分享网页卡片 / AgentSidebar 自动携带的页面上下文,驱动"纯网页消息静默不推送"等业务逻辑;icon/context字段主要服务于 AgentSidebar 自动上下文场景,不属于网页卡片协议本体。
工具调用 Part(
tool_call/tool_result):用于 Agent 在工作流中向用户展示工具调用过程。
tool_call.tool_call_id/name长度上限 256 字符tool_call.arguments必须是 object 或 string;JSON 序列化后 ≤ 64KBtool_call.status严格枚举:pending/running/done/errortool_result.is_error必须是 boolean;duration_ms必须是非负有限数;resultJSON 序列化后 ≤ 64KB- 单个
tool_call/tool_resultpart 整体 JSON 序列化 ≤ 128KB- 这两类 part 允许通过
PATCH /v1/messages/:id编辑(与text/thinking同列),用于 Agent 流式推进工具状态
metadata.client_msg_id(推荐):
客户端发送前生成一个本地 UUID 放入 metadata.client_msg_id,用于防重复显示和乐观 UI。
metadata 系统保留字段:input_modality / source_modality / primary_kind 由服务端在构建 Agent Webhook payload 时自动注入(语音转写归一化场景)——这个自动生成的值只存在于投递给 Agent 的 payload 里,不会回写数据库。但 normalizeMessageMetadata() 对客户端传入的 metadata 对象不做任何字段过滤,如果客户端自己在 metadata 里传了这三个同名字段,会被原样存库并出现在本端点的响应里——请视为保留名,客户端发送时应避免使用这三个字段名,防止和服务端自动生成的语义冲突。详见「Agent Webhook 音频转写归一化」(内部文档 docs/romp/api-reference.md)。
响应 201 Created:
{
"id": "msg-uuid",
"conversation_id": "conv-uuid",
"sender": {
"id": "user-a-uuid",
"type": "user",
"name": "张三",
"avatar": "https://example.com/avatar-a.jpg"
},
"origin": "client",
"parts": [
{ "kind": "text", "text": "你好" }
],
"reply_to": null,
"metadata": { "client_msg_id": "local-uuid" },
"edited_at": null,
"created_at": "2026-02-20T10:30:00.000Z"
}origin 字段:
| 值 | 含义 |
|---|---|
client | 通过 JWT 或 OAuth(mobile:full)认证发送(用户在 App 中操作) |
api | 通过 API Key 或 Agent Token 发送(服务端/机器人/AI Agent) |
system | 系统自动发送的消息 |
副作用:
- 会话的
updated_at自动更新 - 异步触发推送通知(发送者账号以外的所有参与者,按账号级排除,即排除发送者本人全部设备):Novu(In-App + FCM)、JPush、EMAS、Web Push
- 新消息通过自建 WebSocket 网关(
/ws/romp)推送:会话内所有参与者的 user-level stream 自动收到message.new。romp_messages表虽然仍带REPLICA IDENTITY FULL并在 Realtime 发布中,具备被 Supabase Realtime 订阅的能力,但前端stores/romp.ts当前没有建立实际的postgres_changes订阅(ensureRealtimeSubscription()现在是空实现,注释明确写"Supabase Realtime 已移除,消息完全通过 ROMP WebSocket 接收")——消息实时性目前依赖 WebSocket 单通道,不要按"双通道"设计 - 本次请求不是通过 Agent Token 认证(
auth.method !== 'agent',判断的是认证方式而非发送者profiles.type)时,向会话中除发送者本人外的 Agent 类型参与者异步触发 Agent Webhook(两层过滤共同避免 Agent 自触发循环)
错误码:
| 场景 | HTTP | code |
|---|---|---|
| conversation_id 缺失 | 400 | invalid_request |
| parts 为空或格式错误 | 400 | invalid_request |
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
| reply_to 消息不存在或不属于此会话 | 400 | invalid_request |
8. 获取消息历史
GET /romp/v1/messages获取指定会话的消息历史,支持双向游标分页。
查询参数:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
conversation_id | string | 是 | -- | 会话 ID |
limit | number | 否 | 50 | 每页数量,最大 100 |
before | string | 否 | -- | 获取此消息之前的(更早的) |
after | string | 否 | -- | 获取此消息之后的(更新的) |
before和after互斥,不可同时使用。
响应 200 OK:
{
"data": [
{
"id": "msg-uuid",
"conversation_id": "conv-uuid",
"sender": {
"id": "user-b-uuid",
"type": "user",
"name": "李四",
"avatar": "https://example.com/avatar-b.jpg"
},
"origin": "client",
"parts": [{ "kind": "text", "text": "你好!" }],
"reply_to": null,
"metadata": {},
"edited_at": null,
"created_at": "2026-02-20T10:31:00.000Z"
}
],
"has_more": false,
"cursor": null
}排序与翻页:
| 模式 | 排序 | 用途 |
|---|---|---|
| 默认(无游标) | 时间倒序(最新在前) | 进入聊天室加载最新消息 |
before=<id> | 时间倒序 | 上滑加载更早消息 |
after=<id> | 时间正序 | 下滑加载更新消息 |
错误码:
| 场景 | HTTP | code |
|---|---|---|
| conversation_id 缺失 | 400 | invalid_request |
| before 和 after 同时传 | 400 | invalid_request |
| 游标消息不存在 | 400 | invalid_request |
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
9. 编辑消息
PATCH /romp/v1/messages/:id编辑一条已发送的消息。禁止编辑文件附件类 parts(image / audio / video / file);其他 kind 都可以编辑(含 text、thinking、tool_call、tool_result 以及自定义 kind)。tool_call / tool_result 在 Agent 流式推进工具状态时尤其常用。
路径参数:
| 参数 | 说明 |
|---|---|
id | 消息 UUID |
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
parts | Part[] | 是 | 新的消息内容,至少 1 个 Part;不允许包含文件类(image/audio/video/file) |
{
"parts": [
{ "kind": "text", "text": "修改后的内容" }
]
}响应 200 OK:
{
"id": "msg-uuid",
"conversation_id": "conv-uuid",
"sender": {
"id": "user-a-uuid",
"type": "user",
"name": "张三",
"avatar": "https://example.com/avatar-a.jpg"
},
"origin": "client",
"parts": [
{ "kind": "text", "text": "修改后的内容" }
],
"reply_to": null,
"metadata": { "client_msg_id": "local-uuid" },
"edited_at": "2026-02-20T10:35:00.000Z",
"created_at": "2026-02-20T10:30:00.000Z"
}
edited_at:消息被编辑后自动设置为编辑时间(ISO 8601),未编辑的消息此字段为null。
约束说明:
- 只能编辑自己发送的消息
- 必须仍是会话参与者
- parts 中不允许包含
image/audio/video/filekind - 编辑不会触发推送通知(Novu/JPush/EMAS/Web Push),但会通过自建 WebSocket 网关向已订阅该会话的连接广播
message.update事件(Agent 流式更新依赖此机制)
错误码:
| 场景 | HTTP | code |
|---|---|---|
| parts 为空或格式错误 | 400 | invalid_request |
| parts 包含文件类 kind | 400 | invalid_request |
| 消息不存在 | 404 | not_found |
| 非消息发送者 | 403 | forbidden |
| 不再是会话参与者 | 403 | forbidden |
推送端点
10. 注册推送 Token
POST /romp/v1/push/register注册设备推送 Token,用于 App 端接收原生推送通知。支持 JWT / OAuth (mobile:full) 认证。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
token | string | 是 | 推送 token |
platform | string | 是 | 移动端:"ios" 或 "android";Web Push:"web" |
provider | string | 否 | 推送通道(默认 "fcm"),可选值:fcm、apns、expo、jpush、emas、web-push |
integrationIdentifier | string | 否 | Novu FCM 集成标识,仅 provider="fcm" 时有意义 |
subscriptionKeys | object | 否 | Web Push 订阅密钥(仅 provider="web-push" 时必填) |
subscriptionKeys 结构(Web Push):
| 字段 | 类型 | 说明 |
|---|---|---|
p256dh | string | P-256 ECDH 公钥 |
auth | string | 认证密钥 |
provider-platform 组合约束:
| provider | 允许的 platform | 说明 |
|---|---|---|
fcm | ios, android | 通用 |
apns | ios, android | 通用 |
expo | ios, android | 通用 |
jpush | ios, android | 国内全平台(iOS 双发 APNs prod+dev,Android 单发) |
emas | ios, android | 阿里云 EMAS |
web-push | web | 浏览器推送 |
行为说明:
- 幂等 = 以 token 为唯一键复用同一行:不是"仅更新 updated_at",重复注册会用本次携带的值覆盖
user_id/provider/platform等归属与配置字段 - Token 迁移:移动端(
ios/android)若 token 已属于其他用户,原子迁移到当前用户,仅provider="fcm"/"jpush"时才双向同步新旧用户的 Novu credentials;web-push走单纯 upsert,不查询、不同步旧归属用户,是静默覆盖 - Novu 同步(fire-and-forget)在
provider="fcm"时无条件执行;provider="jpush"时额外受环境变量JPUSH_NOVU_SYNC_ENABLED门控,只有精确等于'true'才会同步;web-push受另一个灰度开关WEBPUSH_NOVU_SYNC_ENABLED控制;apns/expo/emas目前不触发 Novu 同步。注意:apns/expo虽在校验白名单内可注册成功,但推送发送逻辑未实现对应通道,用这两个 provider 注册的 token 目前不会实际收到任何推送。 - JPush 有两种互斥的投递模式,由
JPUSH_NOVU_SYNC_ENABLED决定:未设置或非'true'(默认)时走直连模式——消息发送时hasJPush=true,直接调用 JPush API 推送,不经过 Novu;设为'true'时切换为 Novu 托管模式——消息发送时hasJPush被强制置false(跳过直连),JPush 推送完全依赖本端点同步进 Novu 的push-webhookcredentials 由 Novu 侧转发,若 Novu 的 webhook 集成没配置好,JPush 用户会收不到任何推送。两种模式二选一,不是"直连 + 顺带同步"的叠加关系。
响应 200 OK:
{ "success": true }错误码:
| 场景 | HTTP | code |
|---|---|---|
| 非 JWT / OAuth 认证 | 403 | forbidden |
| token 缺失 | 400 | invalid_request |
| platform 无效 | 400 | invalid_request |
| provider 无效 | 400 | invalid_request |
| web-push 缺少 subscriptionKeys | 400 | invalid_request |
| web-push platform 非 "web" | 400 | invalid_request |
11. 注销推送 Token
POST /romp/v1/push/unregister
DELETE /romp/v1/push/register注销设备推送 Token。支持 JWT / OAuth (mobile:full) 认证,幂等。
支持两种方式(DELETE 为兼容别名,部分客户端/网关对 DELETE body 支持较差)。
POST 请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
token | string | 是 | 要注销的推送 token |
DELETE 请求: body 或 ?token=xxx query param
响应 200 OK:
{ "success": true }错误码:
| 场景 | HTTP | code |
|---|---|---|
| 非 JWT / OAuth 认证 | 403 | forbidden |
| token 缺失 | 400 | invalid_request |
Novu 认证端点
12. 获取 Novu subscriberHash
GET /romp/v1/novu/subscriber-hash返回当前用户的 HMAC subscriberHash,供 Web/App 端 Novu SDK 初始化时传入,启用安全校验。支持 JWT / OAuth (mobile:full) 认证。
HMAC = SHA256(subscriberId, NOVU_API_KEY)
响应 200 OK:
{ "subscriberHash": "hex-hmac-sha256-string" }错误码:
| 场景 | HTTP | code |
|---|---|---|
| 非 JWT / OAuth 认证 | 403 | forbidden |
| Novu 未配置 | 503 | internal_error |
文件端点
13. 上传文件
POST /romp/v1/upload
Content-Type: multipart/form-data上传多媒体文件,用于在消息中发送图片、语音、附件等。支持 JWT / OAuth (mobile:full) 认证。
请求参数(form-data):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
file | File | 是 | 文件 |
conversation_id | string | 是 | 目标会话 ID |
purpose | string | 否 | message_image / message_audio / message_file |
文件大小限制:
| purpose | 限制 | 允许的 MIME 类型 |
|---|---|---|
message_image | 20 MB | image/jpeg, image/png, image/gif, image/webp |
message_audio | 5 MB | audio/mp4, audio/aac, audio/mpeg, audio/webm, audio/ogg, audio/wav, audio/m4a, audio/x-m4a |
message_file | 50 MB | 不限制 |
| 未指定 | 50 MB | 不限制 |
MIME 规范化:上传时后端会自动规范化 MIME 类型——去除参数部分并转为小写。例如浏览器 MediaRecorder 输出的
audio/webm;codecs=opus会被规范化为audio/webm后再做白名单匹配。
响应 200 OK:
{
"file_id": "uuid",
"name": "photo.jpg",
"mime_type": "image/jpeg",
"size": 204800,
"url": "签名 URL(有效期 1 小时)",
"expires_at": "2026-03-09T01:00:00.000Z"
}错误码:
| 场景 | HTTP | code |
|---|---|---|
| 非 JWT / OAuth 认证 | 403 | forbidden |
| 未上传文件 | 400 | invalid_request |
| conversation_id 缺失 | 400 | invalid_request |
| 文件超过大小限制 | 400 | file_too_large |
| MIME 类型不允许 | 400 | invalid_file_type |
| 会话不存在 | 404 | not_found |
| 不是会话参与者 | 403 | forbidden |
14. 刷新文件 URL
GET /romp/v1/files/:fileId/url获取文件的新签名 URL(有效期 1 小时)。用于文件 URL 过期后重新获取可访问的下载地址。
支持 JWT / OAuth (mobile:full) 认证。
路径参数:
| 参数 | 说明 |
|---|---|
fileId | 文件 UUID |
响应 200 OK:
{
"url": "新签名 URL",
"expires_at": "2026-03-09T01:00:00.000Z"
}错误码:
| 场景 | HTTP | code |
|---|---|---|
| 文件不存在 | 404 | not_found |
| 无权访问该文件 | 403 | forbidden |
15. 获取文件预览
GET /romp/v1/files/:fileId/preview获取文件的缩略图预览 URL。适用于图片类文件的列表展示场景。
路由本身不限制 auth.method,但 API Key / Agent Token 需要后台 permission_groups 显式授权覆盖该路径才能通过认证中间件——当前默认权限分组未覆盖此路径,实际只有 JWT 能访问;不支持 OAuth(mobile:full)——与「刷新文件 URL」端点不同,该端点未列入 OAuth scope 白名单,移动端 OAuth token 调用会被拦截。返回的 code 是 forbidden(ROMP 路由统一把 401/403 的字符串 error 包装成 {code, message}),原始的 insufficient_scope 只会出现在 message 字段里,不是 code 本身。
路径参数:
| 参数 | 说明 |
|---|---|
fileId | 文件 UUID |
响应 200 OK:
{
"url": "优先缩略图,无则原图",
"original_url": "原图 URL",
"thumbnail_url": "缩略图 URL 或 null",
"expires_at": "2026-03-09T01:00:00.000Z"
}错误码:
| 场景 | HTTP | code |
|---|---|---|
| 文件不存在 | 404 | not_found |
| 无权访问该文件 | 403 | forbidden |
数据模型
ProfileType
type ProfileType = 'user' | 'agent' | 'system'Participant 对象
interface Participant {
id: string // user_id
type: ProfileType // 用户类型(user/agent/system)
name: string | null
avatar: string | null
role: 'owner' | 'member'
}Sender / Peer 对象
interface Sender {
id: string // user_id
type: ProfileType // 用户类型(user/agent/system)
name: string | null
avatar: string | null
}Message 对象
interface Message {
id: string
conversation_id: string
sender: Sender
origin: 'client' | 'api' | 'system'
parts: Part[]
reply_to: string | null
metadata: Record<string, unknown>
edited_at: string | null // ISO 8601,未编辑时为 null
created_at: string // ISO 8601
}Part 类型
type Part =
| { kind: 'text'; text: string; source?: 'audio_transcript' | string }
| { kind: 'thinking'; content: string }
| { kind: 'image'; file_id?: string; url?: string; name?: string; mime_type?: string; size?: number; width?: number; height?: number }
| { kind: 'audio'; file_id?: string; url?: string; name?: string; mime_type?: string; size?: number; duration?: number; waveform?: number[]; transcript?: string; role?: 'source_audio' | string }
| { kind: 'video'; file_id?: string; url?: string; name?: string; mime_type?: string; size?: number; duration?: number; width?: number; height?: number }
| { kind: 'file'; file_id?: string; url?: string; name?: string; mime_type?: string; size?: number }
| { kind: 'webpage'; url: string; title: string; description?: string; icon?: string; screenshot?: string; context?: { document_id?: string; team_slug?: string; project_id?: string; collection_id?: string } }
| { kind: 'tool_call'; tool_call_id: string; name: string; arguments?: Record<string, unknown> | string; status: 'pending' | 'running' | 'done' | 'error' }
| { kind: 'tool_result'; tool_call_id: string; result?: unknown; is_error?: boolean; duration_ms?: number }
| { kind: string; [key: string]: unknown } // 前向兼容LastMessage 对象
interface LastMessage {
id: string
sender_name: string
summary: string // 摘要文本
created_at: string
}相关文档
| 文档 | 说明 |
|---|---|
| ROMP API Reference(内部文档) | 完整参考文档 |
| 实时消息订阅(内部文档) | ROMP WebSocket 接入指南(消息实时性目前是 WS 单通道,Realtime 订阅代码已停用) |
| App 推送集成指南(内部文档) | iOS/Android/Flutter 推送接入指南 |
补充端点
本节补充 plans/need-update-api.md 中尚未覆盖到 docs-site 的接口。端点标题保持严格的 METHOD /path 格式,便于后台文档覆盖率服务识别。
GET /romp/v1/push/diagnostics
读取 ROMP 推送链路诊断信息(渠道配置状态、token 分布、近 24 小时推送量)。
认证:仅 JWT,且额外要求 site:admin 权限(grants 表 permission_code='site:admin'),非站点管理员返回 403 forbidden。 请求:无请求体,无查询参数。
响应 200 OK:
{
"channels": {
"novu": { "enabled": true },
"jpush": { "enabled": true },
"emas": { "enabled": false },
"web-push": { "enabled": true }
},
"tokenDistribution": [
{ "provider": "fcm", "platform": "ios", "count": 80 },
{ "provider": "jpush", "platform": "android", "count": 30 }
],
"last24h": { "total": 0, "byChannel": {} }
}channels 是否 enabled 分别取决于:novu(是否配置了 NOVU_API_KEY+NOVU_API_URL,FCM 走这条通道)、jpush/emas/web-push(各自的配置检测函数)。tokenDistribution 是按 (provider, platform) 分组的计数数组,非固定 key 的对象。
已知问题:
last24h的读取/聚合逻辑本身正常,但写入侧是死代码——romp_push_metrics表的recordPushMetric()从未被任何推送发送路径实际调用,正常业务流程下这张表没有数据,接口通常返回{total:0, byChannel:{}}(不是接口保证恒为 0,只是缺写入者,若表里有其他来源的数据会被正常聚合出来)。total统计的是命中的指标行数,不是sent+failed的总量。
常见错误:401 未认证,403 非 site:admin;服务端异常返回 500。
GET /romp/v1/webhooks
列出当前用户注册的 Webhook。
认证:路由本身不限制 auth.method,但 API Key / Agent Token 需要后台 permission_groups 显式授权覆盖该路径——当前默认权限分组未覆盖 /romp/v1/webhooks*,实际只有 JWT 能访问;不支持 OAuth(mobile:full)。 请求:无请求体,无查询参数,不支持分页——一次性返回当前用户全部 webhook。
响应 200 OK(裸数组,无分页包裹):
[
{ "id": "uuid", "user_id": "uuid", "url": "https://...", "events": ["message.new"], "conversations": null, "created_at": "2026-..." }
]常见错误:401 未认证;服务端异常返回 500。
POST /romp/v1/webhooks
注册一个用户级 Webhook。这是离线补偿通道,不是"注册后必收到全部匹配事件":只有当消息接收方(会话内除发送者外的其他参与者)在投递那一刻没有 ROMP WebSocket 在线连接时,才会查询其名下 active=true 的 webhook 并投递;发送者本人永远被排除,在线用户即使注册了 webhook 也不会收到(在线时消息走 WS 实时推送)。conversations/events 只是在"确实要投递给这个离线用户"之后做的二次范围过滤,不传不代表在线时也会收到。纯 webpage 静默上下文消息同样不会触发 User Webhook。
认证:路由本身不限制 auth.method,但 API Key / Agent Token 需要后台 permission_groups 显式授权覆盖该路径——当前默认权限分组未覆盖 /romp/v1/webhooks*,实际只有 JWT 能访问;不支持 OAuth(mobile:full)。
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
url | string | 是 | 必须是合法 HTTPS URL;创建时只做字符串层校验(拒绝 localhost/字面量内网 IP/回环地址),不做 DNS 解析 |
secret | string | 否 | 用于签名投递请求 |
events | string[] | 否 | 目前仅支持 ["message.new"],传其他值返回 400 |
conversations | string[] | 否 | 限定只在自己离线时推送这些会话的事件;不传则不做会话范围过滤 |
team_id | string | 否 | 若传入,服务端校验当前用户是该团队的 accepted 成员;不传则从认证上下文(API Key/Agent 场景)取团队——当前默认权限分组下这两种 token 实际访问不到本端点,该分支暂时用不到 |
响应 201 Created: 返回插入后的完整 webhook 行(含 id/user_id/team_id/url/secret/events/conversations/created_at)。
常见错误:400 url 缺失/非法 HTTPS/hostname 字面量是内网 IP/events 含不支持值/conversations 非字符串数组,403 非该团队成员,500 服务端异常。
注册时的 SSRF 校验不是完整防护:用域名(而非 IP 字面量)指向内网地址会在创建时通过校验、正常拿到
201。真正的 DNS 解析 + 私网 IP 检测发生在投递那一刻,不是注册时——这类 webhook 能注册成功,但永远收不到任何投递,且不会有任何报错回传。
DELETE /romp/v1/webhooks/:id
删除指定 Webhook(仅能删除自己名下的)。
认证:路由本身不限制 auth.method,但 API Key / Agent Token 需要后台 permission_groups 显式授权覆盖该路径——当前默认权限分组未覆盖 /romp/v1/webhooks*,实际只有 JWT 能访问;不支持 OAuth(mobile:full)。 路径参数
| 参数 | 说明 |
|---|---|
id | Webhook UUID |
请求:无请求体。
响应:成功返回 204 No Content(空 body,非 JSON)。
常见错误:404 该 webhook 不存在或不属于当前用户,500 服务端异常。