Agent 可以拥有自己的网页,展示在对话侧边栏中。LLM 在 Next Hook 中通过 `ctx.Send` 触发导航,侧边栏从 Agent 的 `pages/` 目录渲染页面。
## 目录结构
```
assistants/myns/myagent/
├── package.yao
├── pages/
│ ├── index/ ← 路由:/agents/myns.myagent/
│ │ ├── index.html ← 模板
│ │ ├── index.ts ← 前端 TypeScript(事件处理)
│ │ ├── index.css ← 样式
│ │ ├── index.backend.ts ← 后端 API(GetXxx / ApiGetXxx 函数)
│ │ ├── index.json ← 页面配置($data 绑定、标题)
│ │ └── __locales/ ← 国际化字符串
│ │ ├── en-us.yml
│ │ └── zh-cn.yml
│ └── detail/ ← 路由:/agents/myns.myagent/detail
│ ├── detail.html ← 文件名与目录名一致
│ ├── detail.backend.ts
│ ├── detail.ts
│ └── detail.json
```
## 后端 API
`.backend.ts` 文件暴露两类函数:
- **`GetXxx`** — 页面加载时通过 `.json` 配置文件中的 `$data: "@GetXxx"` 调用,接收 HTTP `Request` 对象。
- **`ApiGetXxx` / `ApiPostXxx`** — 前端通过 `$Backend().Call("ApiGetXxx", ...)` 动态调用。
`Authorized()` 是 SUI 运行时提供的全局函数——声明后即可用于解析 `team_id`。
```typescript
// pages/index/index.backend.ts
import { Process } from "@yao/runtime";
declare function Authorized(): { team_id?: string } | null;
interface Request { authorized?: { team_id?: string } }
// 页面加载时通过 index.json: { "$data": "@GetRecords" } 调用
function GetRecords(request: Request) {
const teamId = request.authorized?.team_id || Authorized()?.team_id;
const MODEL = "agents.myns.myagent";
return Process(`models.${MODEL}.record.Get`, {
wheres: [{ column: "team_id", value: teamId }],
orders: [{ column: "created_at", option: "desc" }],
limit: 50,
});
}
// 前端动态调用
function ApiGetRecords(page: number, pageSize: number) {
const teamId = Authorized()?.team_id;
const MODEL = "agents.myns.myagent";
return Process(`models.${MODEL}.record.Paginate`, {
wheres: [{ column: "team_id", value: teamId }],
orders: [{ column: "created_at", option: "desc" }],
}, page, pageSize || 50);
}
```
`.json` 配置文件将 `GetRecords` 与页面初始数据关联:
```json
{
"title": "My Agent",
"$data": "@GetRecords"
}
```
## HTML 模板
SUI 页面使用基于组件的 HTML 模板,支持数据绑定:
```html
<!-- pages/index/index.html -->
<div id="records-index" class="page">
<!-- 引入共享导航组件 -->
<div is="/myns.myagent/nav" active="index"></div>
<main class="main">
<!-- s:for 遍历 "records" 数据数组 -->
<div s:for="{{ records }}" s:for-item="record" class="record-item">
<h3>{{ record.title }}</h3>
<p>{{ record.content }}</p>
<!-- s:on-click 绑定前端事件处理函数 -->
<button s:on-click="OpenRecord" s:data-id="{{ record.id }}">打开</button>
</div>
</main>
</div>
```
### SUI 模板指令
| 指令 | 说明 |
|------|------|
| `s:for="{{ array }}"` | 对每个元素重复渲染 |
| `s:for-item="name"` | `s:for` 内当前项的变量名 |
| `s:on-click="Handler"` | 将点击事件绑定到前端函数 |
| `s:data-*="{{ value }}"` | 向事件处理函数传递数据 |
| `s:trans` | 标记文本内容用于 i18n 翻译 |
| `is="/path/component"` | 引入 SUI 组件 |
## 前端事件处理
```typescript
// pages/index/index.ts
function OpenRecord(event: Event, data: { id: string }) {
// 导航到详情页
window.location.href = `/agents/myns.myagent/detail?id=${data.id}`;
}
function SearchEntries() {
const query = (document.getElementById("search-input") as HTMLInputElement).value;
// 从后端重新获取数据
// SUI 通过数据绑定系统提供内置的重载机制
}
```
## 从 Agent 触发导航
在 Next Hook 中,使用 `ctx.Send`(`type: "action"`,`name: "navigate"`)打开侧边栏页面:
```typescript
// src/index.ts
export function Next(ctx, payload) {
const recordId = payload.completion?.content;
// 将侧边栏导航到详情页
ctx.Send({
type: "action",
props: {
name: "navigate",
payload: {
route: `/agents/myns.myagent/detail?id=${recordId}`
}
}
});
return null;
}
```
LLM 响应结束后,用户会立即在对话侧边栏看到该页面。
## 国际化(i18n)
将翻译字符串放在 `__locales/` 目录:
```yaml
# pages/index/__locales/en-us.yml
index:
title: My Agent
search_placeholder: Search...
empty: No results found
# pages/index/__locales/zh-cn.yml
index:
title: 我的 Agent
search_placeholder: 搜索...
empty: 没有找到结果
```
在模板中,用 `s:trans` 翻译文本内容,用 `{{ '::key' }}` 翻译内联值:
```html
<h1 s:trans>My Agent</h1>
<input placeholder="{{ '::index.search_placeholder' }}" />
```
## 多页面
典型 Agent 拥有两到三个页面:
| 页面 | 路由 | 用途 |
|------|------|------|
| `index/` | `/agents/myns.myagent/` | 列表/浏览视图 |
| `detail/` | `/agents/myns.myagent/detail?id=xxx` | 单条记录详情视图 |
| `settings/` | `/agents/myns.myagent/settings` | 配置 |
使用共享组件在页面间导航:
```html
<div is="/myns.myagent/nav" active="index"></div>
```
## 下一步
- **[开源项目 →](./open-source)** — 在真实项目中查看所有这些模式的完整实现