Chat 协议
在 Minara 网关上构建第三方聊天客户端的完整契约,覆盖回合循环、流式事件、历史重建、交互问答、附件与模型选择
本页是 Minara chat 协议的接入白皮书。它描述一个客户端用自己的 UI 在网关上跑完整对话所需的一切:社区自建的 Web 终端、移动 App、消息渠道 桥接,或一段自动化脚本。内置 Web UI 使用的正是这套契约,没有任何私有 端点。
一个合规客户端只需要接触四个面:
| 接口面 | 作用 |
|---|---|
POST /v1/chat/stream | 发起一次 agent 回合 |
GET /v1/stream(WebSocket) | 在 chat 频道上投递该回合的事件 |
GET /v1/sessions* | 列出、读取、搜索已持久化的历史 |
POST /v1/interactions/:id/answer | 回答 agent 的反问 |
两个 npm 包封装了这套契约,TypeScript 客户端不必手写任何管道代码:
@minara/types承载 wire 类型(ChatStreamEvent、ChatAttachment、问题载荷)。@minara/gateway-client承载带类型的 HTTP 客户端、多路复用 WebSocket 客户端,以及下文描述的 回合模型 SDK(reduceAgentEvent、sessionRowsToTurns)。
两个包都是纯 ESM,零 UI 依赖。非 TypeScript 客户端直接实现同一套 JSON 契约即可;OpenAPI 规格 覆盖了全部端点。
鉴权
网关设置了 GATEWAY_AUTH_TOKEN 时,HTTP 请求携带
Authorization: Bearer <token>,WebSocket 升级请求携带
?token=<token>(浏览器 WebSocket 无法设置请求头)。未设置该环境
变量时,网关接受匿名的本地请求。
回合循环
一次对话交换分三步:
POST /v1/chat/stream提交用户消息。网关立即返回{ session_id, kind, is_new },回合在后台运行。- 以返回的
session_id为 key,订阅多路复用 WebSocket 上的chat频道。回合事件按序到达,带每频道递增的序列号;断线重连从最后见到的seq续传。帧格式、订阅握手、回放与控制帧在 流协议页面 中规定。 - 把每个事件折叠到进行中的 assistant 回合上,直到
done或error到达。
不必担心 POST 返回与 WebSocket 订阅之间的时间差:网关为每个会话缓冲
在途回合的全部事件,fromSeq: 0 的 subscribe 会从头回放缓冲区。
POST 返回后再订阅永远是安全的。
请求字段
message 是唯一必填字段。完整请求体:
| 字段 | 用途 |
|---|---|
message | 用户文本(或语音转写文本) |
session_id | 继续既有会话;省略则新建会话 |
session_kind | 新会话归属的界面桶(默认 chat,还有 institution、markets、workflow、strategy-studio、data-studio、messaging) |
attachments | 已上传文件的引用,见附件与语音 |
voice_input_key | 原始麦克风录音的 FileStore key |
model | 本回合指定运行的模型 id,见模型选择 |
reasoning_effort | 本回合的思考档位(minimal / low / medium / high) |
retry | true 替换会话最近一个回合,避免堆叠重复 |
surface | web(默认)或 cli;cli 会从系统提示里去掉自定义 URI wire 协议 |
prompt_mode | full(默认)或 minimal,无头调用方可选精简提示骨架 |
完整参数语义见端点参考。
流式事件
每个事件都是 { type, data }。权威的联合类型是
packages/types/src/chat-stream.ts
中的 ChatStreamEvent,SDK 以 AgentEvent 名字再导出。按关注点分组:
| 分组 | 事件 | 客户端义务 |
|---|---|---|
| 握手 | start | 检查 protocol_version(见版本化);读取 supported_blocks |
| 会话身份 | session、session_title | 绑定会话 id,更新标题 |
| 回合文本 | text_delta、reasoning_delta、response | 追加文本;response 是兜底,仅在没有任何 text_delta 时才有意义 |
| 工具调用 | tool_call_start、tool_call_result | 渲染调用卡片;调用可与文本交错 |
| 子工具进度 | sub_tool_call、sub_tool_text_delta、sub_tool_thinking_delta、tool_progress | 可选;长耗时工具在此流出内部进度 |
| 富 UI | ui_block | 渲染或忽略;见 UI Block 协议 |
| 交互 | pending_question_added、pending_question_resolved | 见交互问答 |
| 中途引导 | user_interjection、context_compacted | 插入注入的用户气泡;展示压缩提示 |
| 进度 | phase、skill_activated、todo_snapshot、goal | 可选的状态展示 |
| 终结 | done、error | 恰好到达一个;把回合翻到最终状态 |
只追加 text_delta、在 done / error 上收尾的客户端就已经合规。
其余分组都是体验增强,缺了不影响正确性。未知事件类型必须跳过,绝不能
当作错误,因为联合类型会随时间增长。
done 携带两个值得处理的可选字段:用户通过 POST /v1/chat/interrupt
停止回合时的 interrupted: true,以及来不及注入的引导消息列表
pending_interjections: string[](把它们当普通消息重发即可)。
SDK reducer
@minara/gateway-client 导出的正是内置 Web UI 运行的那个 reducer。
它把事件逐个折叠到 AssistantTurn 上(文本、工具卡片、UI block 的
有序 segment,加上工具调用状态),副作用通过可选回调路由:
import {
createMultiplexClient,
createAgentStreamState,
reduceAgentEvent,
type AssistantTurn,
} from "@minara/gateway-client";
let turn: AssistantTurn = {
id: "t1", toolCalls: [], segments: [], skills: null,
todos: [], status: "streaming",
};
const state = createAgentStreamState();
const ws = createMultiplexClient({ baseUrl, apiKey });
const sub = ws.subscribe("chat", sessionId, {
onEvent: (ev) =>
reduceAgentEvent("t1", ev, {
sessionKind: "chat",
patchAssistant: (fn) => { turn = fn(turn); render(turn); },
onPendingQuestionAdded: (q) => showQuestionPanel(q),
onDone: () => sub.unsubscribe(),
}, state),
});使用这个 reducer 可以保证你的回合状态与 Web UI 逐字节一致,包括回放 时抑制重复 tool start、segment 交错排序等边界情形。
历史
任何刷新或断连缺口之后,已持久化会话是唯一事实源:
GET /v1/sessions?kind=&origin=&limit=&offset=列出会话,按最近 更新排序。GET /v1/sessions/:id以消息行返回完整转录。assistant 行携带metadatablob,内含流当时产出的有序 segment 与工具调用记录, 重建后渲染出的内容与用户当时看到的完全一致。响应还携带is_streaming(重连到在途 WebSocket 缓冲)与pending_questions(重新渲染未答问题)。GET /v1/sessions/search?q=跨会话对消息内容做全文搜索,按匹配度 返回摘录片段。
SDK 的 sessionRowsToTurns(detail.messages) 把消息行转换成与实时
reducer 相同的 Turn[] 形状,一条渲染路径同时服务实时流与历史:
import { sessionRowsToTurns, type SessionDetail } from "@minara/gateway-client";
const detail: SessionDetail = await fetch(`${baseUrl}/v1/sessions/${id}`)
.then((r) => r.json());
const turns = sessionRowsToTurns(detail.messages);
if (detail.is_streaming) {
// 重连:以 fromSeq 0 订阅 chat 频道,继续折叠事件
}交互:agent 的反问
agent 在回合中需要输入时(澄清问题、在选项间做决定、资金动作前的
确认),它发出 pending_question_added,并继续处理其他能做的事。
载荷携带问题 id、请求类型(select、confirm、input 或
secret)、带类型化选项的问题列表,以及 asked_by 来源。
客户端渲染提示后通过 HTTP 作答,answers[] 每个条目对应一个问题,
以 0 起始的 questionIndex 定位:
POST /v1/interactions/:id/answer
{ "answers": [ { "questionIndex": 0, "selected_labels": ["Confirm"] } ] }SDK 以 answerInteraction 封装了该端点:
import { createGatewayClient } from "@minara/gateway-client";
const gateway = createGatewayClient({ baseUrl, apiKey });
const outcome = await gateway.answerInteraction(q.id, [
{ questionIndex: 0, selected_labels: ["Confirm"] },
]);
if (outcome === "not_pending") closeWidget(); // 已被他处作答或已超时"not_pending"(wire 上是 HTTP 404)是多个客户端同时打开同一问题时
的预期结果,不是错误。
select 与 confirm 的答案把所选项的 label 文本放进
selected_labels;自由文本与 input 用 free_text。secret 请求
永远不通过此端点作答:客户端把值写入载荷中 writeEndpoint 指向的
凭据路由,只回答结果。完整语义见
端点参考。
有效作答后,网关向所有订阅者发出 pending_question_resolved,多个
同时打开的客户端无需轮询即可收敛。刷新后,未答问题通过
GET /v1/sessions/:id 的 pending_questions 重新拿到。
资金动作的确认走同一机制,kind: "confirm"。无法渲染确认 UI 的
客户端不得以程序方式作答。未作答的确认会超时,动作被取消。这是安全
的默认行为。
附件与语音
文件输入分两步。先上传字节:
POST /v1/files (multipart/form-data, field name "file")
→ { "key": "chat/files/2026/07/chart.png", "url": "/v1/files/…",
"mediaType": "image/png", "size": 48213, "uploaded_at": "2026-07-17T…" }再在回合请求里引用返回的 key:
{
"message": "这张图说明了什么?",
"attachments": [
{ "key": "chat/files/2026/07/chart.png", "filename": "chart.png",
"media_type": "image/png", "size": 48213, "kind": "image" }
]
}kind 取 image、pdf、spreadsheet、text、office 之一。
限制:每回合最多 8 个附件,图片单个 5 MiB,其他文件单个 20 MiB。
GET /v1/files/:key 取回字节,历史渲染图片附件也是走这条路。
语音输入复用同一存储:POST /v1/voice/transcribe?persist 转写录音,
同时返回转写文本与 FileStore key。转写文本作为 message 发送,key
作为 voice_input_key,历史即可回放原始音频。
模型选择
GET /v1/llm/available-models 返回当前 provider 连接所服务的模型。
回合请求可通过 model 钉住其中任意一个,并用 reasoning_effort
调整思考预算。覆盖只作用于该回合:它从不改动部署级默认
(PUT /v1/llm/default-model),同一网关的并发客户端各自保持独立
选择。非法取值在回合开始前就以 400 失败。
版本化与兼容性
协议以加法方式演进。新事件类型与新的可选字段会随时间出现;合规客户端 跳过未知事件类型、忽略未知字段。在同一协议版本内,既有事件和字段的 含义永不改变。
start 事件显式宣告版本:
{ "type": "start", "data": { "session_id": "chat_...", "protocol_version": 1 } }protocol_version 只在既有事件或字段发生破坏性变更时递增;加法式变更
永不递增。没有该字段的 start 事件视为版本 1。只有当宣告的版本大于
客户端构建时依据的版本才拒绝该流。小于等于的版本总是可以安全消费。
npm 包遵循同一纪律:wire 层的破坏性变更会提升 @minara/types 与
@minara/gateway-client 的主版本号。TypeScript 客户端钉住主版本、
自由升级次版本即可;SDK reducer 吸收加法式变更,客户端代码无需改动。
合规清单
最小合规客户端:
- 发送
POST /v1/chat/stream,绑定返回的session_id。 - 在
/v1/stream上订阅chat:<session_id>,渲染text_delta, 在done/error上收尾。跳过未知事件类型。 - 载入时从
GET /v1/sessions/:id重建历史。
完整功能客户端在此之上叠加工具调用卡片、ui_block 渲染、交互作答
流程、附件、语音、单回合模型覆盖与会话搜索。每项能力相互独立,可按
任意顺序采用。