前端工具与页面上下文
让智能体读取你页面里的数据、调用页面上的函数:前端工具(custom tool)和页面上下文(readable)。
智能体在对话里常常需要「此刻页面上的东西」:视频播到了哪里、表单填了什么、选中了哪一行。这些数据只在浏览器里,你的后端看不到。Kit 给了两个入口:
| 入口 | 谁触发 | 数据怎么到智能体 | 适合 |
|---|---|---|---|
| 前端工具 | 智能体决定调用 | 智能体调用,页面里的函数执行并返回结果 | 按需读取、需要计算或查询的数据,以及页面上的动作 |
| 页面上下文 | 页面主动提供 | 随每条用户消息附带 | 几乎每轮都用得上的小事实,例如「正在看第几课」 |
先读这一条
前端工具和页面上下文都来自浏览器,用户可以改。智能体拿到的是不可信输入,不要据此做授权、计费这类决定。需要可信事实时用 system.message,需要鉴权的数据放在工具服务端。
前端工具
分三步:在智能体里声明工具,在页面里实现它,智能体调用时 Kit 把结果送回去。
1. 在智能体定义里声明
在智能体的 tools 里加一项 type: "custom"。智能体只看得到名字、描述和输入结构,执行在你的页面里。
{
"type": "custom",
"name": "read_playback_progress",
"description": "读取用户当前视频的播放进度,单位秒。用户问「我看到哪了」时调用。",
"input_schema": { "type": "object", "properties": {} }
}name必须匹配^[a-zA-Z0-9_-]{1,64}$,不能与其他自定义工具或内置工具重名。description决定智能体何时调用它,写清楚用途和何时用。input_schema是 JSON Schema,type必须是object。
2. 在页面里实现(Web Component)
把处理函数赋给元素的 onClientTool 属性。它是 JavaScript 属性,不是 HTML 属性,也不是 setOptions 的选项。
<video id="lesson-video" src="/lessons/3.mp4" controls></video>
<praxis-agent style="display:block;height:600px"></praxis-agent>
<script type="module">
import "/praxis-kit.js";
const video = document.querySelector("#lesson-video");
const agent = document.querySelector("praxis-agent");
agent.setOptions({
api: {
url: "https://api-develop.deepraxis.com",
getToken: async () => {
const response = await fetch("/api/praxis-token", { method: "POST" });
if (!response.ok) throw new Error("暂时无法使用");
return (await response.json()).token;
},
},
agent: { name: "课程助手" },
});
// 前端工具:智能体调用时执行
agent.onClientTool = async ({ id, name, input }) => {
if (name !== "read_playback_progress") throw new Error(`未知工具:${name}`);
return { positionSeconds: video.currentTime, durationSeconds: video.duration };
};
// 页面上下文:随每条用户消息附带
agent.setReadable("lesson", "正在看第 3 课");
agent.setReadable("playback", { positionSeconds: 83 });
// 之后可以移除
agent.removeReadable("playback");
// 可选:观察调用与日志
agent.addEventListener("praxis.tool.change", e => console.log(e.detail));
agent.addEventListener("praxis.log", e => console.warn(e.detail));
</script>处理函数收到 { id, name, input }:id 是这次调用的编号,name 是工具名,input 是智能体按 input_schema 传来的参数。Kit 不维护工具注册表,所有自定义工具都进这一个函数,所以对不认识的名字必须抛错。
返回值的处理:
| 你返回 | 智能体收到 |
|---|---|
| 字符串 | 原样 |
| 其他可序列化的值 | JSON.stringify 后的文本 |
undefined | 空字符串 |
| 抛出错误、Promise 被拒绝、结果无法序列化 | 错误文本,并标记为错误(is_error) |
2'. 在页面里实现(React)
React 绑定提供两个 hook:usePraxisTool 注册工具,usePraxisReadable 提供页面上下文。它们要在 <PraxisAgent> 的子树里,或在外层 <PraxisProvider> 里使用。
import { useMemo, useRef } from "react";
import {
PraxisAgent, usePraxisTool, usePraxisReadable,
} from "@deepractice-ai/praxis-kit-react";
function LessonBridge({ lesson, videoRef }) {
// 页面上下文:lesson 变化时自动更新,卸载时自动移除
usePraxisReadable("lesson", `正在看第 ${lesson.number} 课:${lesson.title}`);
// 前端工具:卸载或改名时自动取消注册
usePraxisTool("read_playback_progress", () => ({
positionSeconds: videoRef.current?.currentTime ?? 0,
durationSeconds: videoRef.current?.duration ?? 0,
}));
return null;
}
export function LessonPage({ lesson }) {
const videoRef = useRef<HTMLVideoElement>(null);
const options = useMemo(() => ({
api: { url: "https://api-develop.deepraxis.com" },
agent: { name: "课程助手" },
}), []);
return (
<>
<video ref={videoRef} src={lesson.src} controls />
<PraxisAgent
options={options}
getToken={async () => {
const response = await fetch("/api/praxis-token", { method: "POST" });
if (!response.ok) throw new Error("暂时无法使用");
return (await response.json()).token;
}}
onToolChange={e => console.log(e.detail)}
>
<LessonBridge lesson={lesson} videoRef={videoRef} />
</PraxisAgent>
</>
);
}要点:
usePraxisTool(name, handler):handler收到(input, call),可返回值或 Promise。handler 更换时自动使用最新的函数,不会重新注册。同名工具重复注册时,最后挂载的生效,开发模式下会警告;它卸载后恢复上一个。usePraxisReadable(name, value):挂载时设置,JSON.stringify(value)变化时更新,卸载时移除。值要能 JSON 序列化,变化要触发 React 渲染。- 没有用 hook 注册的工具名,会交给
<PraxisAgent onClientTool={…}>;两者都不处理时,调用以unknown tool <name>报错返回给智能体。 - 想在
<PraxisAgent>之外的组件里注册,把整棵树包在<PraxisProvider>里。 options请保持引用稳定(useMemo),见组件参考。
页面上下文
setReadable(name, value)(React 里是 usePraxisReadable)登记一条页面事实。此后每一条用户消息发出时,Kit 都会附带当前所有登记项的快照,包括首条消息、输入框发送、sendUserMessage 和推荐问题。
name匹配[A-Za-z0-9_.:-]{1,64};值是字符串或可 JSON 序列化的值。非法的名字或值会抛出TypeError,且不会覆盖原来的值。- 更新同名项保持它原来的位置;移除后重新登记会排到最后。
- 没有登记项时不附带任何内容。
服务端把它渲染成附在本轮用户消息末尾的一段文本,并明确标记为不可信:
<page_context untrusted="true">
<item name="lesson">正在看第 3 课</item>
<item name="playback">{"positionSeconds":83}</item>
</page_context>它不进系统提示,也不会延续到下一轮:下一条消息带的是那一刻的新快照。所以页面状态变了,智能体下一轮自然看到新值,你不需要清理旧值。
超时、错误与未注册的工具
| 情况 | 结果 |
|---|---|
元素上没有 onClientTool(React 里没有对应 hook 和 onClientTool) | 立刻返回 tool <name> is not available on this page,标记为错误(React 中为 unknown tool <name>) |
| 处理函数抛错或 Promise 被拒绝 | 错误信息作为错误结果返回,智能体看得到,通常会向用户解释或换个办法 |
结果无法 JSON.stringify | 同上,作为错误返回 |
| 页面一直没有返回结果 | 服务端等待最多 120 秒,到时记一条错误结果(client tool timed out),本轮继续 |
| 结果写回失败 | Kit 触发 praxis.log(level: "error"),不会重跑你的函数,也不会发出成功的 end 事件;服务端的 120 秒超时兜底 |
同一个调用编号只执行一次:Kit 在执行前先认领编号,并检查历史里是否已有结果。切换会话、断线重连、重新挂载都不会让已经回答过的调用重跑;还没有结果的历史调用在页面重新打开时可以被执行。这不是跨标签页的事务保证,同一会话在两个标签页打开时,不要让工具产生副作用。
事件
在元素上监听,都会冒泡并穿过 Shadow DOM。
| 事件 | detail | 说明 |
|---|---|---|
praxis.tool.change | { id, name, status: "start" | "end", isError? } | start 在你的函数执行前;end 在结果被服务端确认之后 |
praxis.log | { level: "info" | "warn" | "error", message, data? } | 上下文被截断时为 warn,data.names 列出被丢弃的名字;结果写回失败时为 error |
React 里对应 onToolChange 和 onLog。
限制
| 项 | 限制 |
|---|---|
| 页面上下文条数 | 每条消息最多 32 项 |
| 页面上下文体积 | 序列化后合计最多 4096 字节(UTF-8) |
| 超出时 | Kit 从最早登记的开始丢弃,直到满足两项限制,并触发 praxis.log 的 warn;登记本身不受影响。全部被丢弃时不附带该字段 |
| 名字 | [A-Za-z0-9_.:-]{1,64} |
| 工具名 | ^[a-zA-Z0-9_-]{1,64}$ |
| 等待结果 | 最多 120 秒 |
给智能体看的数据要精简:只放这一轮可能用到的事实,大块数据用前端工具按需读取。
常见问题
- 工具没被调用:确认智能体定义里有同名的
custom工具,并且description说明了何时用。 - 总是收到「not available」:
onClientTool要赋给元素的属性,且在智能体调用之前赋好;放在setOptions里无效。 - 上下文没有出现:确认值已登记且没有超过限制,留意
praxis.log的警告。