Guide · 使用教程
assistant-ui 工具 UI 教程:服务端审批、权限与历史恢复
用 AI SDK v7 的模拟工单工具展示批准、拒绝、等待、结果与错误,并验证服务端责任。
这篇教程用 assistant-ui 展示一次工具调用:等待用户决定、批准或拒绝、批准后等待执行、返回模拟结果。后端工具只返回虚构工单的模拟数据,不会关闭真实工单。接口依据 2026 年 10 月 12 日的官方 AI SDK v7 适配文档;本文未使用真实模型账号进行端到端调用。
开始前准备
先在独立演示项目搭好基础聊天。官方 CLI 可创建项目:
npx assistant-ui@latest create
跟随 CLI 选择项目目录,按模板配置所选模型的服务端凭证,再运行 npm run dev。模型 key 不应放入公开前端变量或浏览器代码。核对依赖与当前 v7 文档:该适配路线使用 ai@^7、@ai-sdk/react@^4 和 @assistant-ui/ai-sdk。如果已有项目使用旧版本,先按对应适配文档迁移或保持旧版,不能混用代码片段。
第一步:定义无业务副作用的工具
在后端工具模块定义虚构的“关闭工单”模拟工具,并对它要求用户审批。下面是可导入的工具与策略定义,接到基础聊天的 streamText 选项中:tools 使用 demoTools,toolApproval 使用 approvalPolicy。模型与消息流部分沿用已完成的 v7 基础接入。
import { tool } from "ai";
import { z } from "zod";
export const demoTools = {
resolve_ticket: tool({
description: "请求模拟关闭一个虚构工单;不会写入真实业务系统。",
inputSchema: z.object({ ticketId: z.string().min(1).max(80) }),
execute: async ({ ticketId }) => ({ ticketId, simulated: true }),
}),
};
export const approvalPolicy = {
resolve_ticket: () => "user-approval" as const,
};
这是服务端门控:运行时先发出审批请求,收到对应决定后再继续。当前 v7 使用调用级 toolApproval;不要把其他版本的 needsApproval 例子直接复制进来。参数 schema 只约束形状,真实执行时仍必须校验会话、租户和工单权限。
第二步:把审批状态显示为工具 UI
创建一个客户端 renderer 工具包,与服务端工具使用相同名称 resolve_ticket。这里使用 v7 适配文档的客户端 renderer 写法,执行器仍在后端;这段代码没有采用 "use generative" 编译拆分模式。
"use client";
import { useRef, useState } from "react";
import {
defineToolkit,
useAuiState,
type ToolApprovalResponse,
} from "@assistant-ui/react";
export const approvalUi = defineToolkit({
resolve_ticket: {
type: "backend",
render: ({ args, approval, respondToApproval, result, isError, status }) => {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const lock = useRef(false);
const canAnswer = useAuiState((s) => s.thread.capabilities.answerToolCall);
const decide = async (answer: ToolApprovalResponse) => {
if (lock.current || !canAnswer) return;
lock.current = true;
setBusy(true);
setError(null);
try {
await respondToApproval(answer);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "提交决定失败");
} finally {
lock.current = false;
setBusy(false);
}
};
if (approval?.approved === false) return <p>已拒绝,未继续模拟操作。</p>;
if (isError || status.type === "incomplete") {
return <p role="alert">工具未成功完成,请先核对服务端记录。</p>;
}
if (result !== undefined) {
return <pre>{JSON.stringify(result, null, 2)}</pre>;
}
if (approval?.approved === true) return <p>已批准,等待服务端结果。</p>;
if (!approval) return <p>正在等待工具状态。</p>;
return (
<section aria-label="模拟操作确认">
<p>候选操作:模拟关闭工单 {String(args.ticketId)}</p>
<p>本例不会修改真实工单。</p>
<button
disabled={busy || !canAnswer}
onClick={() => void decide({ approved: true })}
>
批准模拟操作
</button>
<button
disabled={busy || !canAnswer}
onClick={() => void decide({ approved: false, reason: "用户拒绝" })}
>
拒绝
</button>
{!canAnswer && <p>此记录为只读,不能回应审批。</p>}
{error && <p role="alert">{error}</p>}
</section>
);
},
},
});
关键是三个分支:approved 未定义表示尚待决定,true 表示批准已记录并等待结果,false 表示拒绝。实际渲染还要确认 approval 对象已经存在,避免普通工具加载阶段错误显示批准按钮。不要使用 if (result) 判断完成,因为合法结果可能是 false、0 或空值;这里检查是否为 undefined。
respondToApproval 从当前工具 part 读取审批标识。不同交互有不同 API:用户提供整个结果用 addResult;执行器通过 human() 暂停时用 resume;服务端审批门控使用 respondToApproval。它们不能互相替代。
第三步:注册 renderer 并继续审批后的请求
把工具包加入 runtime config。下面的 provider 可包住基础聊天已有的 Thread;./ticket-toolkit 对应上一段客户端工具包文件。
"use client";
import type { ReactNode } from "react";
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useAISDKRuntime } from "@assistant-ui/ai-sdk";
import { approvalUi } from "./ticket-toolkit";
export function ChatRuntime({ children }: { children: ReactNode }) {
const chat = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
const runtime = useAISDKRuntime(chat);
const config = AuiConfig({ tools: Tools({ toolkit: approvalUi }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
{children}
</AssistantRuntimeProvider>
);
}
在聊天里请求“模拟关闭工单 demo-123”,分别测试批准和拒绝。批准后应得到 simulated: true 的模拟结果;拒绝后不能出现执行结果。如果有审批卡片却没有继续,检查工具名称、approval response、自动继续条件、消息序列化和后端版本。
第四步:真实业务必须在服务端复核
替换模拟执行器之前,把下面的检查放在实际后端中,并使用测试环境验证。UI 的决定不应让后端信任客户端自报的身份或任意参数。
| 检查 | 要求 |
|---|---|
| 身份与租户 | 从服务端会话取身份和租户,禁止客户端指定任意范围 |
| 资源权限 | 确认该用户有权修改这张工单 |
| 参数与版本 | 批准绑定到具体工具调用、资源与参数版本,参数变更重新确认 |
| 幂等 | 同一业务请求只产生一次修改,重复回应不重复执行 |
| 历史恢复 | 读取旧记录不重新执行;状态恢复以服务端记录为准 |
| 取消和错误 | 取消、超时、断连和已执行后丢响应都有明确状态 |
真实确认界面的工单状态与操作范围应来自获准读取的服务端资料,而不是只展示模型编造的字段。生成式 UI 的组件词表、schema 和 React renderer 也要限制危险 URL、内容与动作;组件名字在 allowlist 内不能证明它有权执行。
最后验收
用虚构数据测试批准、拒绝、重复点击、只读历史、伪造审批、跨租户 ID、过期版本和取消。再用键盘与手机完成整个交互,检查错误是否可见、按钮是否明确和读屏提示。记录每个完成任务的模型、运行与可选托管费用;MAU 不是 token 额度。通过后,再接入一个受控的真实业务操作。
常见问题
- 本例会修改真实工单吗?
- 不会。工具只返回 simulated: true 的虚构结果,真实业务执行器需另外落实权限与幂等。
- 本教程是否完成了真实模型端到端测试?
- 没有。代码按当前官方文档编写并做语法检查,实际 SDK 类型、账号、运行时与业务流程仍需集成验收。
- 服务端审批应该回应哪个 API?
- 对应运行时的 approval 门控使用 respondToApproval;不要用 addResult 或 resume 替代。
- 为什么批准后还显示等待?
- 批准表示交互决定已记录,服务端执行与结果返回是之后的阶段,也可能失败或被业务校验拒绝。
- 打开历史记录能再次批准吗?
- 只读记录应禁用回应能力,后端也必须防止历史恢复或重放造成重复副作用。
- 真实操作需要哪些服务端检查?
- 从会话获取身份和租户,验证资源与操作权限,将批准绑定到调用和参数版本,并记录幂等、状态和审计。