Qoni SDK
本页说明 Qoni 统一 SDK 的安装、全部方法入口、任务与交互数据结构,以及业务函数如何展示请求并提交用户响应。
当前可用版本
Qoni 当前公开的统一 SDK 是 Node.js / TypeScript 包 @qoniai/qoni,源代码位于 QoniAI/qoni-sdk-node。
| 项目 | 当前状态 |
|---|---|
| npm 包 | @qoniai/qoni |
| GitHub 仓库 | QoniAI/qoni-sdk-node |
| 当前公开版本 | 0.9.0 |
| 本页样例版本 | 0.9.0,与 npm 发布版本一致;下载包已附带 |
| 运行环境 | Node.js 18 或更高版本,服务端运行时需要提供 fetch |
| 模块格式 | ESM、CommonJS 和 TypeScript 类型声明 |
| License | MIT |
服务端凭证
Qoni accessKey 和 secretKey 只能保存在可信服务端。不要把它们打包进浏览器、移动应用、公开 CLI 配置或不受信任的 Agent 运行时。
正式包与待落地设计
本页的稳定调用示例使用 npm 0.9.0。Personal Agent、Enterprise Agent 和托管登录页保留 Owner 的未来 Quickstart 设计;其中真实 Agent 身份委托字段 agentId、scopes: ['*']、Agent 创建/绑定和 fill_form 不能照搬到正式包。0.9.0 没有 handle.submit();agent(兼容别名 agentKey)只是审计标签,不能证明 Agent 身份或用户绑定。SDK 会在本地拒绝 *,请显式列出所需 scope。run({ memory }) 的自动记忆设计尚未落地,2026-10-08 灰度验收返回 422;现有接入需分别调用 GUMem 原子方法并核验结果。Track 的后端契约和未发布修复候选(Unreleased,目标 0.10.0,破坏性变更)见 Track。
SDK 覆盖范围
统一 SDK 从 Qoni 提供以下能力:
| Namespace | 用途 |
|---|---|
genauth | 委托 Agent、获取当前用户信息,以及管理 GenAuth 用户 |
gumem | 创建 Session、写入消息、召回 Memory、上传资源和记录 Action |
doAnything | 启动或重新连接通用 Web Agent 任务 |
webSearch | 执行公开网页搜索并读取结果 |
deepResearch | 执行长时间研究任务并下载产物 |
track | 0.9.0 暴露旧监控入口,与当前 /track/tracks 后端不兼容;见 Track |
运行时产品调用使用短期委托令牌。SDK 会负责运行时发现和下游产品令牌兑换,应用代码不应自行拼接内部 /api/v3/eak/* 路径或内部 claim。
安装
下载样例和本页示例使用已发布到 npm 的 0.9.0,包含 genauth.delegateAgent() 兼容入口和按类型收窄的交互。已有应用可用下面的命令安装。下载样例 后,在 examples/qoni 执行 npm ci,会安装下载包附带的同版本 SDK。
npm install @qoniai/qoni@0.9.0也可以使用 pnpm 或 Yarn:
pnpm add @qoniai/qoni@0.9.0
# 或
yarn add @qoniai/qoni@0.9.0初始化客户端
获取 AccessKey
- 打开 Qoni Console 的工作空间列表,选择要调用 SDK 的工作空间。
- 在左侧导航点击 AccessKey,再点击 创建 AccessKey,按调用需求配置权限。
- 创建成功后,在 保存 AccessKey 弹窗点击 复制凭据,将 AccessKey ID 和 AccessKey Secret 分别保存为服务端环境变量
QONI_ACCESS_KEY和QONI_SECRET_KEY。
AccessKey Secret 只在创建时显示一次。已有 AccessKey 可以在列表中查看 ID,但无法重新查看 Secret;如果没有保存 Secret,需要创建新的 AccessKey。
import { Qoni, QoniScopes } from "@qoniai/qoni";
// 在服务端初始化客户端;AK/SK 用于访问当前工作空间的服务。
const qoni = new Qoni({
accessKey: process.env.QONI_ACCESS_KEY!,
secretKey: process.env.QONI_SECRET_KEY!,
});不传 host 时,SDK 默认使用托管网关 https://dashboard.qoni.ai。私有部署或本地部署可以传入 host,它应指向 Qoni Console / SDK gateway,而不是单独的 GenAuth、Web Agent 或 GUMem 地址。
Qoni 支持以下选项:
| 选项 | 必填 | 说明 |
|---|---|---|
accessKey | 是 | Qoni Console 中创建的 access key |
secretKey | 是 | Qoni Console 中创建的 secret key |
host | 否 | 私有部署的 Qoni 网关地址,覆盖默认的服务运行时发现 |
fetch | 否 | 自定义 fetch 实现,用于代理或测试环境 |
timeoutMs | 否 | 单次请求超时时间,默认 30000;事件流等待不受此限制 |
sseMaxRetries | 否 | 事件流断线自动重连次数上限,默认 5,设为 0 关闭 |
获取委托令牌
Web Agent 和 GUMem 产品代表终端用户执行操作。静默委托必须传入与当前 Qoni 凭证绑定的 GenAuth 用户 ID:
// 为当前用户的研究任务申请委托;用户 ID 来自绑定的 GenAuth 用户池。
const { token } = await qoni.genauth.delegateAgent({
userId: process.env.QONI_USER_ID!,
agentKey: "research-assistant", // 本次委托的 Agent 审计标签
scopes: [
QoniScopes.WEB_SEARCH_READ, // 读取搜索状态和结果
QoniScopes.WEB_SEARCH_MANAGE, // 发起或取消搜索
QoniScopes.GUMEM_MEMORY_READ, // 召回用户上下文
QoniScopes.GUMEM_MEMORY_WRITE, // 写入已确认的演示偏好
],
expiresIn: "15m", // SDK 转换成服务端要求的 900 秒
});对于站点登录、浏览器接管、长期监控或敏感产物等高风险操作,优先使用 mode: "interactive",让用户在 Qoni Console 中确认授权。交互式授权在服务端通过 completeDelegateToken({ grantId, code, state }) 完成,委托令牌不会暴露给浏览器。
细粒度权限通过 scopes 声明,格式为 <namespace>.<resource>:<verb>,例如 webagent.web_search:read。Web Agent 每个产品只有 read 和 manage 两个动词;也可以用 products: ["webSearch"] 简写一次申请整个产品。站点登录另有 webagent.site_login:request(打开受控登录浏览器)和 webagent.site_login:confirm(把登录态保存到浏览器 Profile)两个 scope,对应 QoniScopes.SITE_LOGIN_REQUEST / QoniScopes.SITE_LOGIN_CONFIRM;它们不在任何 products 简写里,需要在 scopes 中显式申请,并由 AccessKey 的权限策略允许。SDK 导出 QoniScopes 常量与 QoniScopeBundles 预置组合(如 GUMEM_SESSION_RECALL),避免手拼 scope 字符串;格式错误的 scope 会在本地抛出 QoniValidationError。SDK 常量与服务端可用边界见可申请的 Scopes。
静默委托响应不包含 grantedScopes。需要记录实际授权范围时,调用 introspection 的权威结果:
// 泛型声明本页读取的字段;SDK 默认把 introspection 结果类型视为 unknown。
const { data: tokenInfo } = await qoni.genauth.introspectDelegationToken<{
active: boolean;
scope?: string[];
}>({ token });
if (!tokenInfo.active) throw new Error("Delegation token is inactive");
const effectiveScopes = tokenInfo.scope ?? []; // 供服务端记录授权范围和审计信息交互式委托响应中的 state 是服务端生成 grantState 的兼容别名,不会回显调用方传入的 state。应校验 authorizationUrl 查询参数中的 grant_id 等于响应的 grantId,再在服务端按 grantState 保存 grantId 和业务 state。用户同意后,GenAuth 回调 redirectUri 只带 code、业务 state 和 grant_state(等于 grantState),不带 grantId:按 grant_state 找回记录、核对业务 state,再调用 completeDelegateToken({ grantId, code, state }),其中 grantId 用保存的值,state 传回调的 grant_state。
可申请的 Scopes
delegateToken 和 genauth.delegateAgent 能申请哪些 scope,要过两道关:
- AccessKey 的权限策略:委托只能申请当前 AccessKey 允许的 scope,超出范围会返回
eak.delegation.scope_not_allowed。在 Console 创建或编辑 AccessKey 时勾选的权限策略,决定了这份允许列表。 - 产品接口的校验:GUMem 和 Web Agent 的接口按 scope 放行请求。下表的“放行的操作”一列说明每个 scope 在接口上实际能做什么,并标出用到它的 SDK 方法。
下面列出 SDK 中的 scope 常量及其契约用途;常量存在不代表部署已登记或实现。用户 Profile、支付和 Agent 邮箱能力仍是待落地设计,早期版本留下的旧动词见表后说明。SDK 常量都在 QoniScopes 下,例如 QoniScopes.GUMEM_MEMORY_READ。
GUMem
| Scope | SDK 常量 | 放行的操作 | Console 权限策略 |
|---|---|---|---|
gumem.memory:read | GUMEM_MEMORY_READ | 读取 Session 上下文和消息流;同时满足 gumem.action:read、gumem.profile:read 的要求。SDK 方法:gumem.recall | GUMem 记忆 |
gumem.memory:write | GUMEM_MEMORY_WRITE | 写入 Memory;同时满足 gumem.session:create、gumem.message:write、gumem.resource:write、gumem.action:write 的要求。SDK 方法:gumem.createSession、gumem.addMessages、gumem.uploadResource | GUMem 记忆 |
gumem.session:create | GUMEM_SESSION_CREATE | 创建 Session | GUMem 会话 |
gumem.message:write | GUMEM_MESSAGE_WRITE | 向 Session 写入消息 | GUMem 会话 |
gumem.resource:write | GUMEM_RESOURCE_WRITE | 上传资源文件 | GUMem 管理 |
gumem.action:write | GUMEM_ACTION_WRITE | 记录用户 Action。SDK 方法:gumem.actions.record | GUMem 行为 |
gumem.action:read | GUMEM_ACTION_READ | 查询用户 Action 及其进度、事实、摘要和主题,订阅 Action 流。SDK 方法:gumem.actions.recall、gumem.actions.stream | GUMem 行为 |
gumem.profile:read | GUMEM_PROFILE_READ | 读取和召回用户画像 | GUMem 管理 |
gumem.admin:manage | GUMEM_ADMIN_MANAGE | 调用 GUMem 控制台的项目管理接口 | GUMem 管理 |
gumem.memory:delete | GUMEM_MEMORY_DELETE | 预留,目前没有接口校验它,申请后不增加权限 | GUMem 记忆 |
gumem.search:run | GUMEM_SEARCH_RUN | 预留,目前没有接口校验它,申请后不增加权限 | GUMem 搜索 |
Web Agent
| Scope | SDK 常量 | 放行的操作 | Console 权限策略 |
|---|---|---|---|
webagent.do_anything:read | DO_ANYTHING_READ | 读取 DoAnything 任务的状态、事件、产物和录屏 | WebAgent 任意任务 |
webagent.do_anything:manage | DO_ANYTHING_MANAGE | 启动和取消任务、提交交互响应、发送消息;同时包含 read | WebAgent 任意任务 |
webagent.web_search:read | WEB_SEARCH_READ | 读取搜索状态和结果 | WebAgent 网页搜索 |
webagent.web_search:manage | WEB_SEARCH_MANAGE | 发起和取消搜索;同时包含 read | WebAgent 网页搜索 |
webagent.deep_research:read | DEEP_RESEARCH_READ | 读取研究任务的状态、事件和报告产物 | WebAgent 深度研究 |
webagent.deep_research:manage | DEEP_RESEARCH_MANAGE | 发起和取消研究、提交交互响应;同时包含 read | WebAgent 深度研究 |
webagent.track:read | TRACK_READ | 查看监控任务和执行记录 | WebAgent 追踪 |
webagent.track:manage | TRACK_MANAGE | 创建、修改、暂停、恢复、立即执行和删除监控;同时包含 read | WebAgent 追踪 |
Web Agent 的 SDK 方法里,读取类操作(状态、事件、结果、产物)用 read,其余改变执行状态的操作用 manage。每个产品的 read + manage 也可以用 products 简写一次申请:doAnything、webSearch、deepResearch、track。
用户 Profile 与 Agent 邮箱(待落地设计)
0.9.0 导出了这组常量,但 2026-10-08 灰度验收中五项申请均被 eak.delegation.scope_not_allowed 拒绝。下表保留设计用途,不代表正式包和部署已完成该能力。
| Scope | SDK 常量 | 放行的操作 | Console 权限策略 |
|---|---|---|---|
user.profile:read | USER_PROFILE_READ | 读取用户 Profile 中的姓名、电话和收货地址,供 DoAnything 代填结账信息 | GenAuth 用户资料 |
user.profile:write | USER_PROFILE_WRITE | 把用户在信息补全(fill_form)交互中填写、并标了 profileField 的字段写入 Profile。设计方法:handle.submit(0.9.0 不存在) | GenAuth 用户资料 |
user.payment:use | USER_PAYMENT_USE | 用户在确认(confirmation)交互中允许支付后,由 Web Agent 运行时从 Profile 取卡,在受控浏览器中代填;不向 App 或模型返回卡号 | GenAuth 支付 |
agent.mail:read | AGENT_MAIL_READ | 读取 Agent 专属邮箱中的邮件,例如验证码和订单确认 | GenAuth Agent 邮箱 |
agent.mail:send | AGENT_MAIL_SEND | 以 Agent 专属邮箱发送邮件 | GenAuth Agent 邮箱 |
这组 scope 用于面向个人用户的场景,完整用法见 Personal Agent。未来设计中的 * 表示申请 AccessKey 策略允许的全部 scope;0.9.0 本地拒绝 *,当前应逐项申请。静默模式没有授权页;交互式授权才有用户确认步骤。
申请时注意:
- 宽 scope 包含窄 scope:Web Agent 的
manage包含同一产品的read;GUMem 的gumem.memory:write、gumem.memory:read覆盖表中注明的细分 scope。按最小权限原则,只申请任务真正需要的 scope。 - SDK 按方法换取产品令牌:SDK 每次调用 GUMem 或 Web Agent 前,只用该方法需要的 scope 换取产品令牌,委托中缺少这个 scope 时会返回
eak.token_exchange.scope_not_delegated。所以用 SDK 时,按表中标注的“SDK 方法”申请。例如只委托gumem.session:create时,gumem.createSession仍会失败,需要委托gumem.memory:write。 - 站点登录:
webagent.site_login:request(QoniScopes.SITE_LOGIN_REQUEST)和webagent.site_login:confirm(QoniScopes.SITE_LOGIN_CONFIRM)供openLogin()打开受控登录浏览器、确认后保存登录态,不在任何products简写里。是否可申请取决于部署登记和 AccessKey 策略。2026-10-08 灰度验收可签发这两个 scope,但兑换 Web Agent 产品令牌返回 400;签发成功不等于站点登录全链路可用。 - 旧动词:Console 的 WebAgent 权限策略里还会列出以
:run、:stop、:control结尾的 scope。这些是早期版本的动词,Web Agent 接口只校验read和manage,申请它们不会增加权限,新代码不要使用。
SDK 方法索引
本页对应下载包内 @qoniai/qoni 0.9.0 的类型声明。普通请求返回 QoniResponse<T>:data 是业务结果,meta 包含请求、链路和审计标识;兼容入口 genauth.delegateAgent() 直接返回业务数据;任务创建返回可持续读取的句柄。下面列出统一 SDK 的方法,交互业务函数另在后文说明。
客户端与 GenAuth
| 方法 | 输入与用途 | 返回 |
|---|---|---|
qoni.genauth.delegateAgent(input) | 与 delegateToken 使用同一授权接口;支持 userId、agentKey、显式 scopes、expiresIn: '15m' | 静默模式直接返回 grant.token/grantId/auditId;交互模式直接返回授权请求 |
qoni.delegateToken(input) | mode、agent、products/scopes、expiresIn、可选 idempotencyKey;静默模式传 user,交互模式传 redirectUri/state | 委托结果或授权请求,位于 data |
qoni.delegateAgent(input) | 顶层旧别名,调用 delegateToken;不同于 genauth.delegateAgent | 保留 QoniResponse,token 位于 response.data.token |
qoni.completeDelegateToken(input) | 使用服务端保存的 grantId(回调不带它)、回调的 code 和 grant_state(以 state 传入)完成兑换 | data.token、grantId、auditId 等 |
qoni.currentUser({ accessToken }) | 查询已登录用户,等同于 genauth.userInfo | QoniResponse<T> |
qoni.resolveAnyBoundUser() | 从绑定用户池取一个用户,仅用于演示;生产应用识别真实当前用户 | 用户 ID 字符串 |
qoni.genauth.userInfo({ accessToken }) | 用用户的 GenAuth Access Token 查询身份 | QoniResponse<T> |
qoni.genauth.discovery() / jwks() | 读取 OIDC 发现配置 / 验签公钥 | QoniResponse<T> |
qoni.genauth.introspectDelegationToken({ token }) | 查询委托是否有效及实际 scope | QoniResponse<T> |
qoni.genauth.users.list(input?) | page、limit、options,查询绑定用户池中的用户 | QoniResponse<T> |
qoni.genauth.users.get({ userId }) / getBatch({ userIds }) | 查询一个 / 多个用户 | QoniResponse<T> |
qoni.genauth.users.create(input) / createBatch(input) | 单个用户字段 / 批量 users 或 list;字段遵循 GenAuth 用户 API | QoniResponse<T> |
qoni.genauth.users.update({ userId, ...fields }) | 更新指定用户的字段 | QoniResponse<T> |
qoni.genauth.users.deleteBatch({ userIds }) | 删除指定用户;由业务确认删除范围 | QoniResponse<T> |
users.* 默认由 AK/SK 换取绑定用户池的管理权限,也接受显式 adminToken/userPoolId 覆盖。用户字段和大部分 GenAuth 返回值是泛型数据,应用需按实际字段声明 T 并验证结果。
委托方法的 scopes 能填哪些值、每个 scope 放行哪些操作,见可申请的 Scopes。
GUMem
这些方法使用委托 token;Session、Memory 和 Action 的权限分别以实际服务契约为准。
| 方法 | 主要输入 | 用途与返回 |
|---|---|---|
qoni.gumem.createSession(input) | token;可选 userId、sessionId、title、metadata | 创建 Session,返回 QoniResponse<T> |
qoni.gumem.addMessages(input) | token、sessionId、messages;可选 userId、sync | 写入用户已确认的消息,返回 QoniResponse<T> |
qoni.gumem.recall(input) | token;可选 sessionId、query、details、recallConfig、metadataFilters | 召回上下文,返回 QoniResponse<T> |
qoni.gumem.uploadResource(input) | token、file(Blob/File);可选用户、Session、文件名及格式 | 上传资源,返回 QoniResponse<T> |
qoni.gumem.actions.record(input) | token 和 Action 业务字段 | 记录 Action,返回 QoniResponse<T> |
qoni.gumem.actions.recall(input) | token 和查询条件 | 查询 Action,返回 QoniResponse<T> |
qoni.gumem.actions.stream(input) | token 和查询条件 | 读取 Action stream 接口结果,返回 QoniResponse<T>;不是 AsyncIterable |
跨会话召回受 Memory 自身的 scope 约束。被提炼为 scope=user 的用户级偏好可以进入跨 Session 用户上下文;被归为 scope=session 的会话事实(如某次任务的项目名、预算)默认不会进入另一 Session 的用户上下文。消息写入、提炼完成、scope 归类和最终召回需要分别核验,不能要求每条会话事实都自动变成长期用户记忆。
Web Agent 产品入口
| 方法 | 主要输入 | 返回与边界 |
|---|---|---|
qoni.doAnything.run(input) | token、任务 prompt;可选 capture、limits、session、profileId、browserProxy、keepAlive、allowedActions、skills | RunHandle<DoAnythingEvent> |
qoni.doAnything.attach(runId, options) | 原任务 ID、token;可选 session/capture | 重连原任务,不创建新任务 |
qoni.doAnything.artifacts({ token, runId }) | 委托令牌和任务 ID;可选 signal | Artifact[],无需重放事件 |
qoni.webSearch.run(input) | token、prompt(字符串或字符串数组);可选 maxResultsPerQuery、siteWhitelist、siteBlacklist、capture | RunHandle<WebSearchEvent>;不支持 session/limits |
qoni.webSearch.attach(runId, options) | 原任务 ID、token;可选 capture | 重连搜索任务 |
qoni.deepResearch.run(input) | token、prompt;可选 depth、outputFormat、targetAudience、domainWhitelist、domainBlacklist、session、limits、capture | RunHandle<DeepResearchEvent> |
qoni.deepResearch.attach(runId, options) | 原任务 ID、token;可选 capture | 重连研究任务 |
qoni.track.create(input) | token、监控意图 prompt,以及服务端支持的监控定义字段 | 0.9.0 的旧路由不可用于当前后端;修复候选返回 MonitorHandle |
qoni.track.attach(monitorId, { token }) | 原监控 ID 和委托令牌 | 重连原监控 |
capture 支持 screenshots/videoFrames;limits 当前声明 maxDurationMinutes;session 使用 { sessionId }。DoAnything 的 browserProxy 支持 proxyId、mode(off/all/scoped)和 region;depth 为 light/standard/deep。SDK 只声明或透传字段,不保证部署启用了对应功能。DoAnything 不接受 model、outputSchema 等平台未开放的参数。
任务、监控和产物句柄
| 成员 | 用法 |
|---|---|
run.id / run.sessionRef | 保存任务 ID / 可选的 Session 引用,供查询或后续任务复用 |
run.status() | 读取 RunStatus:id/status/sessionId/output/raw |
run.events(options?) | AsyncIterable;可传 lastEventId、signal、onWireEvent、onReconnect |
run.wait(options?) | 等到终态,返回 RunResult;可传 timeoutMs/signal、onEvent/onWireEvent/onReconnect、onScreenshot/onInteraction |
run.interactionHandle(request) | 把事件中的请求数据转成当前任务的 InteractionHandle |
run.cancel(reason?) | 请求取消任务,返回状态;不是停止客户端等待的同义词 |
monitor.id / monitor.get() | 保存监控 ID / 读取当前定义 |
monitor.pause() / resume() | 暂停 / 恢复监控 |
monitor.refine(patch) | 修复候选仅修改 title/schedule;不能修改原始监控意图或旧 DSL |
monitor.runNow() | 修复候选返回 trackId/sessionId/runId,仅表示已受理;first_look 或当前检查在途时返回 409 task_in_progress |
monitor.events({ lastEventId?, signal? }) | 当前后端没有 Track SSE;修复候选迭代时本地抛 QoniUnsupportedError,零 HTTP |
monitor.interactionHandle(request) | 当前后端没有 Track 问询/干预接口;修复候选本地抛 QoniUnsupportedError |
monitor.runs({ limit?, offset? }) / run(runId) | 修复候选读取最近 50 次 checks,limit/offset 仅在该窗口本地分页;精确 run ID 不在窗口内则失败 |
monitor.delete() | 修复候选软删监控并停止后续调度,不取消当前正在执行的任务 |
artifact.content() | 下载文件字节(Uint8Array) |
artifact.refreshDownloadUrl?.() | 实现提供此方法时,更新短期下载链接 |
RunResult 包含 runId/status/output/artifacts/terminalReason/isTaskSuccessful/raw;业务 output 需要应用校验。Artifact 的元数据是 id/name/mime/sizeBytes/createdAt/downloadUrl/expiresIn,除 id 外均可选。wait() 超时或中止客户端读取不会取消服务端任务;继续读取可用 attach(),取消执行用 cancel()。
监控行描述 Unreleased、目标 0.10.0 的破坏性修复候选,不能按 npm 0.9.x 的可用功能理解。Track 创建立即启动 first_look;checks 成功状态是 done,不映射成 RunResult.status 的 succeeded。完整契约见 Track。
语义事件的 event.type 取值包括:通用的 progress/message/interaction/screenshot/browserLiveUrlChanged/done,搜索的 resultsReady,研究的 phase/sectionReady,以及旧版监控事件类型常量 monitorCreated/triggered/checkCompleted(当前 Track 无事件流,不能订阅)。常量键使用 PascalCase,例如 QoniEventTypes.Done 的值是 done。产品句柄的事件类型各有范围,不是每个产品都产生所有事件。RunImage 包含 bytes/mime 和可选 pageUrl/step;原始事件在 event.raw,完整原始事件订阅使用 onWireEvent。
低层接口与兼容入口
优先使用上面的类型化方法。以下是 SDK 确实暴露的低层接口;api 的请求体和返回结构跟随后端,未纳入稳定的语义契约。
| 入口 | 方法 |
|---|---|
qoni.doAnything.api | createSession、createRun、getRun、events、intervene、cancel、readArtifacts、listArtifacts、artifactDownloadUrl、readRecording |
qoni.webSearch.api | run、get、events、cancel |
qoni.deepResearch.api | run、get、events、followUp、cancel、feedback、listArtifacts、getArtifact |
qoni.track.api | createMonitor、getMonitor、runNow、events、intervene、listRuns、getRun、updateMonitor、deleteMonitor |
qoni.unstableRequest(input) / request(input) | 网关请求:method/path 和可选 token/query/body/headers;request 为同一实现的入口 |
| 签名工具导出 | buildStringToSign(method, path, headers, params)、buildSignature(secretKey, stringToSign)、buildAuthorization(accessKey, secretKey, stringToSign) |
doAnything.api.readArtifacts 是旧结构化快照接口,当前后端没有对应实现,0.9.0 调用会请求旧路由并返回 404。修复候选将它标为 deprecated 并本地抛 QoniUnsupportedError,零 HTTP;文件使用 doAnything.artifacts({ token, runId }) / artifact.content(),浏览器截图使用 capture: { screenshots: true } 与 onScreenshot。两者都不是旧结构化快照的替代实现。
qoni.qoni.delegateToken/completeDelegateToken 也可调用;推荐统一使用客户端顶层方法。delegateAgent/completeDelegateAgent、旧凭据与 token 字段是兼容入口,迁移说明见后文。QoniScopes 是权限常量,QoniProductScopes 是产品对应的 scope 列表,QoniScopeBundles 是预置组合;导出的输入、结果、事件及错误类型对应上述 API。高级部署还可设置 genauthHost/gumemHost/webAgentHost 覆盖单项服务发现;普通集成使用 host。
交互请求与业务函数
任务需要用户出手时,Agent 会暂停并发起一次交互,等应用把用户的决定交回来。交互按「需要用户做什么」分类,和具体业务无关:应用按类型各画一次界面,就能处理任何任务里的交互。这一节说明每种交互的数据、可用的 SDK 方法,以及一个应用怎样把它们接到自己的界面上。下载样例中 Quickstart 脚本(quickstart.ts)用到的业务函数都由应用实现,真实代码位于下载包的 interaction-demo.ts;它们不是 @qoniai/qoni 的导出。
| 类型 | 用户要做的事 | 可用的 SDK 方法 |
|---|---|---|
site_login | 在受控浏览器里亲自登录某个网站 | openLogin()、confirmSignedIn() |
take_control | 登录之外需要亲手操作浏览器,例如验证码 | connectControl()、refreshControl()、releaseControl() |
ask_user | 回答一个问题,答案结构由 answerType 和 options 描述 | answer()、skip() |
confirmation | 允许或拒绝一个操作 | confirm()、reject() |
wait | 不需要做决定:系统在等待限流或外部条件 | retry() |
处理交互时记住三点。未来完整设计(含正式包未实现的 fill_form)见 Personal Agent:用 DoAnything 发起任务并处理交互。
- 同一个交互会多次回调:创建、状态变化和断线回放都会再次调用
onInteraction。只处理status为pending的,并按request.id去重;界面按request.id更新同一张卡片。 - 交互一个一个出现:任务在交互处暂停,当前这个处理完,Agent 才继续,下一个交互才会出现。
- 方法以服务端提供的动作为准:
handle.can(kind)返回false的方法不要调用,调用会抛出QoniValidationError。
句柄与请求数据
onInteraction 收到 SDK 的 InteractionHandle,这里命名为 handle。request = handle.interaction 是请求数据。handle.id/type/status/actions 是部分字段的快捷读取方式;title/prompt/payload 从 request 读取。响应方法已经绑定当前任务和动作接口,不需要应用自己拼接 URL。回调还提供第二个参数 event(RunEvent),可用 event.runId 关联任务;Quickstart 只使用第一个参数。
request 字段 | 应用如何使用 |
|---|---|
id | 稳定请求 ID;合并同一请求的更新和回放 |
type | 选择登录、问答、审批等业务展示 |
status | pending 待处理、active 进行中、resolved 已解决、expired 已过期、canceled 已取消 |
title / prompt? | 卡片标题 / 可选详细说明 |
createdAt / resolvedAt? / expiresAt? | 请求创建、解决及过期时间 |
evidence? | 可选的 { artifactId } 证据引用 |
payload | 当前类型的业务数据,见下表 |
actions | 当前允许的动作;每项有 kind/label/method/endpoint 和可选 inputSchema |
下面是一条问询请求的演示数据,仅展示主要字段:
{
"id": "demo-question-1", // 同一请求的状态变化保留这个 ID
"type": "ask_user", // Agent 需要用户回答一个问题
"status": "pending", // 当前等待用户回应
"title": "请确认处理范围",
"payload": {
"question": "只处理售后问题,还是所有客户问题?",
"answerType": "single_choice", // 决定界面画成文本框、数字、是否、单选还是多选
"options": [
{ "value": "support", "label": "只处理售后问题" },
{ "value": "all", "label": "所有客户问题" }
]
},
"actions": [
{ "kind": "answer", "label": "提交回答" } // 省略 method、endpoint 等字段
]
}五类请求的数据与响应
type | payload | 应用怎么做 |
|---|---|---|
site_login | sites: [{ siteId, displayName, loginUrl }],可选 monitorId | 用户在受控界面完成登录后,handle.confirmSignedIn() 请 Agent 复查;登录界面由 openLogin() 打开时,它先保存这次登录 |
ask_user | question、answerType,可选 options: [{ value, label }] | 提交实际回答:handle.answer(value),值的类型与 answerType 对应;允许跳过时使用 skip() |
confirmation | summary | 同意计划:handle.confirm();拒绝:handle.reject() |
take_control | liveUrl,可选 surface/reason | 用户打开已交出的浏览器;完成后 handle.releaseControl() |
wait | waitKind(rate_limit/external/sleep),可选 until/retryable | 展示等待状态;只有服务端提供 retry 且用户选择后才调用 handle.retry() |
answerType 取值为 text、number、boolean、single_choice、multiple_choice,answer() 分别接受字符串、数字、布尔、一个选项的 value、选项 value 的数组;类型不符时,SDK 在发送前抛出 QoniValidationError。线上协议里这个类型仍叫 clarification,SDK 0.9.0 起统一呈现为 ask_user,带候选项的问题是 single_choice。Interaction 是按 type 分布的联合类型,switch (request.type) 后每个分支的 payload 会自动收窄。同一个请求在整个生命周期里 type 不变;后端用空壳结束交互时,SDK 保留原来的 payload,只更新 status。retryable: true 是数据提示;按钮能否使用仍以 actions 为准。
示例应用:设计五个业务函数
演示使用 DemoRequestCard 表达界面所需的数据和按钮回调,日志只输出摘要;应用接入自己的卡片或对话框渲染。它属于应用代码;demoScreen 是单任务、本地进程内的演示存储。五个业务函数同步返回,不在 SDK 回调里等待输入,因为 wait() 会等待回调返回后才继续消费事件。
// 应用的交互演示,不是 Qoni SDK API;只在用户触发按钮时提交响应。
import { QoniValidationError, type ActionKind, type AskUserAnswer, type Interaction, type InteractionHandle,
type InteractionPayloadByType, type InteractionType, type OpenLoginResult } from '@qoniai/qoni'
type DemoPayload = Interaction['payload'] | undefined
type Replies = Partial<Record<ActionKind, (input?: AskUserAnswer) => Promise<void>>>export interface DemoRequestCard {
id: string // 同一请求的稳定 ID
type: string // 保留原业务类型
status: string // 服务端最新状态
title: string
prompt?: string | null
content: Record<string, unknown> // 应用展示的数据
disabled: boolean // 已结束或已提交时禁用
buttons: {
kind: ActionKind
label: string // 使用服务端按钮文字
onUserClick(input?: AskUserAnswer): Promise<void> // 仅由用户触发
}[]
}
// 本地单任务的演示界面;服务端应用应按用户和任务隔离存储。
export const demoScreen = new Map<string, DemoRequestCard>()1. 请用户登录站点
展示 sites,让用户在受控登录界面完成登录后点击“已完成登录”。confirmSignedIn() 请 Agent 再检查,不直接证明登录成功。受控登录界面可以接入部署提供的流程,也可以用 openLogin({ siteId }) 由应用自己打开(见下文)。
export function requestUserLogin(payload: DemoPayload, handle: InteractionHandle): void {
// sites 每项包含 siteId、displayName、loginUrl;受控登录入口由部署提供。
renderRequest(handle, 'Sign in to the requested sites', () => ({
sites: payloadFor(payload, handle, 'site_login').sites,
}), {
// 用户完成登录后点击;SDK 保存 openLogin() 打开的这次登录,再请 Agent 复查。
confirm_signed_in: () => handle.confirmSignedIn(),
})
// 服务端提供 open_login 时登记开页入口,用户点击后才由 openSiteLogin() 调用;这里不自动开页。
// 请求结束或不再可操作时,作废在途和当前的登录会话。同 id 的非终态事件是同一请求的更新,不作废。
const card = demoScreen.get(handle.id)
if (card && !card.disabled && handle.can('open_login')) {
loginOpeners.set(handle.id, (siteId, profileId) => handle.openLogin({ siteId, profileId }))
} else {
loginOpeners.delete(handle.id)
invalidateLogins(handle.id)
}
}**由应用打开登录浏览器。**服务端提供 open_login 动作时,handle.openLogin({ siteId }) 为用户选中的站点开启受控登录浏览器,返回 liveUrl 等字段。liveUrl 只展示给当前用户,不写日志。用户登录完成后调用 confirmSignedIn():SDK 0.9.0 起,它先把这次登录保存到浏览器 Profile(之后的任务直接复用),再请 Agent 复查并继续。登录只需要这两步:
| 情况 | confirmSignedIn() 的行为 |
|---|---|
| 用户已登录 | 保存登录,Agent 复查后继续任务 |
| 用户其实没有登录成功 | 仍请 Agent 复查;Agent 会再发起一次 site_login,应用按新的请求 ID 展示新卡片 |
服务端已不持有这次会话(HTTP 409 no_pending_login,例如已自动保存) | 同上,照常请 Agent 复查 |
服务端暂时没能保存(probeResult: "release_failed",会话仍保留) | 抛出 code 为 site_login.save_failed 的可重试 QoniError,不通知 Agent;再调用一次会重新保存 |
| 其他保存错误(例如缺少 scope、网络错误) | 抛出 QoniError,不通知 Agent |
openLogin() 和保存登录分别需要 QoniScopes.SITE_LOGIN_REQUEST 和 QoniScopes.SITE_LOGIN_CONFIRM:委托时在 scopes 中显式申请,AccessKey 的权限策略也要允许;缺少任一项时,SDK 换取令牌会被拒绝。无论调用几次,同一个登录会话只保存一次、只请 Agent 复查一次。同一个 RunHandle 发出的句柄共享登录会话,所以在后续回调拿到的句柄上调用 confirmSignedIn() 也会保存这次登录;重新 attach() 得到的 RunHandle 不共享。openLogin() 返回值上的 confirm() 已弃用,它和 confirmSignedIn() 共用同一次保存。
下面两个应用函数供界面的“打开登录”和“登录完成”按钮调用;“登录完成”经同一张卡片的“已完成登录”按钮调用 confirmSignedIn(),沿用卡片的提交锁。调用时请求已结束,或登录会话不是这个请求最近一次打开的,finishSiteLogin() 直接拒绝,不保存登录、不通知 Agent;用户又打开了一个登录窗口、还没打开完时也会拒绝,等新窗口打开后在里面完成。
// 应用可为用户选中的站点打开受控登录浏览器;用户登录后,confirmSignedIn() 保存这次登录并请 Agent 复查。
const loginOpeners = new Map<string, (siteId: string, profileId?: string) => Promise<OpenLoginResult>>()
// 每个请求的当前登录会话:较新的开页成功后才替换它;开页失败不作废已有会话,同一结果只完成一次。
const currentLogins = new Map<string, { attempt: number; login: OpenLoginResult }>()
// 开页序号:连续点击时,晚到的旧结果不会覆盖较新的成功结果。
const loginAttempts = new Map<string, number>()
// 请求结束或锁定时递增:在途的开页全部作废。
const loginEpochs = new Map<string, number>()
// 正在打开的登录窗口数:还有窗口在打开时不能完成登录,否则 SDK 可能已经换成新窗口。
const loginsOpening = new Map<string, number>()
function invalidateLogins(requestId: string): void {
loginEpochs.set(requestId, (loginEpochs.get(requestId) ?? 0) + 1)
currentLogins.delete(requestId)
}
function signedInButton(requestId: string) {
// 只有仍可操作的当前卡片才能完成登录;终态或已提交的卡片没有这个按钮。
const card = demoScreen.get(requestId)
return card && !card.disabled ? card.buttons.find(button => button.kind === 'confirm_signed_in') : undefined
}
export async function openSiteLogin(requestId: string, siteId: string, profileId?: string): Promise<OpenLoginResult> {
// siteId 必须来自当前卡片的 sites;返回的 liveUrl 只展示给当前用户,不写日志。
const card = demoScreen.get(requestId)
const open = loginOpeners.get(requestId)
const sites = (card?.content.sites ?? []) as { siteId: string }[]
if (!card || card.disabled || !open) throw new Error('This request cannot open a login browser')
if (!sites.some(site => site.siteId === siteId)) throw new Error('Choose a site listed in this request')
const epoch = loginEpochs.get(requestId) ?? 0
const attempt = (loginAttempts.get(requestId) ?? 0) + 1
loginAttempts.set(requestId, attempt)
loginsOpening.set(requestId, (loginsOpening.get(requestId) ?? 0) + 1)
let login: OpenLoginResult
try {
login = await open(siteId, profileId)
} finally {
loginsOpening.set(requestId, (loginsOpening.get(requestId) ?? 1) - 1)
}
// 开页期间请求已结束:这个会话不成为当前会话。
if ((loginEpochs.get(requestId) ?? 0) !== epoch || !signedInButton(requestId)) {
throw new Error('This sign-in request is no longer actionable')
}
// 较新的开页已经成功:晚到的旧结果不覆盖它。较新的开页失败时,旧结果照常成为当前会话。
const current = currentLogins.get(requestId)
if (current && current.attempt > attempt) throw new Error('A newer login browser was opened')
currentLogins.set(requestId, { attempt, login })
return login
}
export async function finishSiteLogin(requestId: string, login: OpenLoginResult): Promise<void> {
// 用户在 liveUrl 中登录完成后调用;只接受这个请求最近一次打开、仍可操作的登录会话。
const button = signedInButton(requestId)
if (currentLogins.get(requestId)?.login !== login || !button) throw new Error('This sign-in request is no longer actionable')
// 用户又打开了一个登录窗口、还没打开完:等它打开后,在最新的窗口里完成。
if ((loginsOpening.get(requestId) ?? 0) > 0) throw new Error('Another login browser is still opening for this request')
currentLogins.delete(requestId)
// 经“已完成登录”按钮调用 confirmSignedIn():SDK 先保存这次登录,再请 Agent 复查;仍未登录时 Agent 会发起新的 site_login。
await button.onUserClick()
}2. 问询用户
把 question 放到对话框上,按 answerType 画成文本框、数字输入、是否开关、单选或多选;候选项显示 label、提交 value。用户的回答传给 onUserClick(answer),再由 handle.answer(answer) 交回 Agent。示例不会代填“只处理售后问题”等固定回答。
export function askForTaskDetails(payload: DemoPayload, handle: InteractionHandle): void {
// question 例如“只处理售后问题,还是所有客户问题?”;answerType 决定画文本框、数字、是否、单选还是多选。
renderRequest(handle, 'Clarify the task scope or requirements', () => {
const question = payloadFor(payload, handle, 'ask_user')
// 候选项是 { value, label }:界面显示 label,提交 value
return { ...question, choices: (question.options ?? []).map(option => option.value) }
}, {
// 用户提交后调用;值的类型与 answerType 对应,示例不会生成或代填回答。
answer: value => handle.answer(value!),
skip: () => handle.skip(), // 仅在服务端允许跳过时显示
})
}3. 请用户确认操作计划
展示 summary。只有用户点击同意或拒绝时,才分别调用 confirm() 或 reject();显示卡片不会自动批准。
export function requestUserApproval(payload: DemoPayload, handle: InteractionHandle): void {
// summary 是待确认的操作摘要,例如本次客户问题的处理计划。
renderRequest(handle, 'Review and approve or reject the plan', () => ({
summary: payloadFor(payload, handle, 'confirmation').summary,
}), {
confirm: () => handle.confirm(), // 用户同意
reject: () => handle.reject(), // 用户拒绝
})
}4. 请用户接管浏览器
界面展示 liveUrl 和接管原因,让用户完成手动步骤后点击完成。演示把浏览器入口保留在卡片数据中,不写入日志;它可能携带当前浏览器的访问能力。
export function offerBrowserControl(payload: DemoPayload, handle: InteractionHandle): void {
// liveUrl 是已交给用户控制的浏览器入口;reason 说明需要手动完成什么。
renderRequest(handle, 'Complete a manual step in the browser', () => ({
...payloadFor(payload, handle, 'take_control'),
}), {
// 用户完成操作后点击,把控制权交回 Agent。浏览器入口不写入演示日志。
release_control: () => handle.releaseControl(),
})
}5. 展示等待状态
说明网站限流、等待外部条件或暂时休眠,以及可选的预计恢复时间。服务端没有提供 retry 时,卡片只有状态说明;提供时,由用户决定是否重试。
export function showWaitingStatus(payload: DemoPayload, handle: InteractionHandle): void {
// waitKind 是 rate_limit、external 或 sleep;until 是可选的预计恢复时间。
renderRequest(handle, 'Show why the task is waiting', () => ({
...payloadFor(payload, handle, 'wait'),
}), {
// 只有 actions 提供 retry 才有按钮,且必须由用户选择;不自动重试。
retry: () => handle.retry(),
})
}把用户操作连接到 SDK
演示卡片的 buttons[].onUserClick 是应用回调,内部调用真实 SDK 方法。例如,用户在问答卡片提交文字后,把该文字传给 answer 按钮的回调;在审批卡片选择同意或拒绝后,调用相应按钮。展示函数本身从不执行这些回调。
例如,下面的 submitTaskAnswer(requestId, answer) 可以连接应用的问答表单:requestId 来自正在展示的请求,answer 来自用户的输入控件。它会取最新卡片的按钮,最终调用真实的 handle.answer(answer)。这也是应用函数,不是 SDK 方法。
export async function submitTaskAnswer(requestId: string, answer: AskUserAnswer): Promise<void> {
// 应用的表单提交函数:取得当前卡片,不能使用旧事件里保存的按钮。
const button = demoScreen.get(requestId)?.buttons.find(button => button.kind === 'answer')
if (!button) throw new Error('This request is not accepting an answer')
// answer 来自用户的输入控件;按钮回调会校验输入,再调用 handle.answer(answer)。
return button.onUserClick(answer)
}Web 应用应将回调与 SDK 句柄留在可信服务端:前端只展示需要的数据,并把请求 ID、动作和用户输入提交到应用自己的业务接口。服务端验证当前用户及任务归属,取得最新请求后再响应;不要把 AK/SK 或 SDK 客户端交给浏览器。
共享实现负责四件事:
- 按
id替换当前卡片,以最新actions重建按钮;旧按钮失效。 - 对
pending/active展示内容;终态只更新状态并清除按钮,保留原内容。仅收到历史终态时不新建卡片;终态不可逆,后续旧的非终态回放被忽略。 - 用户点击时先校验回答和可用动作,再锁定该请求,避免双击或回放重复提交。
- 提交出错时保留错误,不自动重试或解锁旧按钮。唯一的例外是 SDK 在发送前的本地校验失败(
QoniValidationError且没有 HTTPstatus,例如数字题收到了文字):请求没有发出,演示恢复卡片,让用户改正后再提交;带status的错误来自服务端,请求已经发出,卡片保持锁定。这份本地演示对同一请求只提交一次,不会在后续非终态事件中自动重新开放按钮;演示不把失败当作成功。
**一次响应演示的边界:**SDK 的交互数据没有请求修订号,也没有单独查询某个交互当前状态的类型化方法;新状态通过交互事件送达。演示保留提交锁,无法恢复同一 id 再次要求登录或重试的按钮。这种重新提供的请求或提交结果不明确的情况,应在 Console/部署界面继续处理;生产应用需基于服务端事件和自己的幂等策略设计恢复流程。这个限制属于演示,不属于 confirmSignedIn() 等 SDK 方法。
查看共享的卡片更新、类型收窄和响应实现
// 一次响应的本地演示:提交后的同一 id 不再开放;SDK 本身没有这个限制。
const submitted = new Set<string>()
const finished = new Set<string>()
function closeFinishedRequest(handle: InteractionHandle): boolean {
// 终态不可逆;旧 pending/active 回放不能复活请求,包括终态先到的情况。
if (finished.has(handle.id)) return true
if (handle.status === 'pending' || handle.status === 'active') return false
finished.add(handle.id)
const card = demoScreen.get(handle.id)
// 终态只更新状态,保留原来的业务内容(run 句柄已把终态折叠回原卡片;直接构造的句柄仍可能是外壳)。
if (card) { card.status = handle.status; card.disabled = true; card.buttons = [] }
console.log({ id: handle.id, status: handle.status, actions: [] })
return true
}
function renderRequest(handle: InteractionHandle, scenario: string,
content: () => Record<string, unknown>, replies: Replies): void {
// 先处理终态,再读取 payload;展示函数立即返回,让 SDK 继续接收状态更新。
if (closeFinishedRequest(handle)) return
const card: DemoRequestCard = {
id: handle.id, type: handle.type, status: handle.status,
title: handle.interaction.title, prompt: handle.interaction.prompt,
content: content(), disabled: submitted.has(handle.id), buttons: [],
}
if (!card.disabled) {
card.buttons = handle.actions.filter(action => replies[action.kind]).map(action => ({
kind: action.kind, label: action.label,
onUserClick: input => submitUserChoice(card, handle, action.kind, input, replies[action.kind]!),
}))
}
// 同一个 id 的新事件替换旧卡片;按钮重新按最新 actions 构建。
demoScreen.set(handle.id, card)
console.log({ scenario, id: card.id, type: card.type, status: card.status,
title: card.title, actions: card.buttons.map(({ kind, label }) => ({ kind, label })) })
}
async function submitUserChoice(card: DemoRequestCard, handle: InteractionHandle,
kind: ActionKind, input: AskUserAnswer | undefined, send: (input?: AskUserAnswer) => Promise<void>): Promise<void> {
// 旧界面、已结束请求和双击不能重复提交;SDK 方法也会校验动作是否由服务端提供。
if (demoScreen.get(card.id) !== card || card.disabled || submitted.has(card.id) || !handle.can(kind)) {
throw new Error('This request card is no longer actionable')
}
// 同一请求还有登录窗口在打开:先别完成登录,等新窗口打开后在里面完成(卡片按钮和 finishSiteLogin() 都走这里)。
if (kind === 'confirm_signed_in' && (loginsOpening.get(card.id) ?? 0) > 0) {
throw new Error('Another login browser is still opening for this request')
}
if (kind === 'answer') {
// 选择题只接受候选项的 value(空字符串、空的多选也合法);没有候选项时,文本不能为空。
const choices = card.content.choices as string[]
if (choices.length) {
const picked = Array.isArray(input) ? input : [input]
if (picked.some(value => typeof value !== 'string' || !choices.includes(value))) throw new Error('Choose an offered answer')
} else if (input === undefined || (typeof input === 'string' && !input.trim())) {
throw new Error('Enter an answer first')
}
}
submitted.add(card.id)
card.disabled = true
try {
await send(input)
} catch (error) {
// SDK 在发送前本地校验失败(QoniValidationError 且没有 HTTP status,例如数字题收到了文字):
// 没有发出请求,恢复卡片让用户改正。带 status 的错误来自服务端,请求已经发出,保持锁定。
// 其他错误可能是 POST 结果尚不明确;不自动重试,也不重新启用旧按钮。错误交给应用处理。
if (error instanceof QoniValidationError && error.status === undefined) { submitted.delete(card.id); card.disabled = false }
throw error
}
}
function payloadFor<T extends InteractionType>(payload: DemoPayload, handle: InteractionHandle,
type: T): InteractionPayloadByType[T] {
if (handle.type !== type || !payload || payload !== handle.interaction.payload) {
throw new Error(`Expected a ${type} request with its SDK payload`)
}
// switch (request.type) 能直接收窄 payload;这里的函数按类型分发,所以校验类型及数据来源后统一取出。
return payload as InteractionPayloadByType[T]
}五种抽象类型之外的新类型(例如当前后端的 secure_input)没有类型化的 payload。示例只提示交给 Console 或部署的专用界面;不能把凭证请求当作问询,也不能用 answer() 收集密码。
export function showUnsupportedRequest(_payload: DemoPayload, handle: InteractionHandle): void {
// 五种抽象类型之外的新类型(例如当前后端的 secure_input)不读取、不代答,也不收集密码。
if (closeFinishedRequest(handle)) return
const previous = demoScreen.get(handle.id)
if (previous) { previous.status = handle.status; previous.disabled = true; previous.buttons = [] }
console.log({ id: handle.id, type: handle.type, status: handle.status,
nextStep: 'Use Qoni Console or the deployment-specific interaction UI' })
}InteractionHandle 完整方法表
handle.can(kind) 检查动作是否由当前请求提供;未提供时调用对应方法会抛出 QoniValidationError。SDK 不替应用完成用户确认、重复响应控制或用户身份验证。
| 方法 | 对应 actions[].kind | 用途与边界 |
|---|---|---|
can(kind) | — | 检查当前是否提供此动作 |
answer(value) | answer | 提交问询的回答,值的类型与 answerType 对应,发送前在本地校验;不能提交密码或新类型的凭证引用 |
skip() | skip | 用户选择跳过当前请求 |
confirm() / reject() | confirm / reject | 提交用户批准 / 拒绝决定 |
openLogin({ siteId?, profileId? }) | open_login | 为 payload.sites 中的站点打开受控登录浏览器,返回 OpenLoginResult(profileId/siteId/sessionId/liveUrl/raw);只列出一个站点时可省略 siteId,传 profileId 重新登录已有 Profile。需要 SITE_LOGIN_REQUEST |
confirmSignedIn() | confirm_signed_in | 先保存 openLogin() 打开的这次登录(需要 SITE_LOGIN_CONFIRM),再通知 Agent 重新检查登录态;没有调用 openLogin() 时只通知 Agent |
login.confirm({ displayLabel? }) | —(openLogin() 返回值的方法) | 已弃用:confirmSignedIn() 会自动保存。手动保存登录态并结束本次登录会话,返回 ConfirmLoginResult(profileId/active/state/probeResult/raw)。需要 SITE_LOGIN_CONFIRM |
connectControl() | connect_control | 接管浏览器,返回 ControlSession(liveUrl/expiresAt?/status?/raw):liveUrl 是交给用户的签名控制地址,只展示给当前用户。仅在动作实际提供时调用;当前后端的接管流程通常不提供此动作 |
refreshControl() | refresh_control | 控制地址过期时换一个新的,同样返回 ControlSession;后续事件也会带来新的浏览器入口 |
releaseControl() | release_control | 用户完成后交还浏览器控制权 |
retry() | retry | 用户选择后请求重试 |
switchProfile() | switch_profile | SDK 导出此方法;当前后端不提供或实现对应动作,演示不连接它 |
ActionKinds、InteractionTypes、InteractionStatuses 是 SDK 导出的常量,可用于类型提示。接管及受控登录还需要部署允许的相应权限。按 id 合并更新;已结束请求可能改变 type 或不再保留原 payload,不要用空终态覆盖先前的业务内容。
下载完整样例,其中包含 interaction-demo.ts、测试和运行依赖。将 interaction-demo.ts 放在 Quickstart 脚本同一目录;示例使用 tsx 执行,因此 import 使用 .js 后缀。页面代码与应用函数经过安装包的严格类型检查;方法索引的名称和低层 API 集合自动校验,表格中的参数说明依据公开类型声明人工核对。卡片点击和开页流程的测试使用真实 InteractionHandle、模拟业务输入和模拟的登录服务响应;这些测试不表示私有站点登录已完成验收。
完整可运行样例
下面的程序实际调用 GenAuth 委托与 introspection、Web Search、GUMem 写入与召回,以及 DoAnything。它还使用 attach() 重新读取同一个搜索任务,而不是重新发起一次搜索。默认是公开网页与独立演示用户,示例偏好不会写进业务用户。
下载完整样例,或在文档仓库中运行:
cd examples/qoni
npm ci
npm run sdk可下载脚本需要 Node.js 20.11 或更高版本;SDK 本身支持 Node.js 18+。
设置服务端 QONI_ACCESS_KEY、QONI_SECRET_KEY 后执行。演示创建隔离用户,因此需要当前凭据具备 GenAuth 用户管理权限。输入、解析、文件保存和交互处理的实际实现都在样例包中;npm run typecheck 会针对已安装的 SDK 类型声明检查全部源文件。
import { QoniScopes } from '@qoniai/qoni'
import { cleanupDemoUser, cliOptions, createContext, delegate, isolateDemoUser, object, parseJsonOutput, readWithRetry, save, saveArtifacts, searchHits, settled, settleRun, withCleanup } from './runtime.js'
// 从服务端凭据创建 Qoni 客户端;context 是样例的应用上下文。
const context = await createContext('sdk', { ...cliOptions(), skipUserResolution: true })
await withCleanup(context, async register => {
// Memory 写入使用独立演示用户,结束时删除该用户。
register('isolated demonstration user', () => cleanupDemoUser(context))
await isolateDemoUser(context)
const qoni = context.qoni
// 一次委托覆盖网页搜索、网页任务和演示用户的 Memory。
const grant = await delegate(context, 'sdk-demonstration', ['webSearch','doAnything'],
[QoniScopes.GUMEM_MEMORY_READ, QoniScopes.GUMEM_MEMORY_WRITE, QoniScopes.GUMEM_MESSAGE_WRITE])
const token = grant.token
// 查询实际授权范围,供审计使用;readWithRetry 是样例的只读重试封装。
const { data: tokenInfo } = await readWithRetry(context, 'delegation introspection', () => qoni.genauth.introspectDelegationToken({ token }))
if (object(tokenInfo).active !== true) throw new Error('The delegation token is not active')
// 找到 Firefox 功能的官方来源,每个搜索词最多返回 3 条结果。
const search = await qoni.webSearch.run({
token,
prompt: 'Mozilla Firefox official features',
maxResultsPerQuery: 3,
})
save(context, 'search-ref.json', { runId: search.id, auditId: grant.auditId })
register('Web Search run', () => search.cancel('SDK demonstration cleanup'))
// wait() 读取最终结果;searchHits() 校验真实的 results[] 搜索条目。
const result = await settleRun(context, search)
save(context, 'search-result.json', result)
settled(result)
const hits = searchHits(result.output)
// 用原 run ID 重新连接同一个任务,适用于刷新界面或重启服务后查询。
const attached = await qoni.webSearch.attach(search.id, { token })
if ((await readWithRetry(context, 'attached run status', () => attached.status())).status !== 'succeeded') throw new Error('The reattached run has not succeeded')
// 为演示用户创建会话;后续消息和召回使用同一个 sessionId。
const sessionId = `sdk-demonstration-${Date.now()}`
await qoni.gumem.createSession({
token,
userId: context.userId,
sessionId,
title: 'SDK demonstration',
})
// 只写已确认的演示偏好;sync: true 请求同步处理这条消息。
await qoni.gumem.addMessages({ token, userId: context.userId, sessionId, sync: true,
messages: [{ role: 'user', content: 'For this demonstration, my confirmed preference is concise explanations.' }] })
// 召回本次任务相关的偏好,并校验新写入的 concise 是否能够被找回。
const { data: memory } = await readWithRetry(context, 'GUMem recall', () => qoni.gumem.recall({ token, sessionId,
query: 'What confirmed explanation preference should be used?', details: true }))
save(context, 'memory.json', { sessionId, memory })
if (!JSON.stringify(memory).includes('concise')) throw new Error('Recall did not include the demo preference just written')
// 把真实搜索结果交给 Agent 阅读,产出一条带来源的简短产品事实。
const run = await qoni.doAnything.run({ token,
prompt: `Read the actual official sources below and provide one concise, supported Firefox feature with its URL. Return only a JSON object with feature and sourceUrl. Sources: ${JSON.stringify(hits)}`,
capture: { screenshots: true } })
save(context, 'run-ref.json', { runId: run.id, session: run.sessionRef })
register('DoAnything run', () => run.cancel('SDK demonstration cleanup'))
// 取得任务终态;产物下载由 saveArtifacts() 调用 Artifact.content() 完成。
const summary = await settleRun(context, run)
save(context, 'result.json', summary)
settled(summary)
await saveArtifacts(context, summary)
// feature 和 sourceUrl 是本应用约定的字段,由样例负责解析和校验。
const feature = object(parseJsonOutput(summary.output))
if (typeof feature.feature !== 'string' || !feature.feature || typeof feature.sourceUrl !== 'string' || !/^https:\/\//.test(feature.sourceUrl)) {
throw new Error('The SDK example did not return a product fact with a source URL')
}
// 关联搜索、网页任务与委托审计 ID,方便回溯这条事实的来源。
const report = { passed: true, dataset: 'public-demo', runId: run.id, searchRunId: search.id,
status: summary.status, hits, memorySessionId: sessionId, output: feature,
auditId: grant.auditId, scopes: object(tokenInfo).scope }
save(context, 'report.json', report)
console.log(JSON.stringify(report, null, 2))
})输出与任务句柄
Web Search 的 RunResult.output 是含 results[] 的搜索结果对象,条目有 title、url、snippet 等字段;它不是任意提示词的 JSON 生成器。需要分析、改写或生成草稿时,把已取得的结果作为 DoAnything 的任务上下文。
DoAnything 的当前业务输出通常包装为 { answer: string }。应用解析 answer 中的 JSON,并校验约定字段和来源;完整原始结果保存在 result.json,解析失败会报错,不会输出虚构的成功结果。
run.wait() 返回 RunResult,包含 runId、status、output、artifacts、terminalReason、isTaskSuccessful 和 raw。产物是 Artifact 对象:await artifact.content() 下载真实字节;downloadUrl 是短期签名 URL,不应长期保存或公开记录。
事件流和回调的完整写法见下载样例中的 quickstart.ts:在 examples/qoni 中执行 npm run quickstart,加 -- --events 使用事件流。onScreenshot 收到图像和从 0 开始的截图序号;onInteraction 收到 SDK 句柄。请求字段、业务函数和用户响应见交互请求与业务函数。
本地验证与记录
npm run verify默认命令运行 Quickstart、SDK 程序和 23 个网页场景,共 25 项;加 --include-track 后纳入两个旧 Track 场景,共 27 项。它们使用官方 0.9.0,不能在当前 /track/tracks 后端通过,不是修复候选的示例。程序创建独立测试用户,并输出逐项验证矩阵。请求状态、run/monitor ID、授权范围和业务输出都来自真实服务;缺少数据、未成功终态、交互未完成或输出不符合约定时,命令返回非零退出码。演示不是实际私有业务账号的验收。
验证器还会真实请求输出里引用的每个来源 URL(只请求公开 HTTPS 地址,逐跳校验重定向)。来源不存在或不可达时判为失败;站点反爬拒绝(401、403、429)记为"未能验证",写进矩阵,不当作来源不存在。introspection、任务状态与 Memory 召回这类幂等读取,遇到 SDK 标记为 retryable 的瞬时错误时最多再试两次;创建任务、委托、写入 Memory 和响应交互不重试。SDK 0.7.0 起,wait() 在任务结束后读取详情和产物列表时,会对同类错误自动再试两次;仍失败时,样例改用不带回调的 wait() 重新读取同一任务的终态,不重跑任务,也不重复触发回调。
来源请求只检查 HTTP 可达性,不检查返回的页面正文;HTTP 200 的挑战页也可能通过,业务事实仍需复核。
矩阵中的 passed-unverified 表示 SDK 调用与输出结构通过,但至少一条来源未能独立复验;汇总会单独列出这些场景,不表示事实已经核实。
本页的内联代码块另由文档仓库的页面代码验证器逐字检查:把页面代码按顺序拼成程序,严格类型检查后用真实 AccessKey 执行。
# 在文档仓库根目录执行(先在 examples/qoni 中 npm ci)
npm run qoni:verify-doc-snippets -- --live默认 --live 将本页的 SDK 程序运行到验证终点。
错误处理
所有 SDK 错误继承 QoniError,携带 code、status、requestId、traceId、auditId 和 retryable 标志。普通 HTTP 请求不会自动重试(0.7.0 起,wait() 在任务结束后读取详情和产物列表时最多再试两次),retryable 为 true 的错误由应用决定重试策略;events() 的 SSE 事件流会按照 sseMaxRetries 自动重连。
| 错误类 | 触发场景 |
|---|---|
QoniValidationError | 本地输入校验失败,例如 scope 格式错误或静默委托缺少 user |
QoniUnsupportedError | 仅 Unreleased 修复候选新增:旧 Track 能力或 snapshot 接口无后端实现,本地抛 unsupported.operation;0.9.0 原包没有此错误类 |
QoniAuthError | accessKey / secretKey 签名被拒绝 |
QoniPermissionDeniedError | 委托令牌缺少所需 scope(HTTP 403) |
QoniTokenExpiredError | 委托令牌已过期 |
QoniDelegationRequiredError | 产品调用缺少 token,或令牌未被网关接受 |
QoniRateLimitError | 触发限流(HTTP 429) |
QoniTimeoutError | 请求或 wait() 超时;任务在服务端继续执行,可 attach() 重连 |
QoniUpstreamError | 下游产品服务错误 |
原始 Quickstart 委托写法
0.8.0 起,qoni.genauth.delegateAgent() 保留原始示例的委托写法,后面的任务仍使用 qoni.doAnything.run({ token: grant.token, prompt })。这项兼容只适用于当前 @qoniai/qoni,不恢复旧包名或不存在的服务端路由。
| 写法 | 当前处理 |
|---|---|
userId / user: { id } | 静默模式必须提供用户 ID;保持原有行为:非空顶层 userId 优先,否则使用 user.id。业务应只传一种写法 |
agentKey / agent | 同一 Agent 审计标签;两者同时提供时必须一致,不是独立的 Agent 身份凭证 |
expiresIn: '15m' / 900 | 签名前转换为整数秒;支持整数 s/m/h/d 时长或秒数字符串,范围为 60–86400 秒 |
scopes | 显式请求当前服务端支持的权限;products 仍可使用,与 scopes 取并集,不用于收窄权限 |
grant.token | genauth.delegateAgent() 直接返回委托数据;顶层 delegateToken() 仍使用 response.data.token,并保留 meta |
旧版 mailbox.*、web.search、web.extract / deniedScopes | 本委托服务尚未实现,SDK 会拒绝,不会转成更宽的产品权限 |
read/manage 约束产品 API,不代表邮箱动作权限。“只生成草稿”是任务要求;限制发送、删除邮件需要应用和服务端实际执行权限策略。
从 0.4.0 之前的版本迁移
0.4.0(2026-08-18)是一次破坏性重命名版本,服务端 wire 契约保持不变:
- 包名从
@eazo/anima改为@qoniai/qoni,推荐的主构造器统一为Qoni,环境变量统一为QONI_*前缀。公开的0.4.0仍保留旧的长名称构造器导出作为兼容别名;新代码应只使用Qoni。 - 顶层
qoni.delegateAgent/qoni.completeDelegateAgent已弃用,改用delegateToken/completeDelegateToken;兑换请求必须包含服务端保存的grantId(浏览器回调不带它),state传回调的grant_state;旧的{ code, state }形式不再支持。 0.8.0起支持userId和user: { id }两种用户 ID 写法;构造器选项accessKeyId改为accessKey。- 网关路由仍位于
/api/v3/eak/*,token claim 与eak.*错误码保持原样,应用不应自行构造这些内部值。
其他 SDK 的公开状态
截至 2026 年 8 月 19 日,QoniAI GitHub 组织只有 qoni-sdk-node 一个公开 SDK 仓库。当前没有在该组织下发现 Qoni Python、Java、Go、PHP 或 C# 统一 SDK。
文档中独立出现的 GUMem SDK、Web Agent SDK 或 GenAuth SDK 属于对应产品的接入资料或历史 SDK,不等同于已经在 QoniAI 组织公开的 Qoni 统一 SDK。选择生产依赖前,应同时确认包注册表、源代码仓库和发布版本;不要根据文档中的示例包名推断它已经公开发布。
检查结果
完成安装后,可以检查 npm 是否能解析当前版本:
npm view @qoniai/qoni version然后确认应用只从服务端读取 QONI_ACCESS_KEY、QONI_SECRET_KEY,并使用真实的 GenAuth 用户 ID 发起静默委托。
下一步
- 阅读 Personal Agent 和 Enterprise Agent,了解身份、行动和记忆如何在完整场景中组合。
- 查看
qoni-sdk-nodeREADME 获取完整 API 接口、错误类型和事件模型。