SuperMap AgentX Server智能体接入WEB应用技术文档
注: 本文由开发实测后,由AI整理完成
面向未接触过 AgentX 的前端 / 全栈开发者。读完应当能独立把「对话式智能体 + 地图指令执行」接进任意 Web 项目。
本文只讲协议与流程,不涉及部署与环境配置。示例代码可直接复制运行(Node 18+ / 现代浏览器)。
目录
- 整体架构
- 接口清单
- [SSE 事件流详解](#SSE 事件流详解)
- [地图指令协议(command 围栏)](#地图指令协议(command 围栏))
- 关键流程
- 可复制代码
- 集成踩坑清单
- 参考实现索引
1. 整体架构
系统由四层组成,层与层之间单向依赖,协议层与执行层互不感知:
┌─────────────────────────────────────────────────────────┐
│ UI 层 对话面板 / 消息气泡 / 审批卡片 │
│ AiChatPanel.vue · ChatMessageItem.vue │
└──────────────────────────┬──────────────────────────────┘
│ 调用 action、读 state
┌──────────────────────────▼──────────────────────────────┐
│ 状态层 会话与运行状态编排(Pinia store) │
│ 建会话 → 建运行 → 订阅流 → 归并 → 执行 → 定稿 │
│ useAiChatStore.js │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ 协议层 REST 封装 · SSE 解析 · 事件归并 · 指令块解析 │
│ agentx.js · stream.js · eventReducer.js │
│ commandParser.js · history.js │
└──────────────────────────┬──────────────────────────────┘
│ 产出「命令对象数组」
┌──────────────────────────▼──────────────────────────────┐
│ 执行层 命令对象 → 三维场景操作 │
│ aiCommandExecutor.js → aiLayerRegistry.js │
└─────────────────────────────────────────────────────────┘
三条关键约束
| 约束 | 说明 |
|---|---|
| 协议层不知道 ClientX | 协议层只产出 {name, params} 命令对象;执行层只认命令对象,不关心它来自 SSE 还是历史回放。 |
| 执行层不知道 SSE | 执行层是纯函数式的 execute(commands, viewer) → results[],可脱离网络单测。 |
| UI 层不碰三维引擎 | UI 只读 store 的 messages / liveTurn / pendingRequests,所有 scene 操作走执行层。 |
收益:换地图引擎只改执行层;换后端协议只改协议层;UI 与状态层不受影响。
2. 接口清单
2.1 通用约定
| 项 | 值 |
|---|---|
| API 基址 | http://<agentx-host>:8490/agentx/autonomous/v2 |
| 鉴权 | 无 (OpenAPI securitySchemes 为空) |
| 智能体路由 | 绝大多数接口需带请求头 X-Agent-Id: <agentId>,缺省为 default |
| 响应体 | 裸 JSON ,没有 {code, data} 包装,response.data 就是业务数据 |
| CORS | 回显 Origin,浏览器可直连(生产环境建议反向代理) |
| 在线文档 | GET /agentx/autonomous/docs/v2/public(Swagger UI) |
| 原始 OpenAPI | GET /agentx/autonomous/openapi/v2/public.json |
⚠️
X-Agent-Id是最容易踩的坑 :会话列表、消息历史、运行事件全部按该头部所属智能体分区 。缺头部或传错智能体,
/messages会返回404 Chat not found,/conversations会返回空列表。详见 7.1。
2.2 本文用到的接口
| 方法 | 路径 | 用途 | 关键返回 |
|---|---|---|---|
| GET | /agents?with_details= |
智能体列表 | {items: AgentResource[], page} |
| POST | /conversations |
创建会话 | {id} = 会话句柄 (conv_...),即 POST /runs 的 session_id,见 [7.2](#方法 路径 用途 关键返回 GET /agents?with_details= 智能体列表 {items: AgentResource[], page} POST /conversations 创建会话 {id} = 会话句柄(conv_…),即 POST /runs 的 session_id,见 7.2 GET /conversations 会话列表(?limit=&cursor=&archived=) {items: ConversationResource[], page:{next_cursor,has_more,total}},每项 id 是 UUID(≠ 会话句柄) PATCH /conversations/{id} 更新会话(改标题等) ConversationResource DELETE /conversations/{id} 删除会话 — GET /conversations/{id}/messages 会话消息历史 {messages: [...]}(不是 {items}) POST /runs 创建一次运行 {id},即 run_id GET /runs/{id} 运行状态 {id, status, superclaw_after_index} GET /runs/{id}/events?after_index= 事件流(SSE) text/event-stream POST /runs/{id}/cancel 取消运行 — GET /runs/{id}/pending-requests 待处理的人机交互请求 {items: PendingRequestResource[], page} POST /pending-requests/{id}/submit 提交审批 / 澄清输入 {run_id, after_index}) |
| GET | /conversations |
会话列表(?limit=&cursor=&archived=) |
{items: ConversationResource[], page:{next_cursor,has_more,total}},每项 id 是 UUID(≠ 会话句柄) |
| PATCH | /conversations/{id} |
更新会话(改标题等) | ConversationResource |
| DELETE | /conversations/{id} |
删除会话 | --- |
| GET | /conversations/{id}/messages |
会话消息历史 | {messages: [...]} (不是 {items}) |
| POST | /runs |
创建一次运行 | {id},即 run_id |
| GET | /runs/{id} |
运行状态 | {id, status, superclaw_after_index} |
| GET | /runs/{id}/events?after_index= |
事件流(SSE) | text/event-stream |
| POST | /runs/{id}/cancel |
取消运行 | --- |
| GET | /runs/{id}/pending-requests |
待处理的人机交互请求 | {items: PendingRequestResource[], page} |
| POST | /pending-requests/{id}/submit |
提交审批 / 澄清输入 | {run_id, after_index} |
2.3 关键资源字段
AgentResource
id、name、description、localized、enabled、active_model、builtin、language、tool_count、mcp_count、skill_count、knowledge_base_count、running_config
ConversationResource
id、title、default_agent_id、default_mode、channel、created_at、updated_at、pinned、archived、archived_at、metadata
⚠️
default_agent_id是存储字段 ,不是分区键。实测该字段恒为"default",拿它当X-Agent-Id会 404。见 7.1。
PendingRequestResource
id、run_id、kind、title、prompt、schema、sequence
RunStatus (枚举)
queued(已创建待执行)、running(执行中)、waiting_input(等待人机交互)、completed、failed、cancelled
2.4 创建运行 POST /runs 请求体
json
{
"input": [
{
"type": "message",
"role": "user",
"content": [{ "type": "text", "text": "把地图定位到北京" }]
}
],
"session_id": "<conversation_id>",
"channel": "console",
"options": {
"mode": "auto",
"enable_background_investigation": false,
"approval_level": "SMART"
},
"metadata": { "source": "web", "ui_language": "zh", "agent_language": "zh" }
}
session_id必须是会话句柄 (POST /conversations返回的id,或列表项的metadata.session_id)。
⚠️ 传列表项的id(UUID)不会报错,但会另起一个新会话 ------详见 7.2。session_id必须回填,否则每轮都是新会话,无法连续对话。approval_level可取STRICT | SMART | AUTO | OFF,控制工具调用的审批强度。input只接受role: "user"的消息(服务端强制)。
2.5 其余可用接口(本文未展开)
/channels/console/messages/*(控制台消息流、已读、摘要)、/conversations/{id}/archive unarchive、
/conversations/{id}/runs /traces、/models /models/active、/instructions、/workspace/file-preview/{path}。
需要时查 Swagger UI。
3. SSE 事件流详解
3.1 为什么不用 EventSource
| 原因 | 说明 |
|---|---|
| 需要自定义请求头 | 订阅接口要带 X-Agent-Id,而 EventSource 不支持自定义 header。 |
必须声明 Accept |
GET /runs/{id}/events 不带 Accept: text/event-stream 会返回 500。 |
结论:用 fetch + ReadableStream 手动解析 SSE。本文第 6.2 节给了可直接复制的解析器。
3.2 事件形状
⚠️ 实测格式与 OpenAPI 文档不一致 。文档描述的是 RunEventResource(type: "message.delta" 之类),
实测返回的是 OpenAI Responses 风格事件。以下为实测结论。
object |
type |
出现时机 | 关键字段 |
|---|---|---|---|
response |
--- | 流的开始与结束 | status、created_at、completed_at、output[]、usage |
message |
reasoning / message / plugin_call / plugin_call_output |
每个「块」开始(in_progress)与结束(completed) |
id、status、content[] |
content |
text |
正文 / 思考的增量分片 | msg_id、delta、text |
content |
data |
工具调用的参数与返回 | msg_id、delta、status、data.{call_id,name,arguments,output} |
(无 object) |
turn_usage |
流的最后一条 | usage、context_usage |
公共字段:每条事件都带 sequence_number(从 1 递增)。
3.3 典型事件序列
以「把地图定位到北京」为例,一次运行的完整事件流:
1 {object:'response', status:'created', created_at:'...T06:14:04Z'}
2 {object:'response', status:'in_progress', ...}
3 {object:'message', type:'reasoning', status:'in_progress', id:'m1', content:[]}
4 {object:'content', type:'text', delta:true, msg_id:'m1', text:'让我先'}
5 {object:'content', type:'text', delta:true, msg_id:'m1', text:'加载技能...'}
... {object:'message', type:'reasoning', status:'completed', id:'m1', content:[{type:'text',text:'完整思考'}]}
... {object:'message', type:'plugin_call', status:'in_progress', id:'t1', content:[]}
... {object:'content', type:'data', delta:true, msg_id:'t1', data:{call_id:'c1', name:'Skill', arguments:'{"sk'}}
... {object:'content', type:'data', delta:true, msg_id:'t1', data:{arguments:'ill":"webgis-map-visualization"}'}}
... {object:'message', type:'plugin_call', status:'completed', id:'t1', content:[{type:'data',data:{...}}]}
... {object:'message', type:'plugin_call_output', status:'in_progress', id:'o1', content:[]}
... {object:'content', type:'data', delta:false, msg_id:'o1', data:{call_id:'c1', name:'Skill', output:'...'}}
... {object:'message', type:'message', status:'in_progress', id:'m9', content:[]}
... {object:'content', type:'text', delta:true, msg_id:'m9', text:'已将视角定位到北京。'}
... {object:'content', type:'text', delta:true, msg_id:'m9', text:'\n\n```command\n{"v":1,...}\n```'}
... {object:'message', type:'message', status:'completed', id:'m9', content:[{type:'text',text:'完整正文'}]}
32 {object:'response', status:'completed', completed_at:'...T06:14:06Z', usage:{input_tokens,output_tokens}}
33 {type:'turn_usage', usage:{model_name,prompt_tokens,completion_tokens,total_tokens}, context_usage:{...}}
归并规则 (对应 applyEvent):
| 事件 | 动作 |
|---|---|
response.started / response.in_progress |
记录运行状态 |
response.status ∈ {completed, failed, cancelled, waiting_input} |
标记终态 |
response.created_at |
记录起始时间(只取首次) |
response.completed_at |
记录结束时间 |
message + in_progress |
按 type 新建块:reasoning→思考、plugin_call→工具、plugin_call_output→工具输出、其余→正文 |
message + completed |
用事件里的完整内容 覆盖增量拼接结果(兜底),标记块 done |
content + text + delta:true |
按 msg_id 追加到对应块的 text |
content + data + delta:true |
按 msg_id 追加工具参数(分片) |
content + data + delta:false |
工具参数取完整快照直接替换(不是追加) |
turn_usage |
记录本次运行的 token 用量 |
⚠️
delta语义是分片 vs 快照 的分水岭:true追加、false替换,搞反会导致参数重复拼接。
message.completed事件带完整内容,用它兜底可修复丢包导致的增量缺漏。
3.4 turn_usage 事件(用量与用时)
流的最后一条事件,没有 object 字段 ,容易被 object 分支直接漏掉:
json
{
"type": "turn_usage",
"session_id": "conv_xxx",
"usage": {
"provider_id": "agentx-agentx-litellm-4000",
"model_name": "deepseek-flash",
"prompt_tokens": 10247,
"completion_tokens": 31,
"total_tokens": 10278,
"context_size": 262144,
"compact_threshold": 0.8
},
"context_usage": { "estimated_tokens": 32, "max_input_length": 262144, "context_usage_ratio": 0.0122 }
}
用时 由 response 事件的 completed_at − created_at 得到(服务端时钟,比前端计时更准)。
历史回放 时同样能拿到这些数据,见 5.4。
3.5 断线重连(after_index 续流)
GET /runs/{id} 返回的 superclaw_after_index 是事件恢复锚点。断线后:
GET /runs/{id}/events?after_index=<superclaw_after_index>
只返回该索引之后的事件,不丢事件也不重复 。注意 superclaw_after_index 可能是 null(表示从头取)。
4. 地图指令协议(command 围栏)
agent 侧技能 webgis-map-visualization 约定:在回复正文之后追加一个 command 围栏代码块,前端解析后驱动地图。
4.1 协议格式
text
已将视角定位到北京。
```command
{"v":1,"commands":[
{"name":"map_pan","params":{"center":[116.4074,39.9042],"animate":true}},
{"name":"map_zoom","params":{"level":11,"duration":1.5}}
]}
```
| 字段 | 说明 |
|---|---|
v |
协议版本,固定 1。不匹配则拒绝执行(避免旧前端执行新协议语义)。 |
commands |
命令对象数组,按顺序执行。 |
name |
命令名,见下表。 |
params |
命令参数,缺失或非对象时按 {} 处理。 |
4.2 完整工具集(15 个)
| 类别 | 命令 | 参数要点 |
|---|---|---|
| 视角控制 | map_pan |
center:[lon,lat]、animate |
map_zoom |
level:1~20、duration |
|
map_fit_bounds |
bounds:[[西,南],[东,北]]、padding(像素) |
|
map_reset |
--- | |
| 图层控制 | layer_show / layer_hide / layer_toggle |
layerId |
layer_opacity |
layerId、opacity:0~1 |
|
update_layer_style |
layerId、样式字段 |
|
layer_remove |
layerId |
|
layer_clear_all |
--- | |
| 数据加载 | add_raster_layer |
layerId、tileUrl(iServer 瓦片模板) |
add_vector_layer |
尚未实现 | |
add_label_layer |
尚未实现 | |
| WebMap | load_webmap |
尚未实现 |
未实现的命令必须显式拒绝并回传明确原因,不要静默忽略------否则前端「视角飞过去了但图层没加载」这类半执行状态极难排查。
4.3 解析规则与错误兜底
extractCommands(text) 契约:
js
{ body, commands, error }
// body 剥离命令块后的正文(解析失败时同样返回剥离后的正文)
// commands 解析成功时的命令数组,失败时为空数组
// error 失败原因,成功时为 null
| 场景 | 行为 |
|---|---|
| 无围栏 | body 原文返回,commands: [],error: null |
| 多个围栏 | 全部从正文剥离 ,但只执行第一个(多块语义不清) |
| JSON 非法 | 剥离后返回正文,commands: [],error: '地图指令解析失败:不是合法 JSON' |
| 版本不匹配 | 同上,error: '地图指令协议版本不兼容(收到 v=...,期望 v=1)' |
| 命令项非法 | 逐条过滤 :name 非空字符串才保留,params 非对象则退化为 {} |
围栏正则 :/`{3,}[ \t]*command[ \t]*\r?\n([\s\S]*?)`{3,}/g,兼容 3+ 反引号与 CRLF。
正文里的围栏必须剥离后再渲染------否则用户会看到一大坨 JSON。
4.4 执行结果
执行器返回逐条结果,供 UI 展示徽标:
js
[{ name: 'map_pan', ok: true, message: '已执行' },
{ name: 'add_vector_layer', ok: false, message: '该命令尚未启用' }]
失败原因分三类:场景未就绪(viewer 未注入)、该命令尚未启用(在未实现名单里)、未知命令:xxx(拼错或协议不同步)。
5. 关键流程
5.1 发送消息 → 渲染完成(主流程)
用户输入
│
├─ 1. 乐观渲染用户气泡(本地 push,不等后端)
│
├─ 2. 无 conversationId ? ── 是 ──► POST /conversations → conversationId
│ (落 sessionStorage,刷新可恢复)
├─ 3. POST /runs { session_id: conversationId, input:[...] } → runId
│
├─ 4. GET /runs/{runId}/events (Accept: text/event-stream)
│ │
│ └─ 逐条事件 ──► applyEvent(turn, event) ──► liveTurn(实时渲染)
│
├─ 5. 流结束(response 终态 + turn_usage)
│ │
│ ├─ 5a. GET /runs/{runId} 复核状态(防止漏掉终态事件)
│ ├─ 5b. 取最后一个正文块 → extractCommands 抽指令、剥离围栏
│ ├─ 5c. execute(commands, viewer) → 结果回填徽标
│ └─ 5d. liveTurn.done = true → push 进 messages,清空 liveTurn
│
└─ 6. 状态是 waiting_input ? ── 是 ──► 见 5.3 人机交互
关键设计点
| 点 | 说明 |
|---|---|
| 用户消息乐观渲染 | 不等后端确认,input 立刻可见,失败时通过 error 提示。 |
| 终态必须复核 | SSE 可能因断线丢终态事件,GET /runs/{id} 是唯一权威。 |
| 命令只在定稿后执行 | 增量分片里的 command 块是半截 JSON,必须等流结束才能解析。 |
| 命令块从正文剥离 | 执行后把该块文本替换为「已执行地图指令 N 条」徽标。 |
5.2 断线重连
SSE 抛出异常
│
├─ 是主动 abort(切会话 / 点停止)? ── 是 ──► 直接结束,不重连
│
├─ 重试次数 < 5 ?
│ ├─ GET /runs/{id} → superclaw_after_index
│ ├─ sleep(退避:0.5s → 1s → 2s → 4s → 8s,上限 8s)
│ └─ GET /runs/{id}/events?after_index=<锚点> 递归续流
│
└─ 超过上限 ──► 写 error:「事件流中断:...」,streaming = false
并发守卫(流代次) :模块级计数器 streamGen,每次 abortStream() 自增。
consumeStream 与 finalizeTurn 在 await 前后都比对捕获时的代次,不等则丢弃------
避免「切换会话后,旧 run 的流复活并把旧回合推进新会话」。
5.3 人机交互(waiting_input)
运行终态 = waiting_input
│
├─ GET /runs/{id}/pending-requests → [{ id, kind, title, prompt, schema }]
│ └─ UI 渲染审批 / 澄清卡片
│
└─ 用户提交 ──► POST /pending-requests/{id}/submit { input }
│
└─ 返回 { run_id, after_index }
└─ 新建 liveTurn,用 after_index 续订 SSE(同一 run)
⚠️ 审批链路实测取不到真实样本 :即便
approval_level: "STRICT",Skill类插件调用仍被自动放行、运行直接completed,
pending-requests返回{items: []}。因此不要依赖pending_request.created事件 的形状,用「终态为
waiting_input→ 主动拉取 pending-requests」的兜底策略更稳。同时要处理「状态是
waiting_input但列表为空」的异常,否则界面会卡死。
5.4 历史会话
AgentX 已提供会话的增删改查,前端补齐封装即可。
打开历史列表 ──► GET /conversations?limit=50&archived=false → items[]
点击某会话 ──► 先 abortStream / 重置 run 态 ──► GET /conversations/{id}/messages
├─ 记下列表 id(UUID)作为「当前会话」,用于高亮与读消息
├─ 记下 metadata.session_id(会话句柄),用于后续 POST /runs 的 session_id
└─ normalizeHistory(history) → messages[]
重命名 ──► PATCH /conversations/{id} { title }
删除 ──► DELETE /conversations/{id}(删的是当前会话则回退空白会话)
⚠️ 切换后继续发消息 时,
session_id必须用会话句柄 而不是列表 id,否则消息会跑到一个新会话里。详见 7.2。
GET /conversations/{id}/messages 的返回结构是最大的坑:
- 返回
{ messages: [...] },不是{items, page}。 messages里每一行是一个扁平的「块」 (user 消息 / reasoning / plugin_call / plugin_call_output / 正文各占一行),
不是 一个完整回合。前端必须把连续的 assistant 行聚合成一个回合,块结构才能和实时渲染对齐。- 历史回放不重新执行地图指令 ,
mapResult恒为null。
用量与用时的历史还原 :每行 metadata 携带
json
{
"timestamp": "2026-10-16T10:55:08.090467+08:00",
"metadata": {
"qwenpaw_turn_usage": {
"usage": { "model_name": "deepseek-flash", "prompt_tokens": 33343, "completion_tokens": 254, "total_tokens": 33597 }
}
}
}
- 回合起始时间 = 其前一条 user 行 的
metadata.timestamp - 回合结束时间 = 该回合最后一条 assistant 行 的
metadata.timestamp - 回合用量 = 该回合中最后一条带
qwenpaw_turn_usage的行 (结构同实时turn_usage.usage)
5.5 回合结束标识
每个助手回合定稿后展示一条「结束标识」,口径实时与历史一致:
───────── 回答结束 · 用时 12.4s · 输入 10,247 / 输出 31 · 合计 10,278 tokens
| 段 | 来源 | 缺失时 |
|---|---|---|
| 用时 | completed_at − created_at |
省略该段(如 waiting_input 回合无 completed_at) |
| token | usage.{prompt,completion,total}_tokens |
total_tokens 缺失则省略该段 |
两段皆无 → 不渲染该行。时间倒挂(结束早于开始)时按时长无效处理。
6. 可复制代码
以下代码不依赖任何框架,可直接粘进项目使用。
6.1 最小 REST 客户端
js
const BASE = 'http://172.18.130.205:8490/agentx/autonomous/v2'
const AGENT_ID = 'GPAAgent'
/** 统一的 AgentX 请求:带 X-Agent-Id、解析裸 JSON、错误转人话 */
async function call(path, { method = 'GET', body, agentId = AGENT_ID } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
'X-Agent-Id': agentId,
...(body ? { 'Content-Type': 'application/json' } : {})
},
body: body ? JSON.stringify(body) : undefined
})
if (!res.ok) {
const text = await res.text().catch(() => '')
// AgentX 的错误体形如 { detail: "..." }
let detail = text
try { detail = JSON.parse(text).detail ?? text } catch { /* 保持原文 */ }
throw new Error(`AgentX 请求失败(${res.status}):${detail}`)
}
return res.status === 204 ? null : res.json()
}
const listAgents = (agentId = AGENT_ID) =>
call(`/agents?with_details=true`, { agentId })
const createConversation = (title, agentId = AGENT_ID) =>
call('/conversations', {
method: 'POST',
agentId,
body: { title, default_agent_id: agentId, default_mode: 'auto', channel: 'console' }
})
const createRun = (sessionId, text, agentId = AGENT_ID) =>
call('/runs', {
method: 'POST',
agentId,
body: {
input: [{ type: 'message', role: 'user', content: [{ type: 'text', text }] }],
session_id: sessionId,
channel: 'console',
options: { mode: 'auto', approval_level: 'SMART' },
metadata: { source: 'web' }
}
})
const getRun = (runId) => call(`/runs/${encodeURIComponent(runId)}`)
const cancelRun = (runId) => call(`/runs/${encodeURIComponent(runId)}/cancel`, { method: 'POST' })
const listMessages = (conversationId, agentId = AGENT_ID) =>
call(`/conversations/${encodeURIComponent(conversationId)}/messages`, { agentId })
const listConversations = (agentId = AGENT_ID) =>
call('/conversations?limit=50&archived=false', { agentId })
const renameConversation = (id, title, agentId = AGENT_ID) =>
call(`/conversations/${encodeURIComponent(id)}`, { method: 'PATCH', agentId, body: { title } })
const deleteConversation = (id, agentId = AGENT_ID) =>
call(`/conversations/${encodeURIComponent(id)}`, { method: 'DELETE', agentId })
const getPendingRequests = (runId) =>
call(`/runs/${encodeURIComponent(runId)}/pending-requests`)
const submitPendingRequest = (requestId, input) =>
call(`/pending-requests/${encodeURIComponent(requestId)}/submit`, { method: 'POST', body: { input } })
6.2 SSE 增量解析器
关键点:块分隔符可能被 chunk 切断,必须留缓冲区。
js
/** 兼容 \n\n、\r\n\r\n、\n\r\n、\r\n\n */
const BLOCK_SEPARATOR_RE = /\r?\n\r?\n/
/** 增量 SSE 解析器:喂入文本片段,返回本次解析出的事件数组 */
function createSseParser() {
let buffer = ''
return function push(chunk) {
buffer += chunk
const events = []
let match
while ((match = BLOCK_SEPARATOR_RE.exec(buffer)) !== null) {
const block = buffer.slice(0, match.index)
buffer = buffer.slice(match.index + match[0].length)
const event = parseBlock(block)
if (event !== null) events.push(event)
}
return events
}
}
function parseBlock(block) {
const dataLines = []
for (const rawLine of block.split('\n')) {
const line = rawLine.replace(/\r$/, '')
if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''))
}
if (dataLines.length === 0) return null
const payload = dataLines.join('\n')
if (payload === '[DONE]') return null
try {
return JSON.parse(payload)
} catch {
return null // 单个坏块不影响整条流
}
}
/**
* 订阅一次运行的事件流
* @param {string} runId
* @param {(event:object)=>void} onEvent
* @param {{agentId?:string, afterIndex?:number|null, signal?:AbortSignal}} [options]
*/
async function subscribeRunEvents(runId, onEvent, { agentId = AGENT_ID, afterIndex, signal } = {}) {
const query = afterIndex == null ? '' : `?after_index=${encodeURIComponent(afterIndex)}`
const res = await fetch(
`${BASE}/runs/${encodeURIComponent(runId)}/events${query}`,
{
// ⚠️ 必须带 Accept,否则服务端返回 500
headers: { Accept: 'text/event-stream', 'X-Agent-Id': agentId },
signal
}
)
if (!res.ok) throw new Error(`AgentX 事件流请求失败:HTTP ${res.status}`)
if (!res.body) throw new Error('AgentX 事件流无响应体')
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
const push = createSseParser()
for (;;) {
const { value, done } = await reader.read()
if (done) break
for (const event of push(decoder.decode(value, { stream: true }))) onEvent(event)
}
// 冲掉解码器里残留的多字节字符
for (const event of push(decoder.decode())) onEvent(event)
}
6.3 事件归并器(最小可用版)
把事件流折叠成一个助手回合。
js
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'waiting_input'])
const createTurn = () => ({
blocks: [], runStatus: null, done: false,
usage: null, startedAt: null, endedAt: null
})
const textOf = (e) =>
(Array.isArray(e.content) ? e.content : [])
.filter((p) => p?.type === 'text')
.map((p) => p.text)
.join('')
const firstDataOf = (e) =>
(Array.isArray(e.content) ? e.content : []).find((p) => p?.type === 'data')?.data || null
const kindOf = (type) =>
({ reasoning: 'reasoning', plugin_call: 'tool', plugin_call_output: 'toolOutput' }[type] || 'text')
/** 纯函数:返回新回合,不改传入值 */
function applyEvent(turn, event) {
if (!turn || !event || typeof event !== 'object') return turn
const next = { ...turn, blocks: turn.blocks.slice() }
// 运行态
if (event.object === 'response') {
if (typeof event.status === 'string') {
next.runStatus = event.status
if (TERMINAL.has(event.status)) next.done = true
}
if (event.created_at && !next.startedAt) next.startedAt = event.created_at
if (event.completed_at) next.endedAt = event.completed_at
return next
}
// 用量(⚠️ 无 object 字段,必须单独判断)
if (event.type === 'turn_usage') {
if (event.usage) next.usage = event.usage
return next
}
// 内容增量
if (event.object === 'content') {
const i = next.blocks.findIndex((b) => b.msgId === event.msg_id)
if (i === -1) return next
const block = { ...next.blocks[i] }
if (event.type === 'text') {
block.text = (block.text || '') + (event.text || '')
} else if (event.type === 'data') {
const d = event.data || {}
if (!block.callId && d.call_id) block.callId = d.call_id
if (!block.name && d.name) block.name = d.name
if (typeof d.arguments === 'string' && d.arguments) {
// delta:true 追加分片;delta:false 是完整快照,直接替换
block.args = event.delta === true ? (block.args || '') + d.arguments : d.arguments
}
if (typeof d.output === 'string' && d.output) block.output = d.output
if (event.status === 'completed') block.done = true
}
next.blocks[i] = block
return next
}
// 块开始 / 结束
if (event.object === 'message') {
const i = next.blocks.findIndex((b) => b.msgId === event.id)
if (event.status === 'completed') {
if (i === -1) return next
const block = { ...next.blocks[i], done: true }
const full = textOf(event)
if (full) block.text = full // 用完整内容兜底覆盖增量拼接
const d = firstDataOf(event)
if (d) {
if (d.call_id) block.callId = d.call_id
if (d.name) block.name = d.name
if (typeof d.arguments === 'string' && d.arguments) block.args = d.arguments
if (typeof d.output === 'string' && d.output) block.output = d.output
}
next.blocks[i] = block
return next
}
if (i !== -1) return next
next.blocks.push({
msgId: event.id, kind: kindOf(event.type),
text: '', name: '', args: '', output: '', callId: null, done: false
})
return next
}
return next
}
6.4 command 块解析
js
const COMMAND_PROTOCOL_VERSION = 1
const COMMAND_FENCE_RE = /`{3,}[ \t]*command[ \t]*\r?\n([\s\S]*?)`{3,}/g
/** 从助手正文中抽取命令块 */
function extractCommands(text) {
const source = typeof text === 'string' ? text : ''
// 每次新建实例,避免 lastIndex 在多次调用间残留
const matches = Array.from(source.matchAll(new RegExp(COMMAND_FENCE_RE.source, 'g')))
if (matches.length === 0) return { body: source, commands: [], error: null }
// 所有命令块都从正文剥离;只执行第一个
const body = source.replace(new RegExp(COMMAND_FENCE_RE.source, 'g'), '').trim()
let parsed
try {
parsed = JSON.parse(matches[0][1])
} catch {
return { body, commands: [], error: '地图指令解析失败:不是合法 JSON' }
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { body, commands: [], error: '地图指令解析失败:顶层不是对象' }
}
if (parsed.v !== COMMAND_PROTOCOL_VERSION) {
return { body, commands: [], error: `地图指令协议版本不兼容(收到 v=${parsed.v},期望 v=${COMMAND_PROTOCOL_VERSION})` }
}
if (!Array.isArray(parsed.commands)) {
return { body, commands: [], error: '地图指令解析失败:commands 不是数组' }
}
const commands = parsed.commands
.filter((c) => c && typeof c === 'object' && !Array.isArray(c) && typeof c.name === 'string' && c.name)
.map((c) => ({
name: c.name,
params: c.params && typeof c.params === 'object' && !Array.isArray(c.params) ? c.params : {}
}))
return { body, commands, error: null }
}
6.5 端到端最小脚本(Node 18+,跑通全链路)
把 6.1 的 call、6.2 的 subscribeRunEvents、6.3 的 applyEvent、6.4 的 extractCommands
放进同一个文件后,追加下面的 main() 即可运行:
js
async function main() {
const QUESTION = '把地图定位到北京'
// 1. 建会话
const conv = await createConversation(QUESTION.slice(0, 20))
console.log('会话:', conv.id)
// 2. 建运行
const run = await createRun(conv.id, QUESTION)
console.log('运行:', run.id)
// 3. 订阅事件流,实时打印正文增量
let turn = createTurn()
await subscribeRunEvents(run.id, (event) => {
turn = applyEvent(turn, event)
if (event.object === 'content' && event.type === 'text') process.stdout.write(event.text)
})
// 4. 复核终态(SSE 可能丢终态事件)
const status = await getRun(run.id)
console.log('\n\n运行状态:', status.status)
// 5. 取最后一个正文块,抽命令
const lastText = [...turn.blocks].reverse().find((b) => b.kind === 'text' && b.text)
if (lastText) {
const { body, commands, error } = extractCommands(lastText.text)
console.log('正文:', body)
if (error) console.log('指令解析失败:', error)
console.log('抽到命令:', JSON.stringify(commands, null, 2))
}
// 6. 用量与用时
console.log('用量:', turn.usage)
console.log('用时(ms):', Date.parse(turn.endedAt) - Date.parse(turn.startedAt))
}
main().catch(console.error)
一次真实运行输出:
会话: conv_b599cc0d3af7410f928f154cf9de67b6
运行: 5ffd54d2dd044314b57f6a119a44cb26
你好
运行状态: completed
正文: 你好
抽到命令: []
用量: { provider_id: '...', model_name: 'deepseek-flash', prompt_tokens: 10247, completion_tokens: 31, total_tokens: 10278 }
用时(ms): 2000
7. 集成踩坑清单
7.1 X-Agent-Id 是分区键,不是普通元数据
会话列表、消息历史、运行事件全部按该头部所属智能体分区。
| 请求 | 结果 |
|---|---|
/conversations/{id}/messages + X-Agent-Id: GPAAgent(正确) |
200 |
同上,不带头部 / X-Agent-Id: default |
404 Chat not found |
/conversations + X-Agent-Id: default |
total = 0(列表为空) |
/conversations + X-Agent-Id: GPAAgent |
正常运行时的会话数 |
踩坑经过 :ConversationResource.default_agent_id 看起来像是「这个会话属于哪个智能体」,
但那只是存储字段 (实测恒为 "default")。早期实现拿它当 X-Agent-Id 去拉历史 → 直接 404,
而且会把前端当前智能体改成 default,导致会话列表变空、后续发送也失败。
结论 :X-Agent-Id 始终用「当前操作的智能体」 ,不要去读会话的 default_agent_id。
7.2 会话有两个 ID:session_id 只能用「会话句柄」
这是本系统最隐蔽的一个坑,且不会报错,只表现为「消息跑丢了」。
| 接口 | 返回的 ID | 形态 |
|---|---|---|
POST /conversations(创建) |
会话句柄 id |
conv_63840d30bec24c9a93040845f78e340b |
GET /conversations(列表) |
会话 UUID id |
adbb2308-95c9-49bf-a1c8-88faa8735eaa |
列表项的 metadata.session_id 才是对应的会话句柄。
用同一个会话实测 POST /runs 的两种 session_id:
session_id 传值 |
结果 |
|---|---|
会话句柄 conv_63840d30... |
✅ 续到原会话(原会话消息数 0 → 3) |
会话 UUID adbb2308-... |
❌ 另起一个新会话(列表里凭空多出一条,标题取首条消息) |
而 GET /conversations/{id}/messages 两者都接受 (都返回 200)------
这就是它隐蔽的原因:读没问题,写才错。
踩坑经过 :历史会话列表用的是 UUID,点击切换后前端把 UUID 记成了「当前会话」,
下一次发送就把它当 session_id 发给 POST /runs → 消息进了一个全新会话,
原会话里什么也没有,且没有任何报错。
结论:
POST /runs的session_id只传会话句柄 ;从列表切进来的会话要取metadata.session_id,取不到再回退到列表id。- 前端最好把两者分开存:
conversationId(列表 UUID,用于列表高亮 / 读消息)与sessionId(会话句柄,用于建运行)。
7.3 SSE 必须带 Accept: text/event-stream
不带该头,GET /runs/{id}/events 返回 500。
7.4 turn_usage 事件没有 object 字段
归并器通常按 event.object 分支。若把 turn_usage 放在 object 判断之后 且不做单独判断,
它会落到「未知事件原样返回」的分支被静默丢弃 ------用量就永远拿不到。
必须放在 response 分支后单独判断 event.type === 'turn_usage'。
7.5 delta 的两种语义
content + data 事件里:
delta: true→ 分片增量 ,必须block.args += d.arguments追加delta: false→ 完整快照 ,必须block.args = d.arguments替换
搞反会导致工具参数重复拼接('{"a"}{"a"}')或只剩最后一片。message.completed 事件带完整内容,用它兜底可修复。
7.6 命令块必须等流结束再解析
增量分片里 command 围栏是半截 JSON ,中途 JSON.parse 必然失败。
必须在回合定稿后取最后一个正文块解析。
7.7 GET /conversations/{id}/messages 返回 {messages} 而非 {items}
且每行是扁平块 不是完整回合,需要前端聚合连续的 assistant 行。
误读 history.items 会静默拿到空列表(刷新恢复后对话消失,但不报错)。
7.8 pending_request.created 事件不可依赖
实测审批链路拿不到真实样本(工具调用被自动放行)。改用
「终态为 waiting_input → 主动 GET /runs/{id}/pending-requests」的兜底策略。
同时处理「状态 waiting_input 但列表为空」的异常,否则界面卡死。
7.9 终态必须复核
SSE 断线可能丢掉终态事件。流结束后始终 GET /runs/{id} 复核状态,这是唯一权威来源。
7.10 并发守卫:切会话 / 停止后的过期响应
快速切换会话或点停止时,旧请求的 await 可能在之后才返回。必须:
- 切换会话 :
await回来后比对conversationId是否仍是本次目标,不是则整体丢弃。 - 流代次 :模块级
streamGen计数器,abortStream()自增;续流与定稿在await前后比对代次。
否则会出现「旧会话的回合被推进新会话」「停止后流又复活」。
7.11 CORS 与生产代理
开发期 AgentX 回显 Origin,浏览器可直连。生产环境请在 Web 服务器配反向代理,并不要 rewrite 掉 /agentx 前缀(路径本身带该前缀)。
8. 参考实现索引
| 模块 | 文件 | 职责 |
|---|---|---|
| REST 封装 | src/api/modules/agentx.js |
全部接口封装、X-Agent-Id 注入、错误转人话 |
| SSE 解析与订阅 | src/utils/agentx/stream.js |
增量解析器、subscribeRunEvents |
| 事件归并 | src/utils/agentx/eventReducer.js |
createTurn / applyEvent,纯函数 |
| 历史转换 | src/utils/agentx/history.js |
normalizeHistory:扁平块 → 回合,还原用量与时间 |
| 用量格式化 | src/utils/agentx/turnMeta.js |
formatDuration / formatCount / buildTurnMeta |
| 指令解析 | src/utils/agentx/commandParser.js |
extractCommands,围栏剥离与协议校验 |
| 状态编排 | src/modules/map/stores/useAiChatStore.js |
建会话 / 建运行 / 订阅 / 重连 / 并发守卫 / 历史会话 |
| 指令执行 | src/utils/supermap/aiCommandExecutor.js |
execute(commands, viewer),12 个命令 |
| 图层注册表 | src/utils/supermap/aiLayerRegistry.js |
AI 创建的图层登记与引用解解析 |
| UI 面板 | src/modules/map/components/ai/AiChatPanel.vue |
面板、历史弹层、输入区 |
| 消息渲染 | src/modules/map/components/ai/ChatMessageItem.vue |
回合按块渲染、结束标识、指令执行徽标 |
| 审批卡片 | src/modules/map/components/ai/PendingRequestCard.vue |
人机交互表单 |
单元测试:tests/unit/{eventReducer,history,turnMeta,commandParser,stream,aiCommandExecutor,aiChatStore}.spec.js
(真实抓取的 SSE 与历史数据放在 tests/fixtures/)