# @blade-hq/agent-client

Blade Agent 的框架无关客户端。浏览器和 Node.js 都能用；用 Vue、Svelte 或自建 UI 的团队直接用这个包，React 团队一般用上层的 `@blade-hq/agent-react`。

它做四件事：

1. **实时会话**（`AgentSession`）：把 Socket.IO 协议、历史加载、流式合流、断线重连全部封装掉，你只面对"状态快照 + 动作 + 事件"。
2. **登录**：`client.auth.login()` 弹窗授权，用户点一下"许可授权"就拿到访问令牌，不用手工复制粘贴。
3. **REST**：类型化会话和模型目录（`client.sessions.*`、`client.models.list()`）。其他长尾接口对照 Swagger 用原生 `fetch` + `client.token` 调用。
4. **部署端点**：从同源 `config.json` 读取其他 Blade 服务的公开地址，不根据主机名和固定端口猜测拓扑。

```bash
npm install @blade-hq/agent-client
```

默认装到的是当前长期支持版（LTS），厂内离线交付按它开发。要跟两周一发的公网版本，改用 `@next`。

最稳妥的做法是先读目标 Server 的 `GET /api/version`，按返回的版本号在 `package.json` 里钉死——NPM 的 `latest` 不一定和目标环境跑的版本一致。

## 快速开始

```html
<button id="login">登录 Blade</button>
```

```ts
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 设置页创建）：

```ts
const client = new BladeClient({
  baseUrl: "https://blade.example.com",
  token: process.env.BLADE_TOKEN,
})
```

## 部署端点

Blade 平台前端需要跳转其他服务时，读取当前 origin 的公开配置：

```ts
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

### 构造

```ts
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 连不上。

### 登录相关

```ts
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 交给后端，由后端换：

```ts
// ExchangeCodeParams -> ExchangeCodeResult
const { access_token } = await client.auth.exchangeCode({ code, state, clientOrigin })
```

`clientOrigin` 要和发起授权时用的 origin 一致；服务端调用会自动补 `Origin` 头。

## 会话：client.sessions

### 实时对话（推荐入口）

```ts
// 连接已有会话
const chat = await client.sessions.connect("会话id")

// 或：创建新会话并直接连接
const newChat = await client.sessions.create()
const configured = await client.sessions.create({ intent: "数据分析", model: "gpt-4o" })
```

