跳到正文

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 类型声明
LicenseMIT

服务端凭证

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执行长时间研究任务并下载产物
track0.9.0 暴露旧监控入口,与当前 /track/tracks 后端不兼容;见 Track

运行时产品调用使用短期委托令牌。SDK 会负责运行时发现和下游产品令牌兑换,应用代码不应自行拼接内部 /api/v3/eak/* 路径或内部 claim。

安装 ​

下载样例和本页示例使用已发布到 npm 的 0.9.0,包含 genauth.delegateAgent() 兼容入口和按类型收窄的交互。已有应用可用下面的命令安装。下载样例 后,在 examples/qoni 执行 npm ci,会安装下载包附带的同版本 SDK。

bash
npm install @qoniai/qoni@0.9.0

也可以使用 pnpm 或 Yarn:

bash
pnpm add @qoniai/qoni@0.9.0
# 或
yarn add @qoniai/qoni@0.9.0

初始化客户端 ​

获取 AccessKey ​

  1. 打开 Qoni Console 的工作空间列表,选择要调用 SDK 的工作空间。
  2. 在左侧导航点击 AccessKey,再点击 创建 AccessKey,按调用需求配置权限。
  3. 创建成功后,在 保存 AccessKey 弹窗点击 复制凭据,将 AccessKey ID 和 AccessKey Secret 分别保存为服务端环境变量 QONI_ACCESS_KEY 和 QONI_SECRET_KEY。

AccessKey Secret 只在创建时显示一次。已有 AccessKey 可以在列表中查看 ID,但无法重新查看 Secret;如果没有保存 Secret,需要创建新的 AccessKey。

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

ts
// 为当前用户的研究任务申请委托;用户 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 的权威结果:

ts
// 泛型声明本页读取的字段;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,要过两道关:

  1. AccessKey 的权限策略:委托只能申请当前 AccessKey 允许的 scope,超出范围会返回 eak.delegation.scope_not_allowed。在 Console 创建或编辑 AccessKey 时勾选的权限策略,决定了这份允许列表。
  2. 产品接口的校验:GUMem 和 Web Agent 的接口按 scope 放行请求。下表的“放行的操作”一列说明每个 scope 在接口上实际能做什么,并标出用到它的 SDK 方法。

下面列出 SDK 中的 scope 常量及其契约用途;常量存在不代表部署已登记或实现。用户 Profile、支付和 Agent 邮箱能力仍是待落地设计,早期版本留下的旧动词见表后说明。SDK 常量都在 QoniScopes 下,例如 QoniScopes.GUMEM_MEMORY_READ。

GUMem

ScopeSDK 常量放行的操作Console 权限策略
gumem.memory:readGUMEM_MEMORY_READ读取 Session 上下文和消息流;同时满足 gumem.action:read、gumem.profile:read 的要求。SDK 方法:gumem.recallGUMem 记忆
gumem.memory:writeGUMEM_MEMORY_WRITE写入 Memory;同时满足 gumem.session:create、gumem.message:write、gumem.resource:write、gumem.action:write 的要求。SDK 方法:gumem.createSession、gumem.addMessages、gumem.uploadResourceGUMem 记忆
gumem.session:createGUMEM_SESSION_CREATE创建 SessionGUMem 会话
gumem.message:writeGUMEM_MESSAGE_WRITE向 Session 写入消息GUMem 会话
gumem.resource:writeGUMEM_RESOURCE_WRITE上传资源文件GUMem 管理
gumem.action:writeGUMEM_ACTION_WRITE记录用户 Action。SDK 方法:gumem.actions.recordGUMem 行为
gumem.action:readGUMEM_ACTION_READ查询用户 Action 及其进度、事实、摘要和主题,订阅 Action 流。SDK 方法:gumem.actions.recall、gumem.actions.streamGUMem 行为
gumem.profile:readGUMEM_PROFILE_READ读取和召回用户画像GUMem 管理
gumem.admin:manageGUMEM_ADMIN_MANAGE调用 GUMem 控制台的项目管理接口GUMem 管理
gumem.memory:deleteGUMEM_MEMORY_DELETE预留,目前没有接口校验它,申请后不增加权限GUMem 记忆
gumem.search:runGUMEM_SEARCH_RUN预留,目前没有接口校验它,申请后不增加权限GUMem 搜索

Web Agent

ScopeSDK 常量放行的操作Console 权限策略
webagent.do_anything:readDO_ANYTHING_READ读取 DoAnything 任务的状态、事件、产物和录屏WebAgent 任意任务
webagent.do_anything:manageDO_ANYTHING_MANAGE启动和取消任务、提交交互响应、发送消息;同时包含 readWebAgent 任意任务
webagent.web_search:readWEB_SEARCH_READ读取搜索状态和结果WebAgent 网页搜索
webagent.web_search:manageWEB_SEARCH_MANAGE发起和取消搜索;同时包含 readWebAgent 网页搜索
webagent.deep_research:readDEEP_RESEARCH_READ读取研究任务的状态、事件和报告产物WebAgent 深度研究
webagent.deep_research:manageDEEP_RESEARCH_MANAGE发起和取消研究、提交交互响应;同时包含 readWebAgent 深度研究
webagent.track:readTRACK_READ查看监控任务和执行记录WebAgent 追踪
webagent.track:manageTRACK_MANAGE创建、修改、暂停、恢复、立即执行和删除监控;同时包含 readWebAgent 追踪

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 拒绝。下表保留设计用途,不代表正式包和部署已完成该能力。

ScopeSDK 常量放行的操作Console 权限策略
user.profile:readUSER_PROFILE_READ读取用户 Profile 中的姓名、电话和收货地址,供 DoAnything 代填结账信息GenAuth 用户资料
user.profile:writeUSER_PROFILE_WRITE把用户在信息补全(fill_form)交互中填写、并标了 profileField 的字段写入 Profile。设计方法:handle.submit(0.9.0 不存在)GenAuth 用户资料
user.payment:useUSER_PAYMENT_USE用户在确认(confirmation)交互中允许支付后,由 Web Agent 运行时从 Profile 取卡,在受控浏览器中代填;不向 App 或模型返回卡号GenAuth 支付
agent.mail:readAGENT_MAIL_READ读取 Agent 专属邮箱中的邮件,例如验证码和订单确认GenAuth Agent 邮箱
agent.mail:sendAGENT_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.userInfoQoniResponse<T>
qoni.resolveAnyBoundUser()从绑定用户池取一个用户,仅用于演示;生产应用识别真实当前用户用户 ID 字符串
qoni.genauth.userInfo({ accessToken })用用户的 GenAuth Access Token 查询身份QoniResponse<T>
qoni.genauth.discovery() / jwks()读取 OIDC 发现配置 / 验签公钥QoniResponse<T>
qoni.genauth.introspectDelegationToken({ token })查询委托是否有效及实际 scopeQoniResponse<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 用户 APIQoniResponse<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、skillsRunHandle<DoAnythingEvent>
qoni.doAnything.attach(runId, options)原任务 ID、token;可选 session/capture重连原任务,不创建新任务
qoni.doAnything.artifacts({ token, runId })委托令牌和任务 ID;可选 signalArtifact[],无需重放事件
qoni.webSearch.run(input)token、prompt(字符串或字符串数组);可选 maxResultsPerQuery、siteWhitelist、siteBlacklist、captureRunHandle<WebSearchEvent>;不支持 session/limits
qoni.webSearch.attach(runId, options)原任务 ID、token;可选 capture重连搜索任务
qoni.deepResearch.run(input)token、prompt;可选 depth、outputFormat、targetAudience、domainWhitelist、domainBlacklist、session、limits、captureRunHandle<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.apicreateSession、createRun、getRun、events、intervene、cancel、readArtifacts、listArtifacts、artifactDownloadUrl、readRecording
qoni.webSearch.apirun、get、events、cancel
qoni.deepResearch.apirun、get、events、followUp、cancel、feedback、listArtifacts、getArtifact
qoni.track.apicreateMonitor、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选择登录、问答、审批等业务展示
statuspending 待处理、active 进行中、resolved 已解决、expired 已过期、canceled 已取消
title / prompt?卡片标题 / 可选详细说明
createdAt / resolvedAt? / expiresAt?请求创建、解决及过期时间
evidence?可选的 { artifactId } 证据引用
payload当前类型的业务数据,见下表
actions当前允许的动作;每项有 kind/label/method/endpoint 和可选 inputSchema

下面是一条问询请求的演示数据,仅展示主要字段:

jsonc
{
  "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 等字段
  ]
}

五类请求的数据与响应 ​

typepayload应用怎么做
site_loginsites: [{ siteId, displayName, loginUrl }],可选 monitorId用户在受控界面完成登录后,handle.confirmSignedIn() 请 Agent 复查;登录界面由 openLogin() 打开时,它先保存这次登录
ask_userquestion、answerType,可选 options: [{ value, label }]提交实际回答:handle.answer(value),值的类型与 answerType 对应;允许跳过时使用 skip()
confirmationsummary同意计划:handle.confirm();拒绝:handle.reject()
take_controlliveUrl,可选 surface/reason用户打开已交出的浏览器;完成后 handle.releaseControl()
waitwaitKind(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() 会等待回调返回后才继续消费事件。

ts
// 应用的交互演示,不是 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>>>
ts
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 }) 由应用自己打开(见下文)。

ts
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;用户又打开了一个登录窗口、还没打开完时也会拒绝,等新窗口打开后在里面完成。

ts
// 应用可为用户选中的站点打开受控登录浏览器;用户登录后,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。示例不会代填“只处理售后问题”等固定回答。

ts
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();显示卡片不会自动批准。

ts
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 和接管原因,让用户完成手动步骤后点击完成。演示把浏览器入口保留在卡片数据中,不写入日志;它可能携带当前浏览器的访问能力。

ts
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 时,卡片只有状态说明;提供时,由用户决定是否重试。

ts
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 方法。

ts
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 客户端交给浏览器。

共享实现负责四件事:

  1. 按 id 替换当前卡片,以最新 actions 重建按钮;旧按钮失效。
  2. 对 pending/active 展示内容;终态只更新状态并清除按钮,保留原内容。仅收到历史终态时不新建卡片;终态不可逆,后续旧的非终态回放被忽略。
  3. 用户点击时先校验回答和可用动作,再锁定该请求,避免双击或回放重复提交。
  4. 提交出错时保留错误,不自动重试或解锁旧按钮。唯一的例外是 SDK 在发送前的本地校验失败(QoniValidationError 且没有 HTTP status,例如数字题收到了文字):请求没有发出,演示恢复卡片,让用户改正后再提交;带 status 的错误来自服务端,请求已经发出,卡片保持锁定。这份本地演示对同一请求只提交一次,不会在后续非终态事件中自动重新开放按钮;演示不把失败当作成功。

**一次响应演示的边界:**SDK 的交互数据没有请求修订号,也没有单独查询某个交互当前状态的类型化方法;新状态通过交互事件送达。演示保留提交锁,无法恢复同一 id 再次要求登录或重试的按钮。这种重新提供的请求或提交结果不明确的情况,应在 Console/部署界面继续处理;生产应用需基于服务端事件和自己的幂等策略设计恢复流程。这个限制属于演示,不属于 confirmSignedIn() 等 SDK 方法。

查看共享的卡片更新、类型收窄和响应实现
ts
// 一次响应的本地演示:提交后的同一 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() 收集密码。

ts
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_profileSDK 导出此方法;当前后端不提供或实现对应动作,演示不连接它

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() 重新读取同一个搜索任务,而不是重新发起一次搜索。默认是公开网页与独立演示用户,示例偏好不会写进业务用户。

下载完整样例,或在文档仓库中运行:

bash
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 类型声明检查全部源文件。

ts
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 句柄。请求字段、业务函数和用户响应见交互请求与业务函数。

本地验证与记录 ​

bash
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 执行。

bash
# 在文档仓库根目录执行(先在 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 原包没有此错误类
QoniAuthErroraccessKey / 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.tokengenauth.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 是否能解析当前版本:

bash
npm view @qoniai/qoni version

然后确认应用只从服务端读取 QONI_ACCESS_KEY、QONI_SECRET_KEY,并使用真实的 GenAuth 用户 ID 发起静默委托。

下一步 ​