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 渲染、交互作答
流程、附件、語音、單回合模型覆蓋與會話搜索。每項能力相互獨立,可按
任意順序採用。