@blade-hq/agent-client
Blade Agent 的框架无关客户端。浏览器和 Node.js 都能用;用 Vue、Svelte 或自建 UI 的团队直接用这个包,React 团队一般用上层的 @blade-hq/agent-react。
它做四件事:
- 实时会话(
AgentSession):把 Socket.IO 协议、历史加载、流式合流、断线重连全部封装掉,你只面对"状态快照 + 动作 + 事件"。 - 登录:
client.auth.login()弹窗授权,用户点一下"许可授权"就拿到访问令牌,不用手工复制粘贴。 - REST:类型化会话和模型目录(
client.sessions.*、client.models.list())。其他长尾接口对照 Swagger 用原生fetch+client.token调用。 - 部署端点:从同源
config.json读取其他 Blade 服务的公开地址,不根据主机名和固定端口猜测拓扑。
npm install @blade-hq/agent-client
默认装到的是当前长期支持版(LTS),厂内离线交付按它开发。要跟两周一发的公网版本,改用 @next。
最稳妥的做法是先读目标 Server 的 GET /api/version,按返回的版本号在 package.json 里钉死——NPM 的 latest 不一定和目标环境跑的版本一致。
快速开始
<button id="login">登录 Blade</button>
import { BladeClient, getTextContent } from "@blade-hq/agent-client"
const client = new BladeClient({ baseUrl: "https://blade.example.com" })
// 登录必须由用户点击触发:浏览器会拦截非用户操作弹出的窗口
document.querySelector("#login")!.addEventListener("click", async () => {
await client.auth.login()
const chat = await client.sessions.create()
chat.subscribe(() => {
const { messages, isStreaming } = chat.getState()
// 换成你自己的渲染。content 可能是字符串也可能是内容块数组(多模态消息),
// 用 getTextContent 两种都能处理,不要自己写 typeof 判断
console.log(messages.map((m) => getTextContent(m.content)), isStreaming)
})
await chat.send("帮我分析一下这份数据")
})
Node.js / 自动化脚本用 token(PAT 在 Blade 设置页创建):
const client = new BladeClient({
baseUrl: "https://blade.example.com",
token: process.env.BLADE_TOKEN,
})
部署端点
Blade 平台前端需要跳转其他服务时,读取当前 origin 的公开配置:
import { loadPlatformEndpoints, resolveServiceUrl } from "@blade-hq/agent-client"
const endpoints = await loadPlatformEndpoints()
const hubUrl = resolveServiceUrl(endpoints, "hub", "/skills/42")
if (hubUrl) window.open(hubUrl)
loadPlatformEndpoints() 请求 config.json,超过 3 秒、网络失败或配置非法时返回空配置;
调用方应隐藏对应入口。文件只能放浏览器可访问的公开地址,不能写 Docker 服务名、令牌或
其他内部配置。应用部署在子路径时,通过 baseUrl 显式传入部署根路径。
resolveServiceUrl() 的 path 只接受服务内相对路径;绝对 URL、反斜杠和越出服务
base path 的路径返回 null。
公开类型为 PlatformEndpoints、PlatformServiceName、LoadPlatformEndpointsOptions;
需要同步初始化时可直接使用 EMPTY_PLATFORM_ENDPOINTS。
BladeClient
构造
new BladeClient({
baseUrl: "https://blade.example.com", // 后端地址;同域部署可传 ""
token: "sk-blade-xxx", // 可选:PAT。不传则用 cookie 或 login()
tokenStorage: "local", // 可选:login() 的令牌存哪("local" 默认 / "memory")
})
高级宿主可通过 BladeClientOptions.commandTransport(event, payload, send) 接管带回执的命令传输,例如浏览器插件让后台持有发送请求,面板关闭后仍能等待请求结束并停止任务。返回原始服务端 ack,失败时抛错;不接管的命令调用 send() 使用默认 Socket.IO。事件订阅仍使用原 socket。client.requestCommand(event, payload, timeoutMs) 是同一通道的低层入口,普通应用继续使用类型化的 AgentSession 方法。
底层 createSocket 允许指定 transports;MV3 service worker 没有 XMLHttpRequest,可使用 ["websocket"]。不指定时保持 Socket.IO 默认选择。
SDK 会固定订阅普通聊天所需的完整实时事件。产品内置 Web 的精简/开发者展示模式不属于 公共 SDK 契约,也没有对应的构造参数或 Socket.IO 字段。
baseUrl填哪个地址? 必须是 Blade Agent 后端的地址(形如http://<主机>:8020),只要域名和端口、不带路径。 注意别填成你平时打开的 Blade OS 地址(同主机的:80)—— 那是另一套接口,SDK 连不上。
登录相关
await client.auth.login() // 弹窗授权登录,成功后 REST 与实时连接立即生效
client.auth.logoutToken() // 清除 login() 存的令牌
client.setToken("sk-blade-x") // 手动热挂载令牌(null 表示清除)
client.hasToken() // 是否持有令牌
client.token // 当前令牌(长尾接口自行调用时填进 Authorization 头)
await client.auth.getMe() // 当前用户信息(未登录时抛 401)
await client.auth.getProviders() // 服务端支持的登录方式
login() 失败时的错误都带中文原因:弹窗被拦截、窗口被关闭、超时。
令牌要留在自己的服务端时,浏览器只把授权回调拿到的一次性 code 交给后端,由后端换:
// ExchangeCodeParams -> ExchangeCodeResult
const { access_token } = await client.auth.exchangeCode({ code, state, clientOrigin })
clientOrigin 要和发起授权时用的 origin 一致;服务端调用会自动补 Origin 头。
会话:client.sessions
实时对话(推荐入口)
// 连接已有会话
const chat = await client.sessions.connect("会话id")
// 或:创建新会话并直接连接
const newChat = await client.sessions.create()
const configured = await client.sessions.create({ intent: "数据分析", model: "gpt-4o" })
两者都返回 AgentSession。
声明完整会话
自定义 Solution、Skill 和模型无需手写上传步骤:
const chat = await client.sessions.create({
solution: {
role: "resume-writer",
files: [
{ path: "solution.yaml", content: solutionYaml },
{ path: "roles/resume-writer/role.yaml", content: roleYaml },
{ path: "roles/resume-writer/AGENTS.md.j2", content: rolePrompt },
],
},
skills: [{
name: "my/resume",
files: [{ path: "SKILL.md", content: "# 简历制作" }],
}],
config: { model: "qwen3.5-122b-int4" },
})
Solution 和 Skill 都声明为 { path, content }[] 文本文件;SDK 负责准备资源、创建会话和安装 Skill,成功返回时 chat 已可使用。已有 Solution 改传 solution: { id, role }。模型值是 Server 模型目录中的 ID,LLM 地址与密钥应由 Server 管理。
SkillDefinition.files 使用 SkillFile[]:文本文件仍填写 path 和 content;二进制文件将内容编码为 base64,并设置 encoding: "base64"。自带可执行文件另设 executable: true。这两个可选字段适用于沙盒会话,不扩展 Solution 的 TextFile 格式。
sessionId从哪来? 三种,按需选:
- 不用管它 —— 首次接入最省事:
client.sessions.create()直接建一个新会话,用不着 ID。React 的<ChatView />不传sessionId也一样。- 自己建、自己存:
const { session_id } = await client.sessions.createSessionWithRequest({ intent: "季度报表分析" })。把返回的session_id(后端是蛇形命名)存进你的数据库或 URL,用户下次进来传给connect(sessionId)(前端参数是驼峰),就能接着上次的对话。- 列出用户已有的:
await client.sessions.listSessions(),让用户自己挑。
会话管理(REST)
await client.sessions.listSessions() // 会话列表
await client.sessions.searchOwnedContent({ q: "项目报告" }) // 搜索自己的对话和文件名
await client.sessions.getSession(id) // 会话详情
await client.sessions.updateSession(id, { intent: "新标题" })
await client.sessions.deleteSession(id)
await client.sessions.getSessionTurns(id) // 历史消息(投影格式,与实时流同构)
const page = await client.sessions.getSessionTurnsPage(id) // 聊天首屏 recent page
await client.sessions.getSessionTurnsPage(id, { before: page.nextBefore! })
// 从某条用户发言定位到最新(消息导航点窗口外的发言):调用参数是 SessionTurnsQuery
await client.sessions.getSessionTurnsPage(id, { fromEntryId: "entry-id" })
// 当前分支上全部可导航的用户发言目录(entry_id + 标题 + 时间,不含消息体)
const index = await client.sessions.getSessionTurnIndex(id) // SessionTurnIndex
const first = index.items[0] // SessionTurnIndexItem
SessionTurnsQuery 的 before 与 fromEntryId 互斥;定位命中不了当前分支时服务端返回
404 / 409,而不是空窗口。目录的 entryId 与 /turns 的 turn_id 同源,可直接当 before 游标。
会话插件可通过 client.sessions.listSessionPlugins(id) 查询,并用
client.sessions.setSessionPluginActivation(id, name, active) 切换当前会话。列表项为 SessionPlugin;修改结果为 SessionPluginActivation,只返回选择和准备状态,安装事实需从列表获取。
账号级目录是 client.plugins.catalog()(PluginsResource 是 client.plugins
的类型)。它不依赖任何会话,返回 PluginCatalogEntry[]:name、display_name、
icon、installed。没有会话也要展示插件名称与图标的界面(例如首页的输入框连接器)
用它,而不是先建一个会话。
icon 是不透明的缓存 token(当前是包内容摘要),不是路径:把它交给
client.plugins.fetchIcon(name) 取字节再转 object URL,不要拿它拼 URL,也不要把
<img> 指向接口路径(token 用户会拿到 401/403)。
const { plugins } = await client.plugins.catalog()
// plugins: PluginCatalogEntry[]
// name 技术 ID:路径、授权、缓存与调用一律用它
// display_name 包声明的业务名;null 时界面回退到 name
// icon 包内图标的 opaque token;null 表示没有可用图标
// installed 当前用户是否安装了它
const blob = await client.plugins.fetchIcon(plugins[0].name)
const url = URL.createObjectURL(blob)
账号电脑目录是 client.computers.listAccount(),返回 AccountComputerList。它只回答
账号事实(登记过哪些电脑、此刻在不在线,AccountComputer),不含"本次会话能不能
用它"——那是会话接口 (client.computers.list) 的回答。两者混着说,用户就会把在线当成
已授权。
插件可以在包内 cn.com.bladeai.agent/config.schema.json 用标准 JSON Schema
(Draft 2020-12)声明账号、token 或任意业务配置。声明了配置的插件用
client.sessions.getSessionPluginConfig(id, name) 读取声明与当前值,用
client.sessions.updateSessionPluginConfig(id, name, { expected_revision, set, remove })
保存;两者都要等到插件包已准备(即已激活过)才有内容,没有声明的插件返回
state: "not_required"。
const { config } = await client.sessions.getSessionPluginConfig(id, "crm")
// config: SessionPluginConfigState
// state "not_required" | "required" | "configured" | "error"
// schema 包内声明的 JSON Schema(Draft 2020-12)
// values 已存值,writeOnly 字段已被平台剔除
// write_only 当前有值的秘密字段 JSON Pointer
// revision 乐观并发用的版本号
// errors SessionPluginConfigError[],逐条 { path, message },不含用户输入
// 列表项 SessionPlugin.config 是 SessionPluginConfigSummary
// (SessionPluginConfigStateName 之外多一个 "unchecked":列表只报本地已知事实,
// 不伪报已配置)
await client.sessions.updateSessionPluginConfig(id, "crm", {
expected_revision: config.revision,
set: [{ path: "/login/password", value: password }], // SessionPluginConfigSet[]
remove: [],
} satisfies SessionPluginConfigUpdate)
图标用 client.sessions.fetchSessionPluginIcon(id, name) 取 Blob(自带凭据),
或 client.sessions.sessionPluginIconUrl(id, name) 取已鉴权 URL。
set/remove 只表达显式改动:没出现的字段保持原值,因此表单不需要回传秘密;
null 是一个合法值,不是删除。校验不通过或版本过期时请求被拒绝,磁盘上的旧
配置保持不变。平台只写用户 Home 下的 ~/.plugin/<plugin_id>/config.json,不会
重启 MCP、清缓存或刷新 token。
会话回放(演示 / 彩排)
拿一个已经聊完的会话当素材,重现当时的回复和工具调用,完全不调用模型。 用来做演示和彩排:离线可跑、不花钱、不会临场翻车。
它不是把现有会话切成回放模式,而是派生一个新会话,源会话原样不动:
// 1. 能不能拿它当素材(有并行子智能体、运行快照不完整的不行)
const { supported, reason } = await client.sessions.getReplayPreview(sourceId)
if (!supported) return show(reason) // reason 可直接展示给用户
// 2. 派生回放会话——这一步才是"进入回放",返回的是一个全新的 session_id
const { session_id: replayId } = await client.sessions.startReplaySession(sourceId)
const fast = await client.sessions.startReplaySession(sourceId, 2) // 或显式 1 | 2 | 5
// 3. 连上它,一出生就带着 replay 状态
const session = await client.sessions.connect(replayId)
session.getState().replay
// → { isReplay: true, speed: 5, sourceSessionId: sourceId }
不传倍速时用 DEFAULT_REPLAY_SPEED;所有创建入口都该用它,免得同一个源对话
从不同入口开出来速度不一样。
连上之后不会自动播——它跟用户对台词(下面的 session 都是第 3 步连上的回放会话):
// 源会话当初第一句问的是"帮我查下当前目录"
await session.send("帮我查下当前目录") // 对得上:按倍速重现当时的回复和工具调用
await session.send("今天天气怎么样") // 对不上:抛 replayMismatch 等你决定(见下)
回放中的状态和动作都挂在会话上,和 send() 同级:
session.getState().replay // { isReplay, speed, sourceSessionId } | null(不是回放会话)
session.getState().viewerRole // "viewer" 表示只读,改不动回放
await session.setReplaySpeed(2)
await session.exitReplay() // 退出回放,之后的对话真的运行
改回放是 owner 专属(服务端 PATCH 走 owner 校验),只读身份下这两个动作是 no-op。
replay 是 SessionState 的一部分,跟着快照订阅走——Vue 直接接 shallowRef 即可,
不用自己拉状态:
const state = shallowRef(session.getState()) // session = connect(replayId) 的返回值
session.subscribe(() => (state.value = session.getState()))
// 模板里 state.value.replay?.isReplay / state.value.replay?.speed
连接会话时会一并拉好初始回放状态,所以第一帧就知道自己在不在回放。
输入冲突:用户说的话和录制内容对不上时,服务端会推 replayMismatch,必须在事件里给出决定:
session.on("replayMismatch", ({ expectedMessage, actualMessage, respond }) => {
respond("keep_replay") // 按录制内容继续
// respond("continue_replay") // 从这里开始真的运行
})
replayMismatch 只交给最先注册的处理器;一个都没注册时默认 continue_replay
(静默转为真实运行)。React 应用可直接用 @blade-hq/agent-react 的 useReplay()。
工作区文件
await client.sessions.listDir(id, "") // 列目录
const upload = await client.sessions.uploadFiles(id, "", fileInput.files) // 支持进度回调
upload.results[0] // { index: 0, path: "..." },失败时为 { index: 0, error: "..." }
await client.sessions.writeFile(id, "notes.md", "# 内容")
await client.sessions.deleteFile(id, "notes.md")
client.buildAuthedUrl(`/api/sessions/${id}/files/report.pdf`) // 带鉴权的下载/预览地址
连接后的 AgentSession 提供最常用的短写法:
await chat.listDir("输出")
await chat.countFiles() // 递归统计,不含目录
await chat.downloadFile("输出/简历.md", "简历.md") // 浏览器下载
对符合评价条件的最新整体结果,可读取或更新当前用户的反馈:
const feedback = await client.sessions.listResultFeedback(id)
await client.sessions.putResultFeedback(id, assistantEntryId, {
helpful: false,
reason: "incomplete",
})
ResultFeedbackReason 是固定枚举;Server 会校验结果资格、写权限,并自行推导 Chat / 软件工厂场景和项目 ID。
模型目录
const { default: defaultModel, models, baseUrl, defaultServiceModel } = await client.models.list()
// models: Array<{ id, label, serviceModelId }>
// baseUrl: 平台默认模型服务的 OpenAI 兼容地址,自建应用带用户令牌可直接调;
// 各部署端口不同,别写死,老版本 Server 返回空串。
建会话选模型用 default / id,直接调 baseUrl 用 defaultServiceModel / serviceModelId。
两套名字不通用:id 是平台内部标识,平台接了多个模型服务时形如
provider-xxx::deepseek-v4-flash,拿它去调 baseUrl 只会得到「模型不存在」。
serviceModelId 为空串表示这个模型不在 baseUrl 那个服务上,做模型选择器时按它过滤。
// 自己调模型服务
const body = { model: defaultServiceModel, messages, stream: true }
baseUrl 是服务端视角的地址。平台常配成 http://127.0.0.1:30000/v1——从自己后端转发
没问题,直接交给浏览器就指向用户自己的机器了。默认形态是后端透传(密钥本来也不该进浏览器),
浏览器直连只适合这个地址对浏览器同样可达的场景。
远程电脑:client.computers
一个会话除了自己的运行时,还可以操作用户接入的其他电脑(blade daemon connect 接进来的机器)。
默认一台都不能用,要先为这次会话启动它。
const { computers } = await client.computers.list(sessionId)
// SessionComputerList → { computers: SessionComputer[] }
// SessionComputer: { id, label, os, arch, home, workspace, allowed_paths,
// online, enabled, is_primary, last_seen_at, ... }
await client.computers.setEnabled(sessionId, computer.id, true) // 启动
await client.computers.setEnabled(sessionId, computer.id, false) // 停用
online 和 enabled 是两件独立的事:前者指那台电脑此刻连着后端,后者指这次会话已经启动了它。
离线的电脑也能先启动,等它连上就直接可用。online 由服务端读心跳时间判定,不做实时探测,
所以电脑离线时列表照样返回它——你拿得到「它离线了」这个结论。
is_primary 表示这次会话本身就跑在这台电脑上。这种电脑恒为可用,也不能停用——
那是会话自己的工作目录所在。
做电脑选择器时用这几个纯函数,别自己重算状态:
import {
canToggleComputer, computerState, sortComputers,
computerOS, computerPlatformLabel, computerDaemonVersion,
} from "@blade-hq/agent-client"
sortComputers(computers) // 按接入时间,顺序稳定不随状态变化
computerState(computer) // ComputerState: "primary" | "enabled" | "offline" | "idle"
canToggleComputer(computer) // 主运行时返回 false
computerOS(computer) // ComputerOS: "macos" | "windows" | "linux" | "unknown"
computerPlatformLabel(computer) // "darwin/arm64"
computerDaemonVersion(computer) // "dev (bc3ad71d1)",未知时是空串
sortComputers 刻意不按在线/可用排序:那样排看着"手边的在前面",代价是电脑
上下线、勾选状态一变整个列表就重排,用户正要点的那一项会在手指底下跑掉。改名也
不会挪位置,新接入的稳定排在末尾。
computerOS 判定的是 daemon 上报的 runtime.GOOS,各处自己 startsWith 一遍必然会分叉;
图标怎么画交给界面,这里只回答"是哪一类系统"。
computerDaemonVersion 返回的是服务端存的完整串——dev 构建带 commit,
因为 dev 的版本号全都是 dev,光看它分不出是哪次构建的二进制。不要在前端另拼一套格式。
ComputersResource 是 client.computers 的类型。
AgentSession
一个会话的实时状态机。状态归属实例:同一页面建多个会话互不干扰。
状态
chat.getState()
// {
// messages, 聊天消息列表(直接渲染)
// isStreaming, 是否正在回复
// status, 会话状态:created / running / completed / failed /
// interrupted / waiting_for_input(未加载时为 null)
// mode, "planning" | "executing" | null
// connection, 连接状态:"connected" | "connecting" | "reconnecting" | "disconnected"
// errorMessage, 最近一次运行错误
// replay, 回放状态:{ isReplay, speed, sourceSessionId } | null
// queue, 等待消息队列快照:{ session_id, revision, paused, pause_reason, items }
// viewerRole, "owner" | "viewer" | null(只读身份改不动会话)
// nextBefore, loadingOlder, 历史分页游标与加载状态
// turns, askAnswers, agentLoops, activeCompaction 进阶字段
// }
const unsubscribe = chat.subscribe(() => rerender(chat.getState()))
快照不可变:状态一变就换新对象引用,可直接接 React useSyncExternalStore 或 Vue shallowRef(见 public-skills 的 Vue 接入文档)。
动作
const accepted = await chat.send("你好") // 服务端接受后为 true;拒绝时为 false
await chat.send("换个方案", { mode: "planning" }) // 指定模式/模型等选项
await chat.queueMessage("补充:预算不超过 5 万") // 运行中入队;返回服务端确认结果与冲突原因
await chat.stop() // 停止当前回复(同时暂停队列,需 resumeQueue 继续)
await chat.compact() // 手动压缩上下文
if (chat.hasOlderHistory) await chat.loadOlderHistory()
chat.dispose() // 彻底释放(什么时候该调见下方说明)
什么时候调
dispose()? 组件卸载时不要调 —— 会话实例由 client 缓存着,用户切走再切回来能直接复用,历史消息不用重新加载。只有这两种情况才调:
- 用户明确关闭或删除了这个会话,之后不会再回来;
- 页面长期开着并且会不断创建一次性会话(比如批量任务面板),需要主动回收。
等待消息队列
队列由服务端持久化:运行中发送、暂停期间发送都进入队尾,按顺序逐条执行;关掉页面不影响已确认的入队,重新打开恢复真实状态。等待消息不会提前伪装成聊天记录——它只在队列区域展示,真正被接收后才进入消息流。
const result = await chat.queueMessage("顺便看下这个报错") // 入队(运行中排队 / 暂停期间照常入队)
// result: { ok, conflict, snapshot, message }
await chat.updateQueuedMessage(item.id, item.version, "改好的正文")
await chat.reorderQueuedMessages(queue.revision, pendingIdsInNewOrder) // ordered_ids 必须是当前 pending 条目 id 的完整集合
await chat.cancelQueuedMessage(item.id, item.version)
await chat.deliverQueuedMessage(item.id, item.version) // 立即补充:交给当前运行在下一个接收点消费
await chat.resumeQueue() // 暂停后继续执行
await chat.refreshQueue() // 手动拉一份权威快照(首次连接与每次重连已自动拉取)
chat.getState().queue 是 SessionQueueSnapshot:{ session_id, revision, paused, pause_reason, items }。它只会被 revision 更新的快照替换——乱序到达的旧广播不会把新状态顶回去。items 只包含 pending(待执行)与 delivering(等待接收)。
- 冲突处理:
conflict表示乐观并发或状态校验失败,服务端快照已自动刷新到本地,此时保留用户草稿并说明原因即可;ok=false且snapshot为 null(例如鉴权失败)时不要改动本地队列。queueMessage结果未知(ack 超时)时会用同一个client_request_id重试一次,不会制造第二条。 - 可操作性判定:
canEditQueuedMessage/canCancelQueuedMessage/canDeliverQueuedMessage只对pending返回 true;文案用queuedMessageStatusLabel与queuePauseReasonLabel。 - 自己处理广播:需要绕过状态机时用
isSessionQueueSnapshot+shouldApplySnapshot+EMPTY_SESSION_QUEUE;解析自定义 ack 用parseQueueAck,返回统一的QueueOperationResult(QueueAck是 ack 的线格式;code是路由失败原因,供调用方决定是否重试)。
页面协作:让智能体和你的页面互动
页面状态与业务后端 → 智能体:
await chat.attachApp({
cli: {
command: "library",
runtime: "python",
source: new URL("./agent/library_cli.py", location.href),
},
getContext: () => ({
route: location.pathname,
selectedBookId: bookStore.getState().selectedBookId,
}),
})
getContext 在每次 send() 前执行一次,适合路由、当前选中对象等轻量页面状态;它不做响应式依赖追踪。完整业务数据由开发者自己的 CLI 查询。command 是智能体在 Bash 中直接执行的命令名,不会套在 blade 命令下。
runtime: "python" 使用沙盒系统 Python 运行单文件脚本;runtime: "executable" 接受能在 Linux 沙盒运行的可执行文件。source 可传 Blob、File 或浏览器可读取的 URL。应用 CLI 安装成功后随会话工作区保留;运行时可通过 BLADE_APP_CONTEXT_FILE 读取同一份页面上下文 JSON。
智能体 → 页面(例如智能体让地图高亮某个点):
chat.onCommand("map.highlight", (payload) => {
// payload 类型是 unknown(内容由技能作者决定),用前自行断言或校验
const { points } = payload as { points: Array<{ lng: number; lat: number }> }
map.highlight(points)
})
"map.highlight" 这个 action 字符串从哪来?——它是技能作者在工具实现的返回值里写的。技能侧(Python 工具)长这样:
def highlight_points(points):
return {
"ok": True,
"message": f"已定位 {len(points)} 个点位",
"_meta": {"bridge": {"action": "map.highlight", "payload": {"points": points}}},
}
后端会把 _meta.bridge 从对话历史里剥离(LLM 看不到它),单独送到前端。所以 action 是技能作者和页面开发者之间约定的暗号,SDK 不限制取值。在 onCommand 注册之前到达的指令会被缓冲,注册时立刻补投,不存在"注册晚了漏消息"。
页面 → 智能体(例如用户在地图上选了个点):
chat.attach("选中点位", { lng: 116.4, lat: 39.9 }) // 变成聊天输入框里的附件
chat.insertText("请分析这个区域") // 往输入框追加文字(不发送)
await chat.send("这里适合开店吗?") // 直接发消息
attach / insertText 需要有渲染层(ChatView 或你自己监听 attachRequested / insertTextRequested 事件)才有可见效果;纯脚本场景直接用 send。
语义事件(进阶)
大多数场景用不到——React 用 hook、指令用 onCommand。需要精确监听时:
chat.on("toolCall", (e) => console.log("调用工具", e.toolCall.name))
chat.on("toolResult", (e) => console.log("工具结果", e.toolCall.result))
chat.on("message", (e) => console.log("新消息", e.message))
chat.on("chatEnd", (e) => console.log("回复结束", e.status))
chat.on("error", (e) => console.error(e.message, e.code))
error 的 message 是可以直接展示的业务文案;code 是服务端给的机器可读原因(如 owner_timeout / route_broken / owner_busy),调用方据此决定要不要提示重试,不要解析 message。同一组原因也以 AgentSessionError.code 的形式抛出(send() 这类只回布尔值的入口从 error 事件拿),队列 API 则通过 QueueOperationResult.code 给出。
完整事件表见 AgentSessionEvents 类型定义(含 modeChange / workspaceChanged / artifact / notification / backgroundTask / taskListUpdated / rewind / replayMismatch 等)。toolResult.source 区分实时结果、首次连接回放和断线重连回放;on() 返回取消函数,handler 抛异常只告警、不影响会话。
分页响应是 SessionTurnsPage,其中 nextBefore: string | null 是唯一的“还有更早历史”真值。历史页面指令通过独立的 HistoricalCommand 日志恢复,不混进展示页。自定义状态容器可复用 prependOlder、replaceWindow 与 LiveRevisionState,但一般直接使用 AgentSession 即可。
iframe 嵌入形态:connectEmbedded
如果你不是嵌组件,而是把 Blade 的聊天页面整个用 iframe 嵌进自己系统,宿主页面用 connectEmbedded 拿到同构的协作 API:
import { connectEmbedded } from "@blade-hq/agent-client"
const chat = connectEmbedded({
iframe: document.querySelector<HTMLIFrameElement>("#blade")!,
allowedOrigins: ["https://blade.example.com"], // 必填:Blade 页面的来源,防伪造
})
chat.onCommand("map.highlight", (payload) => {
const { points } = payload as { points: Array<{ lng: number; lat: number }> }
map.highlight(points)
})
chat.attach("选中点位", { lng: 116.4, lat: 39.9 })
chat.send("这里适合开店吗?")
chat.dispose() // 页面卸载时
同页组件形态和 iframe 形态的协作 API 完全一致,业务代码可以原样复用。
长尾 REST:用原生 fetch
SDK 只类型化会话、登录、headless 三类接口。其余几百个后端接口一律不封装——
对照 Swagger(<后端地址>/docs)用原生 fetch 调用,把 client.token 填进
Authorization 头即可:
const resp = await fetch(`${baseUrl}/api/memories/search?q=xxx`, {
headers: { Authorization: `Bearer ${client.token}` },
})
const memories = await resp.json()
不做通用包装是有意的:包装唯一的增值是自动带令牌,代价却是要补齐
PATCH / query / FormData / AbortSignal 等完整 HTTP 语义(否则不够用),
而且 SDK 隐式携带凭据存在误把令牌发往第三方域的风险。显式暴露 client.token
让责任边界回到标准 fetch,两个问题都不存在。
安全约束:
client.token只应发往baseUrl同源的接口。不要把它附加到 第三方域名的请求上——那等于把用户的访问凭据交给别人。
只读轮询退避
new PollingBackoff(3000) 为轮询维护独立退避状态,参数为正常间隔(大于 0、不超过 60 秒)。
请求成功后调用 reset(),失败后调用 failed(error);waitMs() 返回剩余等待毫秒数,
0 表示可以请求,false 表示停止。HTTP 错误应传入保留响应头的 BladeApiError。
401/403 等确定性错误停止,网络/5xx 指数退避带抖动且最高 60 秒;Retry-After
可以要求更长等待,超出浏览器定时器范围时停止。策略自身不发送请求,调度器须传递
AbortSignal、在卸载时停止,并避免将它用于自动重放 POST 等有副作用的操作。
headless:一次性问答
// 无头模式:直接拿结构化结果,不需要自己处理流式事件
await client.headless.run("统计上月订单量", { schema })
版本
Git Release Tag 是唯一版本来源:发布 v1.2.0 时,镜像和两个 NPM 包均为
1.2.0,SDK 在 Socket.IO 握手中也声明 1.2.0。应用代码不需要处理版本。
只有一个兼容性断点:Server 1.1.1 及以上要求
@blade-hq/agent-client 1.1.1 及以上;未声明 SDK 身份或版本的旧客户端也会被拒绝。
自己渲染消息
message.content 有两种形态:纯文字时是字符串;带图片、文件的消息则是内容块数组。别自己写 typeof 判断,用这几个函数,两种形态都能处理:
import {
getTextContent,
getImageParts,
getFileParts,
groupMessagesByLoop,
contentPreview,
} from "@blade-hq/agent-client"
// 文字部分
getTextContent(message.content) // "帮我看看这张图"
// 图片部分:返回 [{ type: "image_url", image_url: { url } }, ...]
getImageParts(message.content).map((part) => part.image_url.url)
// 文件部分
getFileParts(message.content)
// 其他
groupMessagesByLoop(messages) // 按主/子智能体分组(智能体会派生子智能体干活)
contentPreview(message.content, 80) // 截断预览
错误消息在普通聊天界面展示前用 chatErrorForDisplay(message) 转成稳定的业务文案,
避免把内部路径或传输诊断暴露给用户;仅开发者界面显式传入第二个参数 true 保留原文。
一个完整的渲染示例:
function Message({ message }: { message: ChatMessage }) {
const text = getTextContent(message.content)
const images = getImageParts(message.content)
return (
<div>
{text && <p>{text}</p>}
{images.map((part) => (
<img key={part.image_url.url} src={part.image_url.url} alt="" />
))}
</div>
)
}
让用户挑一个技能来做事
自己实现技能选择器时,用 transformSlashCommand 把选中的技能翻译成智能体能执行的一段话:
import { transformSlashCommand, type SkillMentionAvailability } from "@blade-hq/agent-client"
transformSlashCommand("org/data-analysis", "分析这份季度报表")
// "请使用 org/data-analysis skill 完成任务\n分析这份季度报表"
第三个参数说明这个技能眼下能不能直接用。技能装没装、加载没加载,用户不需要知道,但智能体需要——传进来之后它会被翻译成对应的 CLI 步骤:
// Blade Hub 上已安装、但当前会话还没加载:要求先 use
transformSlashCommand(skillId, prompt, { local: false, installed: true })
// Blade Hub 上还没安装:要求先 install 再 use
transformSlashCommand(skillId, prompt, { local: false, installed: false })
不传第三个参数时按本地已有处理,和不带这个参数的老用法结果一致。
MCP App 卡片
MCP 工具可以通过 _meta.ui 在消息流里产出交互卡片(tool_ui block)。卡片的
实例、守卫、可见性、身份和终态规则全部收敛在这一份共享判定里——内置 Web 与
SDK 渲染的是同一批卡片,不要自己再写一份判断:
import {
collectInlineToolUiCards,
collectPreviewToolUiCards,
resolveToolUiCardContent,
buildToolUiCardKey,
isToolUiCard,
} from "@blade-hq/agent-client"
// 按可见性分类:inline 进消息流,preview 交给你的侧栏/面板(也随 toolPreview 事件推送)
const inlineCards = collectInlineToolUiCards(messages) // [{ key, toolCallId, card }]
const previewCards = collectPreviewToolUiCards(messages) // [{ key, toolCall, card, blocks }]
// 渲染前解析实际内容;archived 留档卡片只用留档 HTML,绝不回退 resourceUri
// (历史恢复/刷新不会因此重放一次 MCP 资源读取)
const resolved = resolveToolUiCardContent(card) // { type: "resource-html" | "resource-uri" | "resource-file", content } | null
// 文件卡片按 sourcePath、URI 按地址、无来源 HTML 按 toolCallId 去重。
// 宿主可先按会话 workspace 将 sourcePath 归一,和文件树中的身份对齐。
const key = buildToolUiCardKey(toolCallId, resolved.type, resolved.content, card.sourcePath)
resource-file 来自 blade ui --html 文件 --preview:content/sourcePath 是文件路径,不是 HTML。打开时通过带鉴权的会话文件接口读取当前文件;历史引用也读取最新文件。每次新工具调用都会发 toolPreview 通知文件有更新,宿主仍按路径复用一个页签;同次调用的重复事件不重复发送。已打开的 HTML 应保留当前页面和交互状态,提供醒目的“刷新”按钮,让用户决定何时读取最新内容。内联卡片继续使用 HTML 快照。
单个 block 判有效用 isToolUiCard(严格守卫:target/height 缺失、archived 无留档
HTML 一律判无效);isInternalStatusToolUiCard 识别内部「阶段进度」卡片(不对用户展示),
isAppDevToolUiCard 识别应用开发会话的预览卡。相关类型:ToolUiCard、
ToolUiCardContentType、InlineToolUiCardEntry、PreviewToolUiCardEntry。
常见问题
| 现象 | 原因与解法 |
|---|---|
| 401,提示未登录 | 浏览器调 client.auth.login();脚本检查 token 是否有效 |
| login() 报"弹窗被拦截" | 浏览器设置里允许该站点弹窗,或改用 PAT |
| login() 报 403 来源未允许 | 让管理员把你的页面 origin 加进后端 BLADE_SDK_AUTH_ALLOWED_ORIGINS |
| socket 一直 connecting | 检查 baseUrl 是否可达、跨域时后端是否允许你的 origin |
| onCommand 收不到指令 | 确认技能工具返回值里写了 _meta.bridge,action 拼写与注册一致 |
更多接入教程(Vue 完整示例、GIS 协作闭环、AI 助手接入指南)见 public-skills 文档站。
附录:公开类型索引
完整签名见 public-api.md(由 scripts/public-api-report.mjs 生成并在 CI 校验)。
- 客户端与登录:
BladeClientOptions、LoginOptions、LoginResult、TokenStorageMode、UploadProgress、BladeApiError、AuthResource、ProvidersResponse、UserInfo - SDK 身份:
SDK_NAME、SDK_VERSION - 声明式会话:
SessionDefinition、SolutionDefinition、SkillDefinition、SkillFile、SessionConfig、TextFile、SessionSetupError、SessionSetupStage - 会话错误:
AgentSessionError(code是服务端路由失败原因,供调用方决定是否重试) - 模型目录:
ModelsResource、ModelCatalog、ModelOption - 聊天分组资源(REST):
ChatGroupsResource、ChatGroup(含delete永久删除);client.chatGroups。旧名ChatProjectsResource、ChatProject、client.chatProjects与chat_project_id字段已随分组改名一并移除,接入方请改用chatGroups与chat_group_id - 插件目录(REST,无会话):
PluginsResource、PluginCatalogEntry(client.plugins)。首页这类还没有会话的界面用它读名称、业务名与图标 token - 账号电脑目录:
AccountComputer、AccountComputerList(client.computers.listAccount(),只读账号事实,不含会话授权) - 全站访问统计(REST):
SiteVisitsResource、SiteVisitsSummary(client.siteVisits.record()上报一次页面访问、client.siteVisits.summary()只读快照)。这两个操作走独立匿名传输:不带 bearer、不触发 401 登录刷新,只依赖匿名访客 Cookie,供内置 Web 同源使用。 - 会话资源(REST):
SessionsResource、CreateSessionRequest、ImportSessionOptions、AppCliDefinition、AppCliAttachment、AttachAppOptions、PaginatedSessionsResult、GlobalSearchResult、GlobalSearchResultItem、GlobalSearchConversationResult、GlobalSearchFileResult、SessionHistory、SessionContextStats、ResultFeedback、ResultFeedbackReason、ShareLinkResult、FileEntry、UploadFileEntry、UploadFilesOptions、SessionProfile、SessionDetail、SessionInfo、SessionStatus、SessionPortMapping、ModeId、TemplateId、PrimarySkillSnapshot、PrimarySkillParallelMode、PrimarySkillStageSpec、PrimarySkillStepSpec、SkillEditorTemplateId、SessionSearchMatch - 会话历史取数:
SessionTurnsQuery、SessionTurnsPage、SessionTurnIndex、SessionTurnIndexItem、isEntryGoneError(isEntryGoneError区分"这条发言已经不在当前分支上"与"这次没取到":前者说明fromEntryId定位目标失效,后者只说明本次请求失败) - 会话回放:
ReplayState、ReplaySpeed、ReplayPreview、ReplaySnapshot、toReplaySnapshot、DEFAULT_REPLAY_SPEED - 会话状态机:
SessionHub、SessionConnectOptions、SessionState、SendOptions、ConnectionStatus、AskUserAnswerData、AgentLoopInfo、ActiveCompactionState、createInitialSessionState、AgentSessionEventName - 静态历史会话:
AgentSession.fromTurns(sessionId, turns)构造的会话isStatic为 true——它没有目录接口,宿主拿已加载窗口推导航条目(turnNavItemsFromMessages);真实会话不要这么做,timeline 被重写后窗口里还是旧分支的发言,那些 entry id 已经点不到了 - 长会话跨页定位:
AgentSession.locateEntry(entryId)(按稳定entry_id取"从它往后"的窗口)、AgentSession.cancelPendingLocate()(作废在途的那次定位;它只中止定位自己的请求,不牵动运行结束 / 重连的权威刷新)。权威刷新(运行结束 / 重连补数 / 翻页之后的对账)不另记状态:一律按当前窗口第一条发言取窗口,那条已经不在分支上(404 / 409)时回落最新一页;sessions.getSessionTurnIndex(sessionId)给出可导航的用户发言目录(SessionTurnIndex/SessionTurnIndexItem),getSessionTurnsPage({ fromEntryId })与它共用同一份分支投影 - 会话消息队列:
SessionQueueSnapshot、QueuedMessage、QueuedMessageStatus、QueueAck、QueueOperationResult、EMPTY_SESSION_QUEUE、isSessionQueueSnapshot、shouldApplySnapshot、parseQueueAck、canEditQueuedMessage、canCancelQueuedMessage、canDeliverQueuedMessage、queuedMessageStatusLabel、queuePauseReasonLabel - 页面协作:
EmbeddedChat、EmbeddedChatOptions、CommandHandler、CommandEnvelope、InboundAction、InboundEnvelope、isCommandEnvelope、isInboundEnvelope - 流式 wire 事件(自定义渲染用):
ContentDelta、ToolCallCreated、ToolCallArgumentsDelta、ToolResultDelta、ToolResultDone、LlmResponseDone、ChatEnd、SystemError、SessionStatusEvent。它们描述 socket 上一帧帧到达的内容,只有自己接管流式拼装时才需要 - 消息与投影协议:
MessageContent、MessageContentPart、TextContentPart、ImageUrlContentPart、FileContentPart、ToolCallInfo、ToolBridgeContent、CompactionInfo、ContextProjectionData、ContextProjectionFields、ContextDisplayState、ContextGroupDisplayState、ContextAction、ContextSourceInfo、MemoryRefInfo、ArchivedFileInfo、ArchivedToolCallInfo、TurnProjection、ContentBlock、PatchEnvelope、MemoryRef、PostChatFollowup、FinalArtifact、ToolCallProjection、PendingQuestionRef、RunTiming、inferToolStatus、contextProjectionData、getContextDisplayState、getContextGroupDisplayState、groupAdjacentContextRuns、latestPostChatFollowup、buildMessageContent、normalizeMessageContent、isHiddenInternalMessage、transformSlashCommand、SkillMentionAvailability、extractTextAttachments、ParsedTextAttachment、ParsedTextContext - MCP Apps 留档与卡片判定:
McpAppData、isMcpAppData、McpAppContextState、McpAppContextUpdate、ToolUiCard、ToolUiCardContentType、InlineToolUiCardEntry、PreviewToolUiCardEntry、isToolUiCard、resolveToolUiCardContent、buildToolUiCardKey、isInternalStatusToolUiCard、isAppDevToolUiCard、collectInlineToolUiCards、collectPreviewToolUiCards(McpAppContextState/McpAppContextUpdate配合sessions.getMcpAppContext/sessions.updateMcpAppContext:App 保存的状态在下一个新运行才进入模型上下文) - Solution / 任务协议:
Solution、SolutionAppField、SolutionAppState、SolutionAppUiConfig、SolutionRef、PublishedSolutionRef、ExistingSolutionRef、PreparedSolution、PreparedSolutionAsset、LayoutType、BizRole、TaskStatus、BackgroundTask、BackgroundTaskStopResult - Headless:
HeadlessResource、RunOptions、RunResult、RunTrace - 低层通道(apps/web 等高级集成):
createSocket、CreateSocketOptions、TypedSocket、AsrAudioPayload、AuthBusyReconnect、authBusyRetryDelayMs、ClientProjectionBuilder、RawEvent、acknowledgeTurnEvents、hasChatRunEvent、acceptedPostChatFollowupCompletesLatestRun、reconcileOptimisticUserTurns、reconcileHistoricalUserTurns
MCP App 留档
McpAppArchive 是 client.sessions.getMcpApp(sessionId, sourceId) 经鉴权返回的归档,包含保存的 HTML 与最新留档输入/结果。callMcpApp 在会话 sandbox 内调用该卡片的归档能力;onMcpAppChanged 订阅结果变化,返回取消订阅函数。
最新调用失败或完成状态未知时,归档同时提供 previousToolInput / previousToolResult,用于先恢复最后成功的界面再展示错误。读取这些留档不会重新执行工具。
会话列表和详情的 SessionInfo.initiator 表示首次发起来源:ui 为界面,sdk 为 SDK;旧会话缺少可靠记录时为 unknown,尚未发起会话时为 null 或未提供。它不等同于后台运行标记 is_headless,后续继续会话不会改变来源。
后台唤醒输入可用 isBackgroundWakeMessage(message) 识别,它读取消息的结构化通知 block。自定义 UI 应为这类输入显示说明卡片;isHiddenInternalMessage 仍将其视为内部输入,避免作为用户发言或回溯导航点。
@blade-hq/agent-client/internal(exports 子路径 ./internal)供 Blade 自有渲染层共享服务器时钟。普通接入方使用会话快照中的 chat_run 读取本轮整体区间,duration_ms 仍表示模型请求耗时。