# 在 SaaS 后台按登录用户嵌入 (/docs/cookbook/scenarios/saas-embedded-assistant)



本页带你把服务型智能体放进一个有登录的 SaaS 后台，并做到三件事：智能体知道**当前是谁**、知道**他正在看什么**、只能查**他自己的**数据。参照实现是仓库里「好物集」演示站（`apps/kit-demo`）的两个场景：右下角浮窗客服，和课程页右侧的课程助教。

你需要：一个 Praxis API Key、一个有登录态的后端、一个前端页面，以及（想让智能体查订单时）一个你自己的 MCP 服务。

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

```text
登录用户 ──▶ 你的后端 /api/praxis-token
              │  用登录态里的用户 ID + 用户信息
              ▼
        POST /agent/v1/embed_tokens
        { agent, external_user_id, system_message }
              │
              ◀── { token, expires_at }
浏览器（Kit）──(token)──▶ Praxis API ──▶ 智能体 ──MCP──▶ 你的订单服务
                                                 └ 带 X-Praxis-External-User-Id
```

三类信息各走各的渠道，可信度不同，完整说明见[传入上下文](/docs/cloud/services/agent/embed/context)：

| 信息            | 渠道                      | 可信         |
| ------------- | ----------------------- | ---------- |
| 当前用户的姓名、会员等级  | 签发令牌时的 `system_message` | 是，来自你的后端   |
| 用户正在看哪一课、播到哪里 | 页面上下文与前端工具              | 否，来自浏览器    |
| 用户的订单         | 智能体调用你的 MCP 工具          | 取决于你的服务端鉴权 |

## 1. 创建智能体 [#1-创建智能体]

在 Console「服务型智能体」点「新建服务型智能体」，然后在「接入与配置 · 配置」里：

1. **指令**：写角色与边界。演示站客服的要点是：只回答本店商品、订单、退货与会员问题；查订单和退货必须使用订单工具，不编造订单；退货前先用一句话向用户确认订单与原因。
2. **MCP 与凭据**：添加 MCP 服务器，名称填 `orders`，类型选 HTTP，URL 填 `https://你的域名/mcp`。再到下方「凭据库」勾选存放该服务访问令牌的凭据库，并保存凭据库选择。凭据的「允许的主机」要包含 MCP 服务的域名，会话按它自动匹配凭据。
3. **工具与授权**：在「授权方式」里，查询类工具选「自动执行」，申请退货这类写入工具选「每次询问」。
4. **安全**：选运行环境，在来源白名单添加你的后台域名，保存安全设置。要允许用 `system_message` 之外的方式覆盖指令才打开「允许宿主覆盖指令」，这里不需要。

期望：在「试用调试」里用「模拟」页签填入 `external_user_id` 与 `system_message`，点「重新签发」后能和智能体对话，「上下文」页签的 host\_context 显示你填的内容。

## 2. 后端：按登录用户签发，并带上用户信息 [#2-后端按登录用户签发并带上用户信息]

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

export async function handlePraxisToken(request: Request, user: { id: string; name: string; level: string } | null) {
  if (!user) 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_SUPPORT,
      external_user_id: user.id, // 稳定且唯一，来自登录态
      system_message: [{ type: "text", text: `当前用户 ${user.name}，会员等级 ${user.level}` }],
    }),
    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 });
}
```

* `external_user_id` 只能来自你的登录态，不接受请求体里的值；它还是下面 MCP 鉴权用的身份。
* `system_message` 是文本块数组，合计不超过 8192 字节。它**不是秘密**，模型可能被要求复述。
* 课程助教是另一个智能体：同一个接口，换 `agent`，`system_message` 里换成学员信息。演示站用一个 `agent` 参数在后端选择，并且只接受预先登记的几个值。

期望：HTTP 200 与 `{ token, expires_at }`；未登录 401。

## 3. 前端：浮窗客服 [#3-前端浮窗客服]

```js
import "/praxis-kit.js"; // Kit 的构建产物，由你的站点提供

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: "popup",
  launcher: { type: "bubble", position: "bottom-right", label: "咨询客服" },
  header: { title: "好物集客服", subtitle: "通常几秒内回复" },
  startScreen: {
    greeting: "你好！有问题随时问我。",
    prompts: ["我的订单到哪了", "怎么退货"].map(text => ({ label: text, prompt: text })),
  },
});
document.body.appendChild(agent);
```

只在用户登录之后才创建这个元素；退出登录时整页刷新，这样上一位用户的令牌不会留在内存里。

期望：右下角出现气泡，点开出现欢迎语；问「我的订单到哪了」时智能体调用订单工具并给出该用户的订单。

## 4. 前端：侧栏课程助教，带页面上下文和前端工具 [#4-前端侧栏课程助教带页面上下文和前端工具]

课程页用 `layout: "sidebar"`（右侧 400px 非模态面板，需要你为它在页面上留出位置）。助教要知道「学员正在看哪一课、播到哪里」，用两个入口：页面上下文放每轮都用得上的小事实，前端工具放需要时才读的数据。

先在智能体定义的 `tools` 里声明前端工具（类型 `custom`，写法见[前端工具与页面上下文](/docs/cloud/services/agent/embed/client-tools)）：

```json
{
  "type": "custom",
  "name": "get_playback_progress",
  "description": "读取学员当前的视频播放进度",
  "input_schema": { "type": "object", "properties": {}, "additionalProperties": false }
}
```

再在页面里实现：

```js
const tutor = document.createElement("praxis-agent");
tutor.setOptions({
  api: { url: "https://api-develop.deepraxis.com", getToken: fetchTokenForTutor },
  agent: { name: "课程助教" },
  layout: "sidebar",
  launcher: { type: "none" },
  header: { title: "课程助教", icon: "message-circle" },
});

