> For the complete documentation index, see [llms.txt](https://docs.convai.com/api-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/use-cases/website-assistant.md).

# 构建网站助手

在你网站的每个页面添加一个 Convai 角色，它知道访客当前所在的页面，并且可以为他们导航、突出显示元素以及填写表单。

在本教程中，我们将为一个多页面网站添加一个网站助手：一个 Convai 角色，会跟随访客在各页面之间切换，回答关于屏幕上内容的问题，并执行诸如“带我去价格页”或“把我的邮箱填入联系表单”之类的请求。到最后，你会在每个页面上都有一个助手面板，能通过文字或语音带访客在网站中移动。

### 我们要构建什么

我们将为一个示例网站构建一个助手，该网站包含 Home、Pricing、Docs 和 Contact 页面。这个助手会做四件事：

* **知道访客在哪里。** 每次页面变化时，网站都会把页面名称、页面元素以及页面文本发送给角色。
* **对网站执行操作。** 角色会选择诸如以下操作 `跳转到定价页` 或 `高亮“开始免费试用”按钮`，然后由网站执行它们。
* **听取反馈。** 在执行操作后，网站会告诉角色发生了什么，因此它的下一次回复会更准确。
* **保持一段对话。** 访客会在页面切换和刷新之间继续与同一个会话对话。

你的应用始终对每个操作负责：角色只会提出一个操作名称，具体是否以及如何执行由你的代码决定。

这些步骤使用不依赖框架的 TypeScript 和 History API 路由器。相同的调用也适用于 React、Vue 或服务端渲染网站。参见 [在 React 中使用](#use-it-in-react) 查看 React 版本。

### 先决条件

* Convai Web SDK 1.9.4 或更高版本。参见 [Web SDK 快速入门](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/quickstart.md).
* 一个 Convai 角色和一个 API 密钥。在生产环境中，请改用身份验证令牌而不是 API 密钥进行连接。参见 [使用认证令牌连接](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/auth-tokens.md).
* 你的网站页面以及助手可以指向的元素。

该助手可与 Convai 和自托管部署配合使用，支持默认实时传输和 SSE 传输。

### 描述你的网站

列出助手可以使用的页面和元素。每个 `名称` 是角色在选择一个操作目标时所说的内容，因此请把名称写得简短且明确。每个 `描述` 都会帮助角色做出选择。

```ts
// site.ts
export interface SitePage {
  name: string;
  path: string;
  description: string;
}

export interface SiteElement {
  name: string;
  description: string;
  path: string; // 元素所在的页面
  selector: string;
}

export const PAGES: SitePage[] = [
  { name: 'Home page', path: '/', description: 'Acme Robotics 及其仓储机器人概览' }，
  { name: 'Pricing page', path: '/pricing', description: '套餐和价格：Starter、Team 和 Enterprise' }，
  { name: 'Docs page', path: '/docs', description: '安装机器人的设置指南' }，
  { name: 'Contact page', path: '/contact', description: '联系销售团队的联系表单' }，
];

export const ELEMENTS: SiteElement[] = [
  { name: 'Start free trial button', description: '开始免费试用', path: '/pricing', selector: '#start-trial' }，
  { name: 'Install guide', description: '如何安装机器人', path: '/docs', selector: '#install-guide' }，
  { name: 'Email field', description: '联系表单的邮箱输入框', path: '/contact', selector: '#contact-email' }，
  { name: 'Message field', description: '联系表单的消息输入框', path: '/contact', selector: '#contact-message' }，
];

export function currentPage(): SitePage {
  return PAGES.find((page) => page.path === location.pathname) ?? PAGES[0];
}
```

### 声明这些操作

将操作及其可能的目标传入 `actionConfig`。页面和元素都放进 `objects`，这样角色就可以针对任意一种对象。

```ts
// assistant.ts
import { ConvaiClient, type ConvaiAction } from '@convai/web-sdk/core';
import { ELEMENTS, PAGES, currentPage, type SitePage } from './site';

const actionConfig = {
  actions: ['Navigate To', 'Highlight', 'Fill Email', 'Fill Message'],
  objects: [
    ...PAGES.map(({ name, description }) => ({ name, description })),
    ...ELEMENTS.map(({ name, description }) => ({ name, description })),
  ],
  characters: [{ name: 'Visitor', bio: '浏览这个网站的人' }],
};
```

连接会话时会发送该配置，因此请把助手可能需要的每个页面和元素都声明出来，而不只是当前页面上的那些。完整的操作契约请参见 [处理角色动作](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/actions.md).

### 告诉角色访客在哪里

编写一个描述当前页面的函数。请用助手的角色说明作为开头，因为每次更新都会整体替换之前的动态信息。

```ts
const ROLE =
  '你是 Acme Robotics 的网站助手。回答关于网站的问题，并且当 ' +
  '访客要求去某处、查看某物或填写某内容时，请执行匹配的操作。' +
  '以“Action result”开头的行表示网站做了什么。如果你选择的某个操作没有' +
  '结果，说明它没有执行：请这样告诉用户，并重新选择它。';

function describePage(page: SitePage): string {
  const visibleText = (document.querySelector<HTMLElement>('main')?.innerText ?? '')
    .replace(/\s+/g, ' ')
    .slice(0, 1500);
  const elements = ELEMENTS.filter((element) => element.path === page.path).map((element) => element.name);
  return [
    ROLE,
    `访客当前在 ${page.name}（${page.path}）上。`,
    elements.length ? `本页元素：${elements.join(', ')}。` : '',
    `页面文本：${visibleText}`,
  ] .filter(Boolean).join('\n');
}
```

在连接时用 `dynamicInfo`发送描述，然后在 `updateDynamicInfo` 每次页面变化时再次发送。 `updateDynamicInfo` 在会话就绪之前不会生效，所以也要在 `botReady`.

```ts
export const client = new ConvaiClient({
  apiKey: 'YOUR_API_KEY',
  characterId: 'YOUR_CHARACTER_ID',
  endUserId: visitorId(),
  characterSessionId: sessionStorage.getItem('assistant-session') ?? undefined,
  startWithAudioOn: false,
  actionConfig,
  dynamicInfo: describePage(currentPage()),
});

client.on('botReady', () => client.updateDynamicInfo(describePage(currentPage())));

// 在你的路由器每次页面渲染后从这里调用。
export function onPageChange(page: SitePage): void {
  client.updateDynamicInfo(describePage(page));
}
```

保持页面文本简短。只要对可见内容做个摘要，角色就足以据此回答问题。

{% hint style="success" %}
打开 Pricing 页面并问“我现在在哪个页面？”。角色会指出 Pricing 页面，并能回答有关其套餐的问题。
{% endhint %}

### 在页面之间保持同一段对话

`endUserId` 会识别访客， `characterSessionId` 会恢复他们的对话。请长期保存访客 ID，并为浏览器标签页保存会话 ID，这样刷新或完整页面加载后仍会继续同一段对话。

```ts
function visitorId(): string {
  let id = localStorage.getItem('assistant-visitor-id');
  if (!id) {
    id = crypto.randomUUID();
    localStorage.setItem('assistant-visitor-id', id);
  }
  return id;
}

client.on('characterSessionId', (id: string) => sessionStorage.setItem('assistant-session', id));
```

在单页应用中，在根组件只创建一次客户端，并在访客在各路由之间移动时保持连接。在完整页面加载的网站中，则在每个页面上都创建它：存储的会话 ID 会把对话接回上次停止的地方。客户端的聊天消息在每次连接时都会清空，因此在完整页面加载后面板会是空白的，而角色仍然记得这段对话。若要显示之前的轮次，请把你自己的对话记录副本保存在 `sessionStorage`中。若要让已登录访客拥有长期记忆，请把他们的账号 ID 作为 `endUserId`时发送。参见 [使用长期记忆](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/long-term-memory.md).

### 执行这些操作

订阅 `actionResponse`。每个事件按顺序携带一次回合的操作，因此请一个接一个地执行。

```ts
client.on('actionResponse', ({ actions }: { actions: ConvaiAction[] }) => {
  void runActions(actions);
});

async function runAction({ name, target }: ConvaiAction): Promise<string> {
  switch (name) {
    case 'Navigate To': {
      const page = PAGES.find((p) => p.name === target) ?? pageNamedIn(lastVisitorText);
      if (!page) return 'failed, unknown page';
      navigate(page.path); // 你的路由器
      return `done, now on the ${page.name}`;
    }
    case 'Highlight': {
      const element = ELEMENTS.find((e) => e.name === target);
      if (!element) return 'failed, unknown element';
      if (element.path !== location.pathname) navigate(element.path);
      return spotlight(element.selector) ? 'done' : 'failed, element not on screen';
    }
    case 'Fill Email':
      return fill('/contact', '#contact-email', valueFrom(target, /[\w.+-]+@[\w-]+\.[\w.-]+/));
    case 'Fill Message':
      return fill('/contact', '#contact-message', valueFrom(target, /"([^"]+)"/));
    default:
      return 'failed, not supported';
  }
}
```

Convai 还可以把目标放在操作名称里返回，例如 `Navigate To: Contact page` 没有 `目标`. `normalize` 会把已声明的操作名称和其余部分拆开，因此两种形式都按同样方式运行。 `runActions` 在 [报告发生了什么](#report-what-happened) 在 `runAction`:

```ts
const ACTIONS = actionConfig.actions;

function normalize(action: ConvaiAction): ConvaiAction {
  if (ACTIONS.includes(action.name)) return action;
  const name = ACTIONS.find((known) => action.name.toLowerCase().startsWith(known.toLowerCase()));
  if (!name) return action;
  const rest = action.name.slice(name.length).replace(/^[\s:-]+/, '').trim();
  return { name, target: action.target ?? (rest || undefined) };
}
```

其他辅助函数会把元素滚动到视图中并为其加上边框，还会把值输入到字段中：

```ts
function spotlight(selector: string): boolean {
  const element = document.querySelector<HTMLElement>(selector);
  if (!element) return false;
  element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  element.classList.add('spotlight'); // 用边框样式美化这个类
  return true;
}

function fill(path: string, selector: string, value: string | undefined): string {
  if (location.pathname !== path) navigate(path);
  const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector);
  if (!field) return 'failed, field not found';
  spotlight(selector);
  if (!value) return 'failed, no value given: ask the visitor what to enter';
  field.value = value;
  field.dispatchEvent(new Event('input', { bubbles: true }));
  return `done, entered "${value}"`;
}
```

将目标与你自己的列表匹配，忽略其他一切，这样角色就只能访问你声明过的页面和元素。像提交表单或付款这类不可逆步骤留给访客去完成：高亮按钮而不是按下它。

#### 从访客消息中读取值

一个操作目标会命名你声明的某个对象。像访客口述的电子邮箱这种自由格式的值，并不是已声明的对象，所以操作可能会在没有目标的情况下到达。请保存访客的最后一条消息，并在缺少目标时从中读取值。

```ts
let lastVisitorText = '';
export function rememberVisitorText(text: string): void {
  lastVisitorText = text;
}

function valueFrom(target: string | undefined, pattern: RegExp): string | undefined {
  if (target && pattern.test(target)) return target;
  const match = lastVisitorText.match(pattern);
  return match ? (match[1] ?? match[0]) : undefined;
}

function pageNamedIn(text: string): SitePage | undefined {
  const words = text.toLowerCase();
  return PAGES.find((page) => words.includes(page.name.replace(' page', '').toLowerCase()));
}
```

调用 `rememberVisitorText` 之前 `sendUserTextMessage`，以及从 `messagesChange` 中读取，用于语音请求，如下一节所示。 `pageNamedIn` 可覆盖偶尔出现的 `Navigate To` 这类没有目标就到达的情况。

### 报告发生了什么

在操作运行完后，把结果附加到角色的上下文中，而不要要求它回复。角色会在下一轮读取这些结果，因此它能确认发生了什么，或者重试。

```ts
async function runActions(actions: ConvaiAction[]): Promise<void> {
  const results: string[] = [];
  for (const action of actions.map(normalize)) {
    // 跳过你没有声明的名称，而不是把它们报告为失败。
    if (!ACTIONS.includes(action.name)) continue;
    results.push(`${action.name}${action.target ? ` ${action.target}` : ''}: ${await runAction(action)}`);
  }
  if (results.length) {
    client.updateContext({ text: `Action result: ${results.join('; ')}`, mode: 'append', run_llm: 'false' });
  }
}
```

如果没有结果，选择了某个操作的角色就无法判断它是否执行了，而且它可能会错误地告诉访客它已经执行。关于其他 `updateContext` 选项，请参见 [在运行时更新角色上下文](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/dynamic-context.md).

### 添加聊天面板

渲染对话、发送输入的消息，并让访客进行对话。 `AudioRenderer` 播放角色的声音。

```ts
// main.ts
import { AudioRenderer } from '@convai/web-sdk/vanilla';
import type { ChatMessage } from '@convai/web-sdk/core';
import { client, rememberVisitorText } from './assistant';

const transcript = document.querySelector<HTMLOListElement>('#transcript')!;
const composer = document.querySelector<HTMLFormElement>('#composer')!;
const input = document.querySelector<HTMLInputElement>('#message')!;
const mic = document.querySelector<HTMLButtonElement>('#mic')!;

const SPEAKERS: Partial<Record<ChatMessage['type'], string>> = {
  'user-llm-text': 'visitor',
  'user-transcription': 'visitor',
  'bot-llm-text': 'assistant',
};

client.on('messagesChange', (messages: ChatMessage[]) => {
  transcript.replaceChildren(
    ...messages
      .filter((message) => SPEAKERS[message.type] && message.content)
      .map((message) => {
        const item = document.createElement('li');
        item.className = `from-${SPEAKERS[message.type]}`;
        item.textContent = message.content;
        return item;
      }),
  );
  const latest = messages.filter((m) => SPEAKERS[m.type] === 'visitor').at(-1);
  if (latest?.content) rememberVisitorText(latest.content);
});

composer.addEventListener('submit', (event) => {
  event.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  rememberVisitorText(text);
  client.sendUserTextMessage(text);
  input.value = '';
});

mic.addEventListener('click', () => void client.audioControls.toggleAudio());

void client.connect().then(() => new AudioRenderer(client.room));
```

浏览器会在访客点击、轻触或按下页面上的按键之前阻止声音播放，而客户端通常会在此之前先连接上。 `AudioRenderer` 处理了这个问题：当浏览器阻止播放时，它会在访客第一次交互时启动语音，例如输入消息或按下麦克风按钮。

这个面板是纯 HTML：一个用于对话记录的有序列表，以及一个包含麦克风按钮、文本输入框和一个 **发送** 按钮的表单。把它固定在页面侧边，这样下面的页面内容变化时它仍保持原位。

### 试用助手

启动你的网站，等待面板连接，然后按顺序尝试这些请求：

1. “带我去价格页。” 网站会打开 Pricing 页面。
2. “Team 套餐多少钱？” 角色会根据页面文本作答。
3. “给我看看免费试用按钮。” 按钮会滚动到视图中并被高亮。
4. “去联系页面，并把 <jane@example.com> 填到邮箱字段里。” 网站会打开 Contact 页面，并在一次请求中填好邮箱字段。
5. “那样成功了吗？” 角色会根据操作结果作出确认。

{% hint style="success" %}
页面会变化，元素会在角色说话时被高亮，而它会正确确认结果。刷新页面：对话会从上次停下的地方继续。
{% endhint %}

选择麦克风并大声说出相同的请求。语音请求会产生相同的操作。

### 在 React 中使用

`useConvaiClient` 使用相同的选项。通过在路由变化时运行的 effect 更新上下文，并在另一个 effect 中处理操作。

```tsx
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useConvaiClient } from '@convai/web-sdk/react';

export function useWebsiteAssistant() {
  const location = useLocation();
  const navigate = useNavigate();
  const client = useConvaiClient({
    apiKey: 'YOUR_API_KEY',
    characterId: 'YOUR_CHARACTER_ID',
    endUserId: visitorId(),
    actionConfig,
    dynamicInfo: describePage(currentPage()),
  });

  useEffect(() => {
    // 等一帧，让新路由在读取其文本之前先完成渲染。
    const frame = requestAnimationFrame(() => client.updateDynamicInfo(describePage(currentPage())));
    return () => cancelAnimationFrame(frame);
  }, [client, location.pathname]);

  useEffect(
    () => client.on('actionResponse', ({ actions }) => void runActions(actions, navigate)),
    [client, navigate],
  );

  return client;
}
```

在根布局中只挂载一次这个 hook，这样对话就能在路由变化之间持续存在。

### 后续步骤

{% content-ref url="/pages/503cd2436973022c29d385f47025d13de33f428c" %}
[处理角色动作](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/actions.md)
{% endcontent-ref %}

{% content-ref url="/pages/4c37a8e73c580fc9f3ebe503a43373bd3551a22e" %}
[在运行时更新角色上下文](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/dynamic-context.md)
{% endcontent-ref %}

{% content-ref url="/pages/534e8cdc1d57114dcd7601d4455823f77a9ad2ef" %}
[使用认证令牌连接](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/auth-tokens.md)
{% endcontent-ref %}

{% content-ref url="/pages/000d34cc8d7b752e53fed78c1eab5ccdcdaf9a00" %}
[连接到自托管部署](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/self-hosted-deployments.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/use-cases/website-assistant.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
