# 给文档站加问答助手 (/docs/cookbook/scenarios/docs-qa-assistant)



本页带你给一个帮助中心或文档站加上「⌘K 问答」：访客按下 ⌘K（Windows / Linux 为 Ctrl+K）或点击搜索框，弹出一个居中的对话窗口，由服务型智能体依据你写好的内容回答。参照实现是仓库里「好物集」演示站（`apps/kit-demo`）的帮助中心页，路径 `/help`。

你需要：一个 Praxis API Key、一个能放后端接口的站点、一个前端页面。

<Callout title="未登录访客即将支持">
  这个场景最常见的需求是「访客不用登录就能问」。令牌签发需要你的后端带上终端用户 ID，所以现在的做法是：只对已登录的用户开放，或由后端自行决定用什么 ID 签发（见下文「未登录的访客」）。面向匿名访客的正式方案「可公开 key」还在开发中，上线后本页会补充，现在请不要依赖。
</Callout>

## 工作方式 [#工作方式]

```text
访客按 ⌘K ──▶ 页面加载 Kit，调用 getToken
                         │
                         ▼
        你的后端 POST /api/praxis-token ──(API Key)──▶ POST /agent/v1/embed_tokens
                         │
                         ◀── { token, expires_at }
Kit（modal）──(token)──▶ Praxis API ──▶ 服务型智能体
```

浏览器里没有 API Key。Kit 只在访客第一次打开时才加载，之后令牌由 `getToken` 按需取得。

## 1. 创建智能体并写入答案范围 [#1-创建智能体并写入答案范围]

1. 在 Console 打开「服务型智能体」，点「新建服务型智能体」。
2. 进入「接入与配置 · 配置 · 指令」，写明角色、回答范围和越界时怎么办，并把要回答的内容放进去。演示站的帮助中心智能体就是这样做的：把 FAQ 全文放进指令，要求「依据下面的内容回答，不编造政策、优惠或时效；范围外的问题请用户联系客服」，并且**不配置任何工具**。
3. 在「配置 · 安全」选择**运行环境**，在**来源白名单**里点「添加来源」，填你的站点来源（例如 `https://help.example.com`）。本地调试可打开「允许 localhost」。点「保存安全设置」。
4. 记下页面标题下方的智能体短号（旁边有复制按钮）。

期望：保存安全设置后出现「已保存，不产生新版本」；「接入与配置 · 试用调试」里能直接和智能体对话。

指令不是秘密，模型可能被用户要求复述，不要放不想公开的内容，见[传入上下文](/docs/cloud/services/agent/embed/context)。

## 2. 后端签发令牌 [#2-后端签发令牌]

在 Console 的 API Keys 里创建一个专门用来签发令牌的 Key，放进后端环境变量。下面是一个与框架无关的处理函数，参照演示站的 `/api/praxis-token`：

```ts
const API = "https://api-develop.deepraxis.com"; // 正式地址上线后替换

export async function handlePraxisToken(request: Request, currentUserId: string | null): Promise<Response> {
  if (!currentUserId) return Response.json({ error: "请先登录" }, { status: 401 });
  const response = await fetch(`${API}/agent/v1/embed_tokens`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PRAXIS_API_KEY}`, // 占位：只放在后端
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      agent: process.env.PRAXIS_AGENT,   // 智能体 id 或短号
      external_user_id: currentUserId,   // 来自你的登录态，不接受前端传入
    }),
    signal: AbortSignal.timeout(15_000),
  });
  // 不要把上游错误原文转给浏览器
  if (!response.ok) return Response.json({ error: "问答暂时不可用，请稍后重试" }, { status: 502 });
  const { token, expires_at } = await response.json();
  return Response.json({ token, expires_at });
}
```

期望：已登录请求返回 HTTP 200 和 `{ token, expires_at }`；未登录返回 401。令牌字段与错误见[嵌入令牌与终端用户](/docs/cloud/services/agent/embed/tokens)。

## 3. 前端：⌘K 打开 modal [#3-前端k-打开-modal]

Kit 的 `layout: "modal"` 是居中的原生对话框，自带遮罩、焦点管理和 Esc 关闭。`launcher: { type: "none" }` 表示不显示启动按钮，由你的页面调用 `open()`。⌘K 快捷键由页面自己监听，Kit 不内置。

```html
<button id="help-search">搜索或提问（⌘K）</button>

