SuperMap AgentX Server智能体接入WEB应用技术文档

SuperMap AgentX Server智能体接入WEB应用技术文档

注: 本文由开发实测后,由AI整理完成

面向未接触过 AgentX 的前端 / 全栈开发者。读完应当能独立把「对话式智能体 + 地图指令执行」接进任意 Web 项目。

本文只讲协议与流程,不涉及部署与环境配置。示例代码可直接复制运行(Node 18+ / 现代浏览器)。


目录

  1. 整体架构
  2. 接口清单
  3. [SSE 事件流详解](#SSE 事件流详解)
  4. [地图指令协议(command 围栏)](#地图指令协议(command 围栏))
  5. 关键流程
  6. 可复制代码
  7. 集成踩坑清单
  8. 参考实现索引

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/)

相关推荐
CRMEB1 小时前
商品 SKU 设计,从简单规格到多规格踩过什么
java·前端·数据库·开源
Chloe.Zz1 小时前
一篇入门Agent
开发语言·前端·javascript
java1234_小锋1 小时前
【技术专题】HTML5 网页技术 - HTML5 表格标签
前端·html·html5
雪芽蓝域zzs1 小时前
JS 项目转 TS 项目(Vue3 + 若依这类后台项目)完整改造清单
前端·vue.js
定栓1 小时前
Webpack迁到Vite第四节:SVG 图标系统
前端·webpack·node.js
Dovis(誓平步青云)2 小时前
车辆进入隧道后信号断开,座舱旅程怎样补回事件
java·开发语言·前端·数据库·电脑
定栓2 小时前
Webpack迁到Vite第六节:构建配置详解(vite.config.js)
前端·javascript·webpack
风骏时光牛马2 小时前
从零到精通AI全栈学习路线
前端