两者都返回 [`AgentSession`](#agentsession)。

### 声明完整会话

自定义 Solution、Skill 和模型无需手写上传步骤：

```ts
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` 从哪来？** 三种，按需选：
> 1. **不用管它** —— 首次接入最省事：`client.sessions.create()` 直接建一个新会话，用不着 ID。React 的 `<ChatView />` 不传 `sessionId` 也一样。
> 2. **自己建、自己存**：`const { session_id } = await client.sessions.createSessionWithRequest({ intent: "季度报表分析" })`。把返回的 `session_id`（后端是蛇形命名）存进你的数据库或 URL，用户下次进来传给 `connect(sessionId)`（前端参数是驼峰），就能接着上次的对话。
> 3. **列出用户已有的**：`await client.sessions.listSessions()`，让用户自己挑。

### 会话管理（REST）

```ts
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）。

```ts
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"`。

```ts
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。

### 会话回放（演示 / 彩排）

拿一个已经聊完的会话当素材，重现当时的回复和工具调用，**完全不调用模型**。
用来做演示和彩排：离线可跑、不花钱、不会临场翻车。

**它不是把现有会话切成回放模式，而是派生一个新会话**，源会话原样不动：

```ts
// 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 步连上的**回放会话**）：

```ts
// 源会话当初第一句问的是"帮我查下当前目录"
await session.send("帮我查下当前目录")   // 对得上：按倍速重现当时的回复和工具调用
await session.send("今天天气怎么样")     // 对不上：抛 replayMismatch 等你决定（见下）
```

**回放中的状态和动作**都挂在会话上，和 `send()` 同级：

```ts
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` 即可，
不用自己拉状态：

```ts
const state = shallowRef(session.getState())   // session = connect(replayId) 的返回值
session.subscribe(() => (state.value = session.getState()))
// 模板里 state.value.replay?.isReplay / state.value.replay?.speed
```

连接会话时会一并拉好初始回放状态，所以第一帧就知道自己在不在回放。

**输入冲突**：用户说的话和录制内容对不上时，服务端会推 `replayMismatch`，必须在事件里给出决定：

```ts
session.on("replayMismatch", ({ expectedMessage, actualMessage, respond }) => {
  respond("keep_replay")      // 按录制内容继续
  // respond("continue_replay")  // 从这里开始真的运行
})
```

`replayMismatch` **只交给最先注册的处理器**；一个都没注册时默认 `continue_replay`
（静默转为真实运行）。React 应用可直接用 `@blade-hq/agent-react` 的 `useReplay()`。

### 工作区文件

```ts
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` 提供最常用的短写法：

```ts
await chat.listDir("输出")
await chat.countFiles()                         // 递归统计，不含目录
await chat.downloadFile("输出/简历.md", "简历.md") // 浏览器下载
```

对符合评价条件的最新整体结果，可读取或更新当前用户的反馈：

```ts
const feedback = await client.sessions.listResultFeedback(id)
await client.sessions.putResultFeedback(id, assistantEntryId, {
  helpful: false,
  reason: "incomplete",
})
```

`ResultFeedbackReason` 是固定枚举；Server 会校验结果资格、写权限，并自行推导 Chat / 软件工厂场景和项目 ID。

### 模型目录

```ts
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` 那个服务上，做模型选择器时按它过滤。

```ts
// 自己调模型服务
const body = { model: defaultServiceModel, messages, stream: true }
```

`baseUrl` 是**服务端视角**的地址。平台常配成 `http://127.0.0.1:30000/v1`——从自己后端转发
没问题，直接交给浏览器就指向用户自己的机器了。默认形态是后端透传（密钥本来也不该进浏览器），
浏览器直连只适合这个地址对浏览器同样可达的场景。

### 远程电脑：client.computers

一个会话除了自己的运行时，还可以操作用户接入的其他电脑（`blade daemon connect` 接进来的机器）。
**默认一台都不能用**，要先为这次会话启动它。

```ts
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` 表示这次会话本身就跑在这台电脑上。这种电脑恒为可用，也不能停用——
那是会话自己的工作目录所在。

做电脑选择器时用这几个纯函数，别自己重算状态：

```ts
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

一个会话的实时状态机。**状态归属实例**：同一页面建多个会话互不干扰。

### 状态

```ts
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 接入文档）。

### 动作

```ts
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 缓存着，用户切走再切回来能直接复用，历史消息不用重新加载。只有这两种情况才调：
> 1. 用户明确关闭或删除了这个会话，之后不会再回来；
> 2. 页面长期开着并且会不断创建一次性会话（比如批量任务面板），需要主动回收。

### 等待消息队列

队列由服务端持久化：运行中发送、暂停期间发送都进入队尾，按顺序逐条执行；关掉页面不影响已确认的入队，重新打开恢复真实状态。等待消息**不会**提前伪装成聊天记录——它只在队列区域展示，真正被接收后才进入消息流。

```ts
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` 是路由失败原因，供调用方决定是否重试）。

### 页面协作：让智能体和你的页面互动

**页面状态与业务后端 → 智能体**：

```ts
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。

**智能体 → 页面**（例如智能体让地图高亮某个点）：

```ts
chat.onCommand("map.highlight", (payload) => {
  // payload 类型是 unknown（内容由技能作者决定），用前自行断言或校验
  const { points } = payload as { points: Array<{ lng: number; lat: number }> }
  map.highlight(points)
})
```

`"map.highlight"` 这个 action 字符串从哪来？——它是**技能作者**在工具实现的返回值里写的。技能侧（Python 工具）长这样：

```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` 注册之前到达的指令会被缓冲，注册时立刻补投，不存在"注册晚了漏消息"。

**页面 → 智能体**（例如用户在地图上选了个点）：

```ts
chat.attach("选中点位", { lng: 116.4, lat: 39.9 }) // 变成聊天输入框里的附件
chat.insertText("请分析这个区域")                   // 往输入框追加文字（不发送）
await chat.send("这里适合开店吗？")                 // 直接发消息
```

`attach` / `insertText` 需要有渲染层（ChatView 或你自己监听 `attachRequested` / `insertTextRequested` 事件）才有可见效果；纯脚本场景直接用 `send`。

### 语义事件（进阶）

大多数场景用不到——React 用 hook、指令用 `onCommand`。需要精确监听时：

```ts
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：

```ts
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` 头即可：

```ts
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：一次性问答

```ts
// 无头模式：直接拿结构化结果，不需要自己处理流式事件
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` 判断，用这几个函数，两种形态都能处理：

```ts
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` 保留原文。

一个完整的渲染示例：

```tsx
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` 把选中的技能翻译成智能体能执行的一段话：

```ts
import { transformSlashCommand, type SkillMentionAvailability } from "@blade-hq/agent-client"

transformSlashCommand("org/data-analysis", "分析这份季度报表")
// "请使用 org/data-analysis skill 完成任务\n分析这份季度报表"
```

第三个参数说明这个技能眼下能不能直接用。技能装没装、加载没加载，用户不需要知道，但智能体需要——传进来之后它会被翻译成对应的 CLI 步骤：

```ts
// 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 渲染的是同一批卡片，不要自己再写一份判断：

```ts
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 文档站](https://github.com/blade-hq/public-skills)。

## 附录：公开类型索引

完整签名见 [public-api.md](./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` 仍表示模型请求耗时。