// 页面上下文：随每条用户消息附带，标记为不可信
tutor.setReadable("course", "降噪耳机入门");
tutor.setReadable("lesson", "第 1 课：认识主动降噪");

// 前端工具：智能体调用时在页面里执行，返回值交还给智能体
tutor.onClientTool = ({ name }) => {
  if (name !== "get_playback_progress") throw new Error("unknown tool");
  return { lesson: "第 1 课：认识主动降噪", position_seconds: 83, duration_seconds: 300, playing: true };
};

document.body.appendChild(tutor);
tutor.addEventListener("praxis.open", () => document.body.classList.add("tutor-open"));
tutor.addEventListener("praxis.close", () => document.body.classList.remove("tutor-open"));
document.querySelector("#open-tutor").addEventListener("click", () => tutor.open());
```

换课时再调用一次 `setReadable("lesson", …)`，之后每条消息自动带最新值。`onClientTool` 是元素的 JavaScript 属性，不是 `setOptions` 的选项；对不认识的工具名必须抛错。限制、超时与 React 写法见[前端工具与页面上下文](/docs/cloud/services/agent/embed/client-tools)。

期望：打开侧栏后问「我现在看到哪了」，智能体调用 `get_playback_progress` 并据返回值回答，页面上能看到它没有编造进度。

## 5. 服务端按用户鉴权的 MCP 工具 [#5-服务端按用户鉴权的-mcp-工具]

平台调用你的 MCP 服务时会带上 `X-Praxis-External-User-Id`（令牌里的 `external_user_id`）、`X-Praxis-Agent-Id`、`X-Praxis-Session-Id`。工具**必须**按这个头限定数据范围，不要用模型传来的参数确定用户。

```ts
// MCP 请求入口：先校验平台出示的访问令牌，再取用户身份
export async function handleMcp(request: Request, expectedToken: string) {
  if (request.headers.get("authorization") !== `Bearer ${expectedToken}`) {
    return Response.json({ error: "未授权" }, { status: 401 });
  }
  const userId = request.headers.get("X-Praxis-External-User-Id"); // 每个请求单独读取
  // list_orders: 只返回 userId 名下的订单
  // request_return: 先确认订单属于 userId，再受理
  // userId 缺失或不认识 → 以工具错误返回，不返回任何数据
}
```

演示站的 MCP 服务有两个工具：`list_orders`（`readOnlyHint: true`）和 `request_return`（`destructiveHint: true`，参数 `order_id`、`reason`，订单不属于当前用户时返回「订单不存在或不属于当前用户」）。每个请求单独创建服务与闭包，并发请求之间不会串用户。

期望：用 alice 登录问订单只看到 alice 的订单；换 bob 登录看到 bob 的；让智能体「查 alice 的订单」时 bob 拿不到任何数据。询问确认只是帮用户确认意图，不是安全手段，详见[工具授权](/docs/cloud/services/agent/embed/tool-authorization)。

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

* **智能体说查不了订单**：在「配置 · MCP 与凭据」确认 MCP 服务器地址可访问，凭据库已勾选，且凭据的允许主机包含该域名。
* **工具总是返回「缺少用户身份」**：只有会话属于某位终端用户时才带身份头；确认令牌签发时传了 `external_user_id`。
* **侧栏遮住了页面内容**：侧栏是浮在页面上的面板，要用 `praxis.open` / `praxis.close` 事件给页面留出空位。
* **前端工具总是「not available」**：`onClientTool` 要在智能体调用之前赋到元素上。

下一步：[给文档站加问答助手](/docs/cookbook/scenarios/docs-qa-assistant) · [令牌与终端用户](/docs/cloud/services/agent/embed/tokens) · [安全](/docs/cloud/services/agent/embed/security)