<script type="module">
  let pending;
  function loadAssistant() {
    pending ??= (async () => {
      await import("/praxis-kit.js"); // Kit 的构建产物，由你的站点提供
      await customElements.whenDefined("praxis-agent");
      const agent = document.createElement("praxis-agent");
      agent.setOptions({
        api: {
          url: "https://api-develop.deepraxis.com", // 正式地址上线后替换
          getToken: async () => {
            const response = await fetch("/api/praxis-token", { method: "POST", credentials: "same-origin" });
            if (!response.ok) throw new Error("问答暂时不可用，请稍后重试");
            return (await response.json()).token;
          },
        },
        agent: { name: "帮助中心问答" },
        layout: "modal",
        launcher: { type: "none" },
        header: { title: "帮助中心问答", icon: "help-circle" },
        startScreen: {
          greeting: "有什么可以帮你？",
          prompts: ["如何申请退货？", "在哪里查看配送进度？"].map(text => ({ label: text, prompt: text })),
        },
        composer: { placeholder: "输入你的问题…" },
      });
      document.body.appendChild(agent);
      return agent;
    })().catch(error => { pending = undefined; throw error; }); // 加载失败后允许再次尝试
    return pending;
  }

  async function openAssistant() {
    try { (await loadAssistant()).open(); }
    catch { alert("问答加载失败，请重新打开搜索。"); }
  }

  document.querySelector("#help-search").addEventListener("click", openAssistant);
  document.addEventListener("keydown", event => {
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
      event.preventDefault();
      openAssistant();
    }
  });
</script>
```

* `open()` / `close()` 是元素方法；窗口关闭时元素触发 `praxis.close` 事件，可以在里面把焦点还给搜索框。
* 想用 CDN 加载而不是自托管，见[安全](/docs/cloud/services/agent/embed/security)里的「子资源完整性」：浮动路径 `https://kit-develop.praxisusercontent.com/v1/praxis-kit.js`，或不可变路径 `/v0.1.0/praxis-kit.js` 配 `integrity`。
* 用 React 时换成 `@deepractice-ai/praxis-kit-react` 的 `<PraxisAgent>` 与 `usePraxisAgent()`，`control.open()` 打开，见[嵌入方式](/docs/cloud/services/agent/embed/methods)。

期望：按 ⌘K 或点击按钮，弹出带遮罩的对话窗口并出现欢迎语与两个推荐问题；点推荐问题收到回复；按 Esc 关闭。

## 4. 验证 [#4-验证]

不打开浏览器也可以验证后端和令牌：

```bash
curl -X POST https://api-develop.deepraxis.com/agent/v1/embed_tokens \
  -H "Authorization: Bearer $PRAXIS_API_KEY" -H "Content-Type: application/json" \
  -d '{"agent":"<智能体 id 或短号>","external_user_id":"smoke-test"}'
```

期望：HTTP 200，返回 `id`、`token`、`expires_at`。随后在 Console 的「运营与管理 · 签发记录」能看到这条记录，见[运营与管理](/docs/cloud/services/agent/embed/console-operate)。

## 未登录的访客 [#未登录的访客]

演示站为了能让任何人试用，对帮助中心智能体在未登录时用固定的 `external_user_id: "guest"` 签发。这只适合演示：所有访客共用同一个终端用户身份，嵌入策略里「单个终端用户的活跃会话上限」「每分钟请求上限」按这个身份合计，封禁一个访客就等于封禁全部。正式站点请在「可公开 key」上线前只对已登录用户开放，或自行给每位访客分配稳定的匿名 ID。

## 常见问题 [#常见问题]

* **签发返回 `INVALID_PARAMS`「embed policy not configured」**：智能体还没有嵌入策略，回到「配置 · 安全」保存一次。
* **签发返回 `EMBED_USER_BLOCKED`**：这个 `external_user_id` 在封禁名单里，在「运营与管理 · 终端用户」解封。
* **弹窗打开但无法对话**：确认页面来源在来源白名单里，`api.url` 与签发令牌用的是同一个环境。
* **页面设置了 CSP 后脚本被拦截**：见[安全](/docs/cloud/services/agent/embed/security)里的内容安全策略。

下一步：[在 SaaS 后台按登录用户嵌入](/docs/cookbook/scenarios/saas-embedded-assistant) · [组件参考](/docs/cloud/services/agent/embed/component-reference) · [限制与错误](/docs/cloud/services/agent/embed/limits-and-errors)
