# 前端工具与页面上下文 (/docs/cloud/services/agent/embed/client-tools)



智能体在对话里常常需要「此刻页面上的东西」：视频播到了哪里、表单填了什么、选中了哪一行。这些数据只在浏览器里，你的后端看不到。Kit 给了两个入口：

| 入口        | 谁触发     | 数据怎么到智能体            | 适合                       |
| --------- | ------- | ------------------- | ------------------------ |
| **前端工具**  | 智能体决定调用 | 智能体调用，页面里的函数执行并返回结果 | 按需读取、需要计算或查询的数据，以及页面上的动作 |
| **页面上下文** | 页面主动提供  | 随每条用户消息附带           | 几乎每轮都用得上的小事实，例如「正在看第几课」  |

<Callout title="先读这一条">
  前端工具和页面上下文都来自浏览器，用户可以改。智能体拿到的是**不可信**输入，不要据此做授权、计费这类决定。需要可信事实时用 [`system.message`](/docs/cloud/services/agent/embed/context)，需要鉴权的数据放在[工具服务端](/docs/cloud/services/agent/embed/tool-authorization)。
</Callout>

## 前端工具 [#前端工具]

分三步：在智能体里声明工具，在页面里实现它，智能体调用时 Kit 把结果送回去。

### 1. 在智能体定义里声明 [#1-在智能体定义里声明]

在智能体的 `tools` 里加一项 `type: "custom"`。智能体只看得到名字、描述和输入结构，执行在你的页面里。

```json
{
  "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） [#2-在页面里实现web-component]

把处理函数赋给元素的 **`onClientTool` 属性**。它是 JavaScript 属性，不是 HTML 属性，也不是 `setOptions` 的选项。

```html
<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） [#2-在页面里实现react]

React 绑定提供两个 hook：`usePraxisTool` 注册工具，`usePraxisReadable` 提供页面上下文。它们要在 `<PraxisAgent>` 的子树里，或在外层 `<PraxisProvider>` 里使用。

```tsx
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`），见[组件参考](/docs/cloud/services/agent/embed/component-reference)。

## 页面上下文 [#页面上下文]

`setReadable(name, value)`（React 里是 `usePraxisReadable`）登记一条页面事实。此后**每一条用户消息**发出时，Kit 都会附带当前所有登记项的快照，包括首条消息、输入框发送、`sendUserMessage` 和推荐问题。

* `name` 匹配 `[A-Za-z0-9_.:-]{1,64}`；值是字符串或可 JSON 序列化的值。非法的名字或值会抛出 `TypeError`，且不会覆盖原来的值。
* 更新同名项保持它原来的位置；移除后重新登记会排到最后。
* 没有登记项时不附带任何内容。

服务端把它渲染成附在**本轮用户消息末尾**的一段文本，并明确标记为不可信：

```xml
<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` 的警告。

下一步：[工具授权](/docs/cloud/services/agent/embed/tool-authorization) · [安全](/docs/cloud/services/agent/embed/security) · [组件参考](/docs/cloud/services/agent/embed/component-reference)
