# 让 AI 帮你写插件 (/docs/build/ai-authoring)
> 在 App 里让助手和你一起写 PLUGIN.md 插件。它会查阅语言参考、校验草稿、对真实服务试运行只读工具,最后打开审核页,由你决定是否安装。
插件文档是纯文本,所以 App 里的 AI 助手可以和你一起写。比如你可以说「给体育馆预约网站做个插件」,最后会得到一个 `PLUGIN.md` 文件夹。你审核之后,可以安装它,也可以放弃。
你只需要 App 和一个配置好的 AI 模型,不需要电脑。不过,如果之后要发布插件,你还是会用到电脑。
助手通过一个名为 **Plugins**(id 为 `plugins`)的内置插件提供的工具(tool)完成这项工作。这些工具都不能在你确认之前安装任何东西。
## 相关工具
助手会用到五个工具。前四个只读取,最后一个负责为你打开审核页。
**`plugins.reference`**(read)用一页的篇幅返回完整的插件语言:清单、步骤、表达式、标准库、宿主能力、限制和一个示例。助手在准备编写或修改插件时才读取它。它不在每一轮的提示词里,所以不会占用其他对话的空间。
**`plugins.validate`**(read)用和 App 相同的加载器检查一个草稿文件夹。草稿以 `{ files: { "PLUGIN.md": "…", "tools/x.yaml": "…" } }` 的形式传入。草稿有效时,工具返回 `{ ok: true, id, version, tools, widgets, campus, network, abilities }`;否则返回 `{ ok: false, issues }`,每个问题都会标出所在的文件和步骤。
**`plugins.tryTool`**(read)在不安装草稿的前提下,真实运行草稿里的一个**只读**工具。它会发出真实请求,对草稿声明的服务使用你的校园登录状态,并给工具一个用完即弃的存储。`write`、`money` 和 `public` 效果的工具不能这样试运行。运行结果会被当作不可信数据。
**`plugins.source`**(read)返回已安装插件文档的文件,供助手解释、修复或复制。内置的 TypeScript 插件没有插件文档,所以这个工具对它们无效。
**`plugins.install`**(write)为一个链接(GitHub 文件夹或 `PLUGIN.md` 的 URL)或一份草稿(`{ files }`)打开 App 的审核页。它只负责打开审核页,装不装由你在审核页上决定。
Plugins 插件默认在运行 `write` 工具之前询问你。所以只要你没有改过这项授权方式,助手在打开审核页之前也会先征得你的同意。
## 编写插件的流程
### 说出你的需求
说清楚插件要做什么。如果你知道它该用哪个页面或接口,也一起告诉助手。如果涉及校园服务,请明确指出:插件会用你的登录状态调用这个服务,永远看不到你的密码。
### 助手起草并校验
这些工具不在对话开始时提供:助手先用 `plugins.use` 加载 `plugins` 插件的编写工具。然后它读取 `plugins.reference`,再编写 `PLUGIN.md` 和各个工具文件。每次修改后,它都会调用 `plugins.validate`,并持续修正草稿,直到没有问题为止。
### 试运行只读工具
`plugins.tryTool` 会对真实服务运行只读工具。安装之前你就能看到实际结果,从而判断插件是否真的能用。
### 审核并安装
`plugins.install` 打开的审核页,和你通过安装链接看到的是同一个。它会显示插件是官方的还是社区的,能访问哪些校园服务、网站和 App 功能,以及每个工具和它的效果。在对话中写出的草稿算作社区插件。你点「安装」之前,什么都不会装上。
## 修改已安装的插件
想修改已安装的插件文档,直接请助手修复或扩展它。助手会用 `plugins.source` 读取文件,修改后进行校验,再用新版本重新打开审核页。
如果你在对话里贴了一个插件链接,助手会用 `plugins.install { link }` 为它打开审核页。
## 分享你的作品
助手写出的草稿就是一个普通的插件文件夹。想分享它,或者让它能通过链接更新,就把文件夹放进一个公开的 GitHub 仓库,文件夹的 URL 就是它的安装链接(见[编写第一个插件](/docs/build/quickstart#share-it))。发布之前,请在电脑上运行一遍[作者 CLI](/docs/build/declarative-plugins#test),并补上录制好的测试。
## 安全
- 助手从不自行安装任何东西。`plugins.install` 只会打开审核页。
- `plugins.tryTool` 只运行只读工具,只能访问草稿声明的范围,也不会保留任何数据。
- 装好的草稿和其他社区插件遵守同样的规则:`write`、`money` 和 `public` 工具会先询问,结果作为数据交给 AI,不会被当作指令。
插件语言本身见[声明式插件](/docs/build/declarative-plugins),各种授权方式怎样运作见[权限](/docs/concepts/permissions)。
# 内置插件 (/docs/build/bundled-plugin)
> 用 definePlugin 编写随 App 一起发布的插件。本页通过一个完整示例,依次介绍工具、数据源、页面、设置项、小组件、快捷指令、英文文字和测试。
内置插件(bundled plugin)是 `pku-harness-plugins/plugins/` 下的一个 TypeScript 模块,只依赖 `@pku-harness/sdk`。它在手机上的 App 内部运行,只能通过「宿主服务」(host services)访问 App 的其他部分。宿主服务是 App 传给每个插件的一组对象,用于发起校园请求、发送通知、管理待办和定时任务等。和插件文档不同,内置插件可以添加原生界面。
内置插件只能随 App 版本发布,所以**请先开一个 issue**,说明插件做什么,以及为什么不能写成插件文档或远程插件。
TypeScript 用于 App 自身的功能:界面、看板、定时任务、待办、日程、课表、记忆、插件管理、模型服务商、身份验证器和付款码页面。插件语言暂时表达不了的接入也用 TypeScript。官方的校园接入 classrooms、campus-events、grades、canteen、shuttle 和 notices 都是随 App 内置的[插件文档](/docs/build/declarative-plugins),其余的正在迁移。如果你的插件只是向校内或公开接口发请求、再提取数据,请改写 `PLUGIN.md`。如果它需要插件语言还没有的功能,请提议新增一项宿主能力(ability)。
本页从头编写一个完整的插件 `focus`。它记录专注时段(比如做一小时作业),并显示你今天一共专注了多少分钟。这个插件不调用任何外部服务,可以直接运行。如果要对接校园服务,就把其中的存储调用换成 `ctx.services.campus.request(...)`(见[校园会话](/docs/concepts/campus-sessions))。
你需要 bun,并了解 TypeScript 和一些 React Native。第 1 到第 6 步都在你电脑上的插件仓库里完成,最后一节会在模拟器或手机上的 App 里运行这个插件。
## 目录结构
插件放在 `plugins/` 下自己的文件夹里。除此之外,你还要修改两个共用文件:内置插件列表 `plugins/index.ts`,以及 `registry/registry.json`。
## 1. package.json
```json title="plugins/focus/package.json"
{
"name": "@pku-harness/plugin-focus",
"private": true,
"version": "0.1.0",
"description": "Focus sessions and today's total, on the device",
"type": "module",
"main": "src/index.ts",
"license": "MIT",
"pkuHarness": { "builtin": true },
"peerDependencies": {
"@pku-harness/sdk": "*",
"zod": "^4.0.0",
"react": "*",
"react-native": "*"
}
}
```
每个插件都是插件工作区里的一个包,工作区会把 `@pku-harness/sdk` 解析到 `packages/sdk/src`。所有包都是私有的,不会发布到 npm。依赖写成 peer dependencies,因为它们由 App 提供。添加其他运行时依赖之前,请先和我们商量,因为所有代码都会打进 App 的安装包,让它变大。
## 2. 先写纯逻辑
先写不依赖 React Native 的逻辑,放在普通模块里。导入了 `react-native` 的模块,不做 mock 的话 `bun test` 无法加载,所以把逻辑分开写,测试起来就很方便。
这里的 `store.ts` 把专注时段保存在插件的键值(KV)存储里,并计算今天的分钟数。`minutesOn` 是一个纯函数:它把时间 `now` 当作参数传入,而不是自己读取时钟,所以测试可以传入任意时间。
```ts title="plugins/focus/src/store.ts"
import type { KV } from '@pku-harness/sdk';
export type Session = { id: string; label: string; startedAt: number; endedAt: number | null };
const KEY = 'sessions';
export async function loadSessions(kv: KV): Promise {
return (await kv.get(KEY)) ?? [];
}
export async function saveSessions(kv: KV, list: Session[]): Promise {
await kv.set(KEY, list.slice(-500));
}
/** `now` 所在本地日期的专注分钟数,进行中的专注算到 `now` 为止。纯函数。 */
export function minutesOn(list: Session[], now: number): number {
const d = new Date(now);
const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime();
let ms = 0;
for (const s of list) {
const a = Math.max(s.startedAt, start);
const b = Math.min(s.endedAt ?? now, end);
if (b > a) ms += b - a;
}
return Math.round(ms / 60_000);
}
export const running = (list: Session[]) => list.find((s) => s.endedAt == null) ?? null;
```
## 3. 插件本体
`src/index.ts` 把各部分组合起来。它定义了三个工具(`start`、`stop` 和 `today`)、一个供小组件使用的数据源,以及插件添加到 App 里的页面、设置项、小组件和快捷指令。
```ts title="plugins/focus/src/index.ts"
import { definePlugin, defineTool } from '@pku-harness/sdk';
import { z } from 'zod';
import { FocusScreen } from './screen';
import { loadSessions, minutesOn, running, saveSessions } from './store';
import { FocusWidget } from './widget';
/** 专注:开始、结束专注时段,查看今天的总时长。所有数据都留在设备上。 */
export const focusPlugin = definePlugin({
id: 'focus',
name: '专注',
description: '记录专注时段,查看今天专注了多久。',
version: '0.1.0',
icon: 'clock',
requires: [],
scopes: [],
i18n: {
en: {
name: 'Focus',
description: 'Track focus sessions and see how long you focused today.',
tools: { start: { title: 'Start focusing' }, stop: { title: 'Stop focusing' }, today: { title: 'Focus today' } },
widgets: { today: { title: 'Focus today', description: 'Minutes focused today and the running session, if any.' } },
screens: { main: { title: 'Focus' } },
settings: { main: { title: 'Focus', subtitle: 'Focus sessions' } },
commands: { focus: { description: 'Open focus sessions', aliases: ['pomodoro'] } },
dataSources: { today: { title: 'Focus today' } },
},
},
instructions: `专注记录:用户说"开始学习/专注""我学完了"时用 focus__start / focus__stop;问"今天学了多久"时用 focus__today。`,
dataSources: {
today: { title: '今日专注', tool: 'today', ttlMs: 60_000 },
},
contributes: {
screens: { main: { title: '专注', component: FocusScreen } },
settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
widgets: {
today: {
title: '今日专注',
description: '今天专注的分钟数,以及正在进行的专注。',
sizes: ['sm', 'md', 'lg'],
defaultSize: 'sm',
component: FocusWidget,
},
},
commands: {
focus: { description: '打开专注', aliases: ['专注', '番茄钟'], run: (_input, { navigate }) => navigate('/p/focus/main') },
},
},
tools: {
start: defineTool({
title: '开始专注',
description: 'Start a focus session now. Fails if one is already running (stop it first).',
input: z.object({ label: z.string().trim().max(40).optional().describe('what the user focuses on, e.g. "高数作业"') }),
effect: 'write',
execute: async ({ label }, ctx) => {
const list = await loadSessions(ctx.kv);
if (running(list)) throw new Error('已经有一个正在进行的专注,请先结束它');
const session = { id: Date.now().toString(36), label: label ?? '', startedAt: Date.now(), endedAt: null };
await saveSessions(ctx.kv, [...list, session]);
return { started: true, label: session.label };
},
}),
stop: defineTool({
title: '结束专注',
description: 'Stop the running focus session. Returns its length in minutes.',
input: z.object({}),
effect: 'write',
execute: async (_input, ctx) => {
const list = await loadSessions(ctx.kv);
const current = running(list);
if (!current) return { stopped: false, reason: 'nothing running' };
const now = Date.now();
await saveSessions(ctx.kv, list.map((s) => (s.id === current.id ? { ...s, endedAt: now } : s)));
return { stopped: true, minutes: Math.round((now - current.startedAt) / 60_000) };
},
}),
today: defineTool({
title: '今日专注',
description: 'Minutes focused today (device-local day), including the running session, and the running session if any.',
input: z.object({}),
effect: 'read',
execute: async (_input, ctx) => {
const list = await loadSessions(ctx.kv);
const current = running(list);
return { minutes: minutesOn(list, Date.now()), running: current ? { label: current.label, startedAt: current.startedAt } : null };
},
}),
},
});
```
`definePlugin` 接收一个描述整个插件的对象,其中各部分的作用如下:
| 字段 | 作用 |
| --- | --- |
| `id` | 注册表 id,格式为 `^[a-z0-9][a-z0-9-]{1,40}$`。工具 id 会是 `focus.start`,模型看到的名字是 `focus__start`。 |
| `name`、`description`、工具的 `title` | 中文,展示给用户。英文写在 `i18n.en` 里。 |
| `instructions` | 加在助手的系统提示词(system prompt)里,每次请求都会发送,所以要写得短。提到工具时用模型名。 |
| `dataSources` | 适合给小组件和定时检查用的读取工具,带缓存时长。 |
| `contributes.screens` | 挂载在 `/p/focus/main`(`presentation: 'modal'` 时为 `/m/focus/`)。 |
| `contributes.settings` | 设置页里的一行,点开进入某个页面。 |
| `contributes.widgets` | 一个原生的看板小组件(widget),助手会读取它的 `description`。 |
| `contributes.commands` | 聊天输入框里的 `/focus` 快捷指令(这里是一个本地操作)。 |
| `tools` | 插件的 API:每个工具一种效果(effect),用 zod 定义输入,返回数据。 |
## 4. 界面
页面和小组件通过 `host` 属性拿到 App 的组件,从不直接从 App 导入。这样插件就不依赖 App 的代码,App 改版后插件照样能用。页面、小组件和字符串表的写法如下:
```tsx title="plugins/focus/src/screen.tsx"
import type { PluginScreenProps } from '@pku-harness/sdk';
import { strings } from './strings';
type Today = { minutes: number; running: { label: string; startedAt: number } | null };
/** 开始和结束专注。以用户身份调用插件自己的工具。 */
export function FocusScreen({ host, invoke }: PluginScreenProps) {
const { Screen, Stat, Button, Column, Text } = host.components;
const s = strings[host.useLocale()];
const data = host.useToolData('focus.today', {}, { ttlMinutes: 1 });
const active = data.value?.running ?? null;
const toggle = async () => {
host.haptics.tap();
await invoke(active ? 'focus.stop' : 'focus.start', {});
await data.refresh();
};
return (
{active ? s.running(active.label) : s.idle}
);
}
```
```tsx title="plugins/focus/src/widget.tsx"
import type { PluginWidgetProps } from '@pku-harness/sdk';
import { View } from 'react-native';
import { strings } from './strings';
type Today = { minutes: number; running: { label: string; startedAt: number } | null };
/** 今天的专注分钟数;由宿主负责刷新(小组件里不要轮询)。 */
export function FocusWidget({ host, size }: PluginWidgetProps) {
const { Text, Skeleton } = host.components;
const t = host.useTheme();
const s = strings[host.useLocale()];
const data = host.useToolData('focus.today', {}, { ttlMinutes: 1 });
if (data.status === 'loading') return ;
if (!data.value) return {data.error ?? ''};
return (
{size === 'sm' ? (
{s.today}
) : null}
{s.minutes(data.value.minutes)}
{data.value.running ? s.running(data.value.running.label) : s.idle}
);
}
```
```ts title="plugins/focus/src/strings.ts"
import { defineStrings } from '@pku-harness/sdk';
type Strings = {
today: string;
minutes: (n: number) => string;
start: string;
stop: string;
running: (label: string) => string;
idle: string;
};
export const strings = defineStrings({
'zh-Hans': {
today: '今日专注',
minutes: (n) => `${n} 分钟`,
start: '开始专注',
stop: '结束',
running: (label) => (label ? `正在专注:${label}` : '正在专注'),
idle: '还没有开始',
},
en: {
today: 'Focus today',
minutes: (n) => (n === 1 ? '1 min' : `${n} min`),
start: 'Start focusing',
stop: 'Stop',
running: (label) => (label ? `Focusing on ${label}` : 'Focusing'),
idle: 'Not started yet',
},
});
```
页面用 `invoke` 调用插件自己的工具。`invoke` 以**用户身份**(`actor: 'user'`)运行工具:输入照常校验,权限照常生效,但不会弹出授权确认,因为用户刚刚亲手点了按钮。执行修改操作之后,要刷新页面上正在显示的实时查询,就像 `toggle` 里调用 `data.refresh()` 那样。
## 5. 测试
测试覆盖第 2 步的纯函数 `minutesOn`。由于它把 `now` 当作参数,每个测试都可以固定时间。
```ts title="plugins/focus/src/store.test.ts"
import { describe, expect, test } from 'bun:test';
import { minutesOn, type Session } from './store';
const at = (h: number, m = 0) => new Date(2026, 9, 21, h, m).getTime();
describe('minutesOn', () => {
test('sums finished and running sessions of the day', () => {
const list: Session[] = [
{ id: 'a', label: '高数', startedAt: at(8), endedAt: at(8, 50) },
{ id: 'b', label: '英语', startedAt: at(10), endedAt: null },
];
expect(minutesOn(list, at(10, 30))).toBe(80);
});
test('cuts a session that started yesterday at midnight', () => {
const list: Session[] = [{ id: 'c', label: '', startedAt: at(0) - 30 * 60_000, endedAt: at(0, 20) }];
expect(minutesOn(list, at(12))).toBe(20);
});
});
```
## 6. 注册插件
App 只加载 `plugins/index.ts` 里列出的插件,所以要把你的插件加进这个列表。列表的顺序也就是插件在设置页和系统提示词里的顺序:
```ts title="plugins/index.ts"
import { focusPlugin } from './focus/src';
export const builtinPlugins: PluginDef[] = [
// …
focusPlugin,
];
```
然后在 `registry/registry.json` 里加一条记录,插件市场展示的就是这份列表。图标不能和其他条目重复:
```json
{
"id": "focus",
"name": "专注",
"description": "记录专注时段,查看今天专注了多久。",
"author": "your-github-name",
"source": "https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/focus",
"kind": "builtin",
"icon": "clock",
"requires": [],
"scopes": [],
"tags": []
}
```
最后,运行和持续集成(CI)相同的检查:
```bash
bun test # 你的测试,以及多语言覆盖测试(plugins/i18n.test.ts)
bunx tsc --noEmit -p . # 对 SDK 和插件做类型检查
bun scripts/validate-registry.ts # 检查 schema、id 和图标是否唯一、内置插件是否存在
```
## 在 App 里运行
App 以 git 子模块 `plugins/` 的形式引入 `pku-harness-plugins`,并从这里加载 `@pku-harness/sdk` 和内置插件。所以要在 App 里看到你的插件,就要在这个子模块里开发。
### 连同子模块克隆 App
```bash
git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git
cd pku-harness
bun install
```
### 在 `plugins/` 里开发
创建 `plugins/plugins/focus/`,也就是子模块里的 `plugins/` 目录。在 `plugins/plugins/index.ts` 里注册插件,然后运行 App:
```bash
bun run android # 在模拟器或设备上运行开发版
# 或者,已经装好开发版时:
bunx expo start --dev-client
```
Metro 是 React Native 的 JavaScript 打包工具,你保存文件后它会自动重新加载 App。要推送分支,就把子模块的远程仓库指向你的 fork(`git -C plugins remote add …`)。pull request 提交到 `pku-harness-plugins`,之后再由 App 仓库更新子模块指向的提交。
### 试一试
有四种方式可以试用:打开「设置 → 专注」;在看板的小组件库里添加小组件;在对话里输入 `/focus`;或者对助手说「开始专注高数作业」。
如果你没有模型的 API key,可以用[模拟模型](/docs/build/testing#mock-model)。在设备上,有一种只在开发版里可用的深链接,可以直接运行某个工具:`pkuharness://dev?tool=focus.today&input={}`。
## 规则
- **必须**只从 `@pku-harness/sdk`、`zod`、`react` 和 `react-native` 导入,外加你自己的文件和 `plugins/shared/`(内置插件之间共享的代码,比如 `campus-card.ts`)。不能导入 App 的代码,也不能导入其他插件目录里的文件。
- **必须**遵守[工具规则](/docs/build/tools)、[多语言规则](/docs/build/i18n)和[能力需求规则](/docs/concepts/capabilities)。
- **必须**用 `host.useToolData` 加载小组件和页面的数据,不要轮询。
- **应当**让 `instructions` 和 `context` 尽量短,因为每次请求都要为它们消耗 token。
# 声明式插件(PLUGIN.md) (/docs/build/declarative-plugins)
> 插件语言的说明。插件是一个文件夹,包含 PLUGIN.md 清单、给助手的说明、用 YAML 步骤写的工具和生成式界面小组件。App 解释执行它,通过链接安装,不需要构建代码,也不需要 App 发版。
本页介绍插件语言:插件文件夹怎样组织,清单里写什么,工具怎样用步骤和表达式写成,以及插件怎样安装和更新。如果你还没写过插件,请先完成[快速上手](/docs/build/quickstart)。
语言规范见 [`docs/PLUGIN-LANGUAGE.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/PLUGIN-LANGUAGE.md)(规范版本 1),实现位于 `@pku-harness/sdk` 的 `lang/` 目录,其中有 schema、解释器、宿主能力和加载器。本页是规范的摘要。两者不一致时,App 以 SDK 里的 [`schema.ts`](https://github.com/pkuinfo/pku-harness-plugins/blob/main/packages/sdk/src/lang/schema.ts) 和 [`abilities.ts`](https://github.com/pkuinfo/pku-harness-plugins/blob/main/packages/sdk/src/lang/abilities.ts) 为准。
声明式插件是一个由文本文件组成的文件夹,可以作为链接分享。它在 PKU harness 里的作用,和 `SKILL.md` 文件夹在 AI Agent 里的作用相同。App 读取这个文件夹,用一个小而安全的解释器执行其中声明的步骤。App 从不下载或运行插件代码,因此发布新插件或更新插件既不需要 App 发版,也不需要服务器。
如果一项任务只是向校园网站或公开网站发几个请求,再提取一些数据,就适合写成声明式插件。例如空闲教室、食堂人流和营业时间、班车时刻表、成绩、校园活动和通知。
有些事情无法用插件语言安全地表达,比如读取短信验证码、添加待办。App 把这类功能做成「宿主能力」(host ability):它们内置在 App 里,任何插件声明之后都可以调用。如果你有自己的服务器,请改写[远程插件](/docs/build/remote-plugin)。你也可以在 App 里[让 AI 帮你写插件](/docs/build/ai-authoring)。
## 官方插件文档
[`pku-harness-plugins/plugins`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins) 里的官方校园接入都是插件文档,每个都附有录制好的测试,很适合当作范例来读。它们分别演示了插件语言的不同部分:
- [`classrooms`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/classrooms) 用 `each` 并行发出公开请求,在 `constants` 里定义别名,并提供一个看板小组件。
- [`campus-events`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/campus-events) 通过 `verify.treehole` 读取树洞日历。
- [`grades`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/grades) 用 `verify.treehole` 和 `verify.treeholeSendCode` 处理二次验证,验证方式可以是短信,也可以是手机令牌(北大统一身份认证 IAAA 的身份验证器)。
- [`canteen`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/canteen) 使用 `shared:` 文件,把结果缓存在键值(KV)存储里、请求失败时退回旧数据,并读取含合并单元格的 HTML 表格。
- [`shuttle`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/shuttle) 完全不联网:时刻表直接作为数据写在插件里,小组件每分钟刷新一次。
- [`notices`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/notices) 访问通配域名(`*.pku.edu.cn`)和微信公众号文章。它在 `shared:` 里维护一张来源表,按页面缓存到 KV 并在失败时退回旧数据,用 `untrusted: true` 标记返回第三方文本的工具,还提供一条快捷指令。
App 内置了这些插件文档,并像其他插件一样从各自的 GitHub 链接更新。所以修复其中某个解析器,不需要 App 发版。
## 目录结构
```
classrooms/
PLUGIN.md # 清单(YAML frontmatter)+ 给助手的说明(Markdown)
tools/free.yaml # 每个文件一个工具(也可以直接写在 frontmatter 里)
shared.yaml # 可选:所有工具共用的 constants 和 functions
widgets/overview.json # 看板小组件:生成式界面 spec + 数据绑定
screens/…json # 整页的生成式界面
tests/free.yaml # 供 CLI 和 CI 使用的录制响应;App 从不拉取
README.md, assets/ # 给人和插件市场看;App 会忽略
```
- 一个文件夹放一个插件,一个仓库可以放很多个。在插件市场里,插件用 `owner/repo/path` 标识。
- App 只加载 **frontmatter 里写明的文件**。路径必须是相对路径,不能包含 `..`。由于 App 从不列出文件夹内容,插件放在任何静态托管上都可以:GitHub、gist 或你自己的服务器。
- 文件必须是 UTF-8 文本。App 加载的文件加起来不能超过 **512 KB**。
## 清单
清单就是 `PLUGIN.md` 顶部的 YAML frontmatter。下面是一个简化版 `classrooms` 插件的清单:
```md title="classrooms/PLUGIN.md"
---
spec: 1
id: classrooms
name: 空闲教室
description: 查询各教学楼今天、明天、后天的空闲教室。
version: 1.2.0
author: pkuinfo
icon: location
network: [portal.pku.edu.cn]
tools:
free: tools/free.yaml
widgets:
overview: widgets/overview.json
dataSources:
overview: { tool: free, args: { day: today }, ttl: 30m }
i18n:
en:
name: Free classrooms
description: Free classrooms in each teaching building.
tools: { free: { title: Free classrooms, summary: Find free classrooms in a building } }
---
Use `free` when the user asks where to study or which rooms are free. Periods are 1–12
(1 = 08:00). Answer with the building and room, and the free period ranges.
```
必填字段有六个:`spec: 1`、`id`、`name`、`description`(4 到 200 个字符)、`version`(语义化版本号,如 `1.2.0`)和 `author`。
### 插件声明的范围
有四个字段最重要,因为同学们在安装前会在审核页上看到它们。
`campus` 列出插件可以**带着用户的登录状态**调用的校园服务,名称有 `card`、`its`、`treehole`、`blackboard`、`elective`、`cwfw`、`cwpay`、`bdkj`、`library`、`library-space` 等。这些请求由 App 自己签发,所以插件永远看不到密码、cookie 或 token。
`network` 列出插件可以访问的公开域名,写成 `example.com` 或 `*.example.com` 的形式,只允许 HTTPS。
`abilities` 列出插件用到的[宿主能力](#host-abilities):`verify`、`otp`、`todos`、`timetable`、`scheduler`、`browser`、`campusPage`、`sms`、`files`、`crypto` 和 `cloud`。
`tools` 写明每个工具所在的文件,每个工具再各自声明 `effect`:`read`、`write`、`money` 或 `public`。
### 可选字段
- `shared` 指向一个 YAML 文件,其中的 `constants` 和 `functions` 所有工具都能使用。如果工具自己定义了同名项,以工具自己的为准。
- `dataSources`、`widgets` 和 `screens` 用来添加实时数据和生成式界面。
- `settings` 和 `commands` 用来添加设置项和快捷指令。
- `defaultModes` 设置每种效果的初始授权方式。
- `i18n` 放英文文字,可以翻译名称、简介、说明,以及每个工具的 `title` 和 `summary`(`i18n.en.tools.`)。
- `remote: { mcp, auth }` 用 `PLUGIN.md` 描述一个远程 MCP 服务器。
- `cloud` 声明插件对 PKU harness Cloud 的使用,包括 `cloud.disclosure`;`server` 声明服务端函数([见下文](#server-functions))。
`requires` 不用写,它由 `campus`、`network`、`cloud` 和 `server` 自动推导。每个字段的详细说明,以及插件市场会检查什么,见[清单参考](/docs/marketplace/manifest)。
### 给助手的说明
**Markdown 正文**会作为插件说明发给模型。请写得简短:什么问题用哪个工具,返回的数字分别表示什么。在正文里,工具要写成 `__` 的形式,例如 `classrooms__free`,因为模型看到的就是这个名字。
## 工具
工具(tool)是一份数据,由以下几部分组成:
- `title`:工具的名字,显示给用户,不超过 60 个字符。
- `summary`(可选):给人看的一行说明,不超过 80 个字符,显示在安装审核页和插件市场插件页的工具名下方。英文版写在 `i18n.en.tools..summary`;不写的话,App 不会给使用英文界面的用户显示这一行。
- `description`:只给模型看,从不显示给用户。
- `effect`:`read`、`write`、`money` 或 `public`,必须如实声明。
- `input`:带类型的输入字段。
- `constants` 和 `functions`(可选):这个工具自己用的常量和辅助表达式。
- `steps`:按顺序执行的请求和数据转换。
- `return`:组装结果的表达式。
还有两个标记会改变 App 对待工具的方式。`untrusted: true` 表示工具的结果里含有第三方文本,比如帖子或文章。`userOnly: true` 表示只有用户亲手点击才能运行这个工具,助手不能调用。
```yaml title="tools/free.yaml(节选)"
title: 空闲教室
summary: 查某栋楼哪些教室空着
description: Free rooms in one building (or all) for today, tomorrow or the day after.
effect: read
input:
building: { type: string, optional: true, trim: true, description: 'e.g. 二教, 理教' }
day: { enum: [today, tomorrow, dayAfter], default: today }
constants:
DAY_QUERY: { today: 今天, tomorrow: 明天, dayAfter: 后天 }
steps:
- let: page
request:
url: https://portal.pku.edu.cn/publicQuery/classroomQuery/retrClassRoomFree.do
query: { buildingName: '${input.building ?? "二教"}', time: '${DAY_QUERY[input.day]}' }
headers: { referer: 'https://portal.pku.edu.cn/publicQuery/' }
as: json
- check: page.success
error: 空闲教室查询未成功
return: "page.rows.map(r => ({ room: r.room, seats: num(r.cap, 0) }))"
```
每个**输入字段**的格式是 `{ type, enum?, default?, optional?, description?, min?, max?, pattern?, trim?, items? }`。`type` 取 `string`、`number`、`integer`、`boolean`、`array`、`object` 之一。`trim: true` 会在校验前去掉字符串首尾的空白。App 在任何步骤运行之前就会校验全部输入,所以步骤里可以放心使用它们。
### 步骤
每个步骤只做一件事,可以用 `let: name` 把结果绑定到一个名字上。后面的步骤和 `return` 能使用所有已绑定的名字,以及 `input`、`constants`、`functions` 和 `now`。
| 步骤 | 作用 |
| --- | --- |
| `{ let, value: expr }` | 绑定一个表达式的值。 |
| `{ let, request, as? }` | 发出一个 HTTP 请求。 |
| `{ let, each, concurrency?, request \| steps \| value }` | 对列表中的每一项执行一次。 |
| `{ let, paginate: { request, next, items, max } }` | 逐页重复同一个请求。 |
| `{ let, extract: { from, each, fields }, limit? }` | 从 HTML 中提取条目。 |
| `{ if, then, else? }` | 条件分支。 |
| `{ check, error }` | 条件不成立时带着信息失败。 |
| `{ try, catch }` | 捕获错误;`catch` 里可以使用 `error`。 |
| `{ let?, kv: get \| set \| delete, key, value?, ttl? }` | 读写插件自己的存储。 |
| `{ let, cache: { key, ttl, staleOnError? }, steps }` | 把内部步骤的结果缓存到 KV。 |
| `{ let?, call: , input }` | 调用同一插件的另一个工具。 |
| `{ let?, ability: ., args }` | 调用一项[宿主能力](#host-abilities)。 |
其中几种步骤需要多说几句。
**请求**有两种写法:用 `campus: ` 指定校园服务,再写该服务上的路径;或者写一个属于 `network` 域名的公开 `https` URL。`as` 决定怎样读取回复,可以是 `json`、`text`、`html` 或 `response`。用 `response` 时,无论状态码是多少,都会得到 `{ status, url, headers, text }`;其他几种写法在状态码不是 2xx 时会失败。
**`each`** 遍历的列表最多 50 项,同时运行 1 到 4 个,其中可以使用 `item` 和 `index`。**`paginate`** 在 `next` 不为 null 时重复请求,最多 30 页。
**`extract`** 用 CSS 选择器从 HTML 中选出元素。每个字段的值可以经过管道处理,例如 `text`、`attr href`、`url`、`num`、`date` 和 `match `。
每次运行都有固定的上限:200 个步骤,60 个请求(同时最多 4 个),60 秒,单个响应 2 MB,结果 1 MB,100,000 次表达式运算,每个插件 256 KB 的 KV 存储。插件语言里没有无界循环,没有 `eval`,也没有 JavaScript。
### 表达式
有些字段写的是「表达式」:`value`、`each`、`if`、`check`、`return`、`next`、`items`、`from`,以及 `call` 的 input、`ability` 的 args 和 `functions` 的函数体。表达式只能有一个,语法是 JavaScript 的一个安全子集。可以使用字面量、`.` `?.` `[]`、算术运算、严格的 `==` 和 `!=`、`&& || ?? !`、`a ? b : c`,以及作为参数的箭头函数。不能赋值,不能用 `new`,也访问不到任何全局变量。使用未绑定的名字会报错。
其余的字符串都是「模板」。模板里的 `${expr}` 会被替换成表达式的值。如果整个模板正好就是一个 `${expr}`,值会保留原来的类型,不会变成字符串。
标准库包括列表(`map`、`filter`、`sortBy`、`groupBy`、`sum`、`reduce` 等)、对象、字符串、数字、北京时间的日期、URL、JSON 和 HTML(`html`、`select`、`first`、`tableRows`、`htmlText` 等)。完整列表见规范 §6.2,在 App 里可以查看 `plugins.reference`。
下面几点经常让人出错:
- **正则表达式写成字符串。** 每个正则只编译一次,长度有上限。需要忽略大小写之类的匹配时,在模式开头写内联标志,例如 `'(?i)^yanyuan'`。支持的标志有 `i`、`m`、`s` 和 `u`。
- **字符串转义。** `\\`、`\'`、`\"`、`\n`、`\t` 和 `\r` 的含义和 JavaScript 相同。其他转义**会保留反斜杠**,所以 `'\d+'` 就是正则 `\d+`。这里有意与 JavaScript 不同,目的是让模式经过 YAML 之后保持原样。不过,正则仍然建议写在单引号 YAML 字符串或 `|` 块里。
- **注释。** 表达式里可以使用 `// …` 和 `/* … */`。
- **合并单元格。** `tableRows(node, { header: true })` 为每一行返回一个对象,键是表头文字,rowspan 和 colspan 都已展开。`tableRows(node, { cells: true })` 返回由 `{ text, row, col, rowspan, colspan, id }` 组成的网格行,同一个合并单元格覆盖的所有格子 `id` 相同。
如果表达式含有 `: `,或者以 `{`、`[`、`'`、`"` 开头,就要加引号,或者写成块标量(`return: |`)。不加引号时,YAML 会把它当成映射或列表。遇到这种情况,校验器会指出出错的行。
### 返回结果
返回普通的 JSON,不要返回指令。App 能识别几种结果格式:
- `{ status: 'verification_required', kind, methods, message }` 表示需要用户提供验证码。
- `{ status: 'needs_input', fields, message }` 表示工具还需要用户补充输入。
- `{ items, hasMore, truncated }` 是列表的格式。
## 宿主能力
宿主能力是 App 提供的功能。插件在 `abilities` 里列出对应的分组后,就可以使用它。每个操作的效果是固定的,调用它的工具声明的效果**至少**要达到这一级。例如,`read` 工具不能调用 `todos.add`,因为它是 `write` 操作。不在下表里的名字一律拒绝。
插件不能自己添加宿主能力。新的宿主能力只能加进 App 本身,在那里统一审核一次,之后所有插件都能使用。
| 宿主能力 | 操作(效果) |
| --- | --- |
| `verify` | `treehole { request, code?, via?: sms \| otp, kind?: login \| course }`(read),`treeholeSendCode { kind? }`(write) |
| `otp` | `code`(read):手机令牌当前的验证码,没有则为 null |
| `todos` | `list`(read);`add`、`update`、`setDone`、`remove`(write) |
| `timetable` | `get`(read);`upsert`、`remove`(write) |
| `scheduler` | `list`(read);`create`、`update`、`remove`(write) |
| `browser` | `open { url }`(read):在 App 外打开一个 https URL |
| `campusPage` | `open { service }`(read):以已登录状态打开校园网站 |
| `sms` | `awaitCode`(read):用户允许读取一条短信后,一键填入短信验证码 |
| `files` | `pick { types?, multiple? }`(read):让用户选择文件 |
| `crypto` | `sha256 { text }`(read) |
| `cloud` | `call { fn, input }`(read):调用本插件的某个[服务端函数](#server-functions) |
调用宿主能力的步骤写法如下:
```yaml title="调用宿主能力的步骤"
- let: data
ability: verify.treehole
args: "{ request: { url: '/chapi/api/course/score_v2' }, code: input.code, via: input.method ?? 'sms' }"
```
### 二次验证(`verify`)
树洞有时会先要求额外验证才肯回答:登录时要短信验证码,读取课程数据前要一个令牌。处理这些需要按正确的顺序发好几个请求。`verify.treehole` 把一次树洞请求包装起来,替你完成这些工作,插件就不用自己实现。它的行为如下:
- 如果传了 `code`,它会先提交验证码,再发出请求。`via` 说明验证码是通过 `sms` 收到的,还是来自手机令牌(`otp`)。如果当前没有待处理的验证,`kind` 指明验证码用于哪种验证:`login` 或 `course`。
- 如果需要课程令牌而你没有传验证码,它会自动尝试用已绑定的手机令牌生成验证码。
- 其他情况下,它返回 `{ status: 'verification_required', kind, methods, message, next }`,由助手向用户索要验证码。
- 树洞的回复里没有 `success: true` 就算失败,工具会带着树洞返回的信息报错。
`verify.treeholeSendCode { kind? }` 为当前待处理的验证发送短信。没有待处理的验证时,用 `kind` 指定一种。如果树洞提示上一条验证码还没过期,也算发送成功。
## 服务端函数
SDK 里已经有 `server:` 字段、`{ fn }` 绑定和 `cloud.call` 宿主能力,但插件的服务端函数**目前在官方服务器上没有开放**。请让插件在手机上运行,或者把你自己的后端做成[远程插件](/docs/build/remote-plugin)。
`server:` 部分用同样的步骤和表达式声明函数,由 PKU harness Cloud 服务器运行。服务器有一套自己的能力:服务端 KV 存储,`crypto.sha256`、`hmacSha256` 和 `randomId`,以及使用服务器运营方设置的密钥(secrets)向 `server.network` 里的域名发 HTTPS 请求。服务端函数永远拿不到校园访问权限。
带有 `server:` 的插件必须在 `cloud.disclosure` 里写明哪些数据会离开设备。设计和进展见 [`docs/CLOUD-FUNCTIONS.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/CLOUD-FUNCTIONS.md)(目前暂缓);另见[云服务 → 插件作者指南](/docs/cloud/plugin-guide#server-functions)。
## 安装链接
同学们有四种方式安装插件:在 App 里粘贴链接(设置 → 插件 → 添加)、扫描二维码、在[插件市场](/marketplace)点「在 App 中打开」,或者让助手安装(它会调用 `plugins.install`)。App 接受以下几种链接:
| 形式 | 示例 |
|---|---|
| GitHub 文件夹 | `https://github.com///tree/[/` |
| GitHub 仓库根目录 | `https://github.com//`(PLUGIN.md 位于根目录) |
| GitHub 文件 | `https://github.com///blob/][//PLUGIN.md` |
| 任意指向 `PLUGIN.md` 的 HTTPS URL | gist 的 raw 链接、你自己的服务器 |
| 深链接 | `pkuharness://plugin?src=<上面任意一种链接,经过 URL 编码>` |
安装之前,App 会拉取并校验所有文件,然后显示**审核页**。审核页告诉同学三件事:
- 这是**官方**插件还是**社区**插件。官方插件指 App 内置的插件,或者链接指向 `github.com/pkuinfo/pku-harness-plugins` 的插件,其余都是社区插件。
- 插件能访问什么:**校园服务**(使用用户的登录状态调用,插件看不到密码)、**网站**(`network` 里的域名)和 **App 功能**(宿主能力)。
- 插件能做什么:列出每个工具,以及它的说明和效果。
社区插件的 `write`、`money` 和 `public` 工具,初始设置都是「先询问」。它们的结果作为不可信数据交给 AI,不会被当作指令执行。用户点「安装」之前,什么都不会装上。
## 更新
App 会在启动时(每天最多一次)和用户打开插件页面时,重新检查已安装插件的链接。接下来怎么处理,取决于哪些内容有变化:
- 内容哈希没变:什么都不做。
- 内容变了,但声明没变:直接应用更新,并显示「已更新到 1.3.0」之类的提示。
- 声明范围变大了,比如新增了校园服务、域名或宿主能力,或者效果变强:更新会先暂停,等用户审核同意后再应用。
用户可以关闭自动更新,也可以回滚到上一个版本。对作者来说,发布更新只需要一次 `git push`。
## 测试
```bash
bun run plugin validate path/to/my-plugin # App 会做的全部检查,精确到文件和步骤
bun run plugin test path/to/my-plugin # 回放 tests/*.yaml(录制的响应)
bun run plugin run path/to/my-plugin '{"k":"v"}' # 真实请求,仅限公开域名
```
请在 `pku-harness-plugins` 的本地副本里运行这些命令。校园请求只能在 App 里发出,所以要把它们的响应录进 `tests/*.yaml`(见[快速上手](/docs/build/quickstart#record-a-test))。在插件仓库里,持续集成(CI)会通过 `plugins/documents.test.ts` 回放所有插件文档的测试。
## 发布
把文件夹推送到公开的 GitHub 仓库,然后在[插件市场](/marketplace/submit)提交这个仓库。插件市场会找出所有 `PLUGIN.md`,检查路径和声明,并如实展示同学们将会看到的内容。见[发布](/docs/marketplace/publishing)和[审核规则](/docs/marketplace/review)。
想让插件成为官方插件,就向 `pku-harness-plugins` 提交一个 pull request,把插件文件夹加到 `plugins/` 下,并附上录制好的测试。
# 多语言(i18n) (/docs/build/i18n)
> 内置插件怎样同时提供中文和英文。清单里的文字在 `i18n.en` 里写英文版,页面和小组件的文字放进 `defineStrings` 表,日期交给 SDK 的格式化函数,给模型看的文字不翻译。
App 支持简体中文(`zh-Hans`)和英文(`en`)。中文是源语言:每个字符串先用中文写,再补上英文版。用户在设置里选择「跟随系统」「简体中文」或「English」。
每个内置插件(bundled plugin)都**必须**把两种语言写全。插件仓库里的测试 `plugins/i18n.test.ts` 会逐个检查插件,缺少英文字符串就会失败。本页讲的是内置插件。插件文档的英文文字全部写在清单的 `i18n.en` 部分(见[声明式插件](/docs/build/declarative-plugins#the-manifest))。
## 哪些要翻译
给人看的文字要翻译。只给模型看的文字,比如工具描述和插件说明,只写一份,不翻译。
| 文字 | 位置 | 是否翻译 |
| --- | --- | --- |
| 插件 `name`、`description` | `definePlugin`(中文)+ `i18n.en` | 是 |
| 工具 `title` | `tools..title` + `i18n.en.tools..title` | 是 |
| 工具 `summary`(仅插件文档) | 工具文件里的 `summary` + `i18n.en.tools..summary` | 是 |
| 小组件标题和描述 | `contributes.widgets` + `i18n.en.widgets` | 是 |
| 页面标题 | `contributes.screens` + `i18n.en.screens` | 是 |
| 设置项 | `contributes.settings` + `i18n.en.settings.` | 是 |
| 快捷指令 | `contributes.commands` + `i18n.en.commands.`(`description`、`argHint`、`aliases`、`prompt`) | 是 |
| 数据源标题 | `dataSources` + `i18n.en.dataSources` | 是 |
| 模型服务商名称、提示、模型名 | `providers` + `i18n.en.providers` | 是 |
| 页面和小组件里的文字 | `src/strings.ts`(`defineStrings`) | 是 |
| 工具 `description`、`instructions`、`context`、工具结果 | 给模型看 | **否** |
## 清单的英文版本
中文字符串照常写在 `definePlugin` 里,英文版写在 `i18n.en` 里。下面的例子来自 `shuttle` 插件:
```ts
definePlugin({
id: 'shuttle',
name: '校车',
description: '燕园—新燕园(昌平)通勤班车和新燕园—地铁北安河快速公交时刻表,算出下一班车。',
i18n: {
en: {
name: 'Shuttle bus',
description: 'Timetables for the Yanyuan–Xinyanyuan (Changping) commuter shuttle and the Xinyanyuan–Bei’anhe metro express bus, with the next departure worked out for you.',
tools: { next: { title: 'Next shuttle' }, timetable: { title: 'Shuttle timetable' } },
widgets: { next: { title: 'Next shuttle', description: '…' } },
commands: { shuttle: { description: 'Next shuttle (works offline)', argHint: 'e.g. Yanyuan to Xinyanyuan', aliases: ['bus'] } },
dataSources: { next: { title: 'Next shuttle' } },
},
},
// …
});
```
运行时,`en` 里缺少的字段会回退到中文。但覆盖测试仍会失败,所以请全部填上。
## 字符串表
页面和小组件里的文字放在字符串表里,每个插件一张,位于 `src/strings.ts`。先用类型定义一次所有的键,再为每种语言分别给出值:
```ts title="src/strings.ts"
import { defineStrings } from '@pku-harness/sdk';
type Strings = {
none: string;
later: (list: string) => string;
};
export const strings = defineStrings({
'zh-Hans': { none: '今明两天没有班次', later: (list) => `之后 ${list}` },
en: { none: 'No buses today or tomorrow', later: (list) => `Then ${list}` },
});
```
```tsx
const s = strings[host.useLocale()];
{s.none}
```
在组件里,用 `host.useLocale()` 选出用户所用语言的那张表。
`defineStrings` 会拿 `en` 和 `zh-Hans` 做类型检查:两者的键必须相同,值的类型也必须相同。带参数的字符串写成函数。不要把翻译好的片段拼接成句子,因为两种语言的语序不同。
## 日期和数字
App 运行在 JavaScript 引擎 Hermes 上,而 Hermes 会忽略 `Intl` 的语言设置,`toLocaleDateString` 之类的函数因此会给出错误的格式。请改用 SDK 提供的格式化函数,其中大多数接收来自 `host.useLocale()` 的语言参数 `l`:
| 函数 | zh-Hans | en |
| --- | --- | --- |
| `formatClock(d)` | `21:04` | `21:04` |
| `formatWeekday(d, l)` | `周一` | `Mon` |
| `formatLocalDate(d, l)` | `10月18日` | `Oct 18` |
| `formatLocalDateTime(d, l)` | `10月18日 21:04` | `Oct 18, 21:04` |
| `formatRelative(ms, l)` | `刚刚`, `3 分钟前`, `昨天 21:04` | `Just now`, `3 min ago`, `Yesterday 21:04` |
| `formatDay(d, l)` | `今天`, `明天`, `周三` | `Today`, `Tomorrow`, `Wed` |
| `formatCountdown(ms, l)` | `2小时10分` | `2h 10m` |
| `plural(n, one)` | — | `1 book`, `3 books` |
详见 [SDK → format](/docs/sdk/format)。
## 英文文案规范
覆盖测试还会检查英文字符串的写法:
- 除了括号里的注释和引号内的内容,不能出现中文。`Treehole (树洞)` 是可以的。
- 用弯撇号(`’`),不用直撇号(`'`)。
- 不用感叹号。
- 简短,并使用句首大写(sentence case),即只有第一个单词和专有名词首字母大写。校园专有名词用官方英文名或拼音:Peking University、Treehole (树洞)、Course (教学网 / Blackboard)、Campus Card。
中文文字也一样:写得简短,不用感叹号。
# 开发插件 (/docs/build)
> 怎样在 PLUGIN.md 插件文档、远程插件和内置 TypeScript 插件之间做选择,需要克隆哪些仓库,以及从写第一个工具到上架插件市场的工作顺序。
插件(plugin)负责把 PKU harness 接到某一个校园服务上,或者给 App 增加一项功能。每个插件提供一组「工具」(tool),也就是带类型的小操作,比如「列出空闲教室」「查询校园卡余额」。AI 助手和用户在界面上的点击,调用的都是同一套工具。
所以写插件主要是三件事:弄清楚服务怎样响应请求,把这些请求包装成工具,再在合适的地方加一个小组件或页面。
阅读本章需要你能看懂 YAML 或 TypeScript,并会使用终端。你不需要了解 App 的内部实现。
## 选择插件类型
插件有三种。大多数情况下,你应该写第一种。
**插件文档**是一个包含 `PLUGIN.md` 文件的文件夹。它的工具用 YAML 步骤和安全的小型表达式写成,由 App 在手机上解释执行。你用 `git push` 发布,同学们通过链接安装和更新。大多数校园接入都适合写成插件文档:向校园网站或公开网站发请求,提取需要的数据,必要时带上用户的校园登录状态。建议先看[快速上手](/docs/build/quickstart),再读[声明式插件](/docs/build/declarative-plugins)。你也可以在 App 里[让 AI 帮你写](/docs/build/ai-authoring)。
**远程插件**是你自己运行的服务器。它使用 MCP(Model Context Protocol,模型上下文协议)。MCP 是 AI 应用通过 HTTP 调用工具的一种标准协议,这里使用它的 Streamable HTTP 传输方式。远程插件可以用任何语言编写,部署服务器就等于发布更新,不需要 App 发版。它适合你自己的服务、计算量大的任务,或者需要通用代码的场景。从 [`templates/remote-plugin`](/docs/build/remote-plugin) 开始。
**内置插件**是用 TypeScript、zod 和 React Native 编写的模块,打包在 App 里。它只能随 App 版本发布,所以动手之前请先开一个 issue。内置插件用于 App 自身的功能(看板、日程、定时任务、记忆、模型服务商等),以及插件语言暂时表达不了的接入。从 [`definePlugin`](/docs/build/bundled-plugin) 开始。
| | 插件文档 | 远程插件 | 内置插件 |
| --- | --- | --- | --- |
| 编写方式 | YAML 步骤和表达式 | 任意语言(MCP) | TypeScript |
| 运行位置 | 手机上,由 App 解释执行 | 你的服务器 | 手机上,App 内部 |
| 更新方式 | `git push` | 重新部署服务器 | 随 App 发版 |
官方的校园接入中,classrooms、campus-events、grades、canteen、shuttle 和 notices 已经是插件文档。其余官方接入目前仍是 TypeScript,我们正在逐个迁移。
## 开发流程
### 阅读概念
[插件](/docs/concepts/plugins)、[工具与效果](/docs/concepts/tools-and-effects)、[权限](/docs/concepts/permissions)和[安全](/docs/concepts/security)这四页规定了每个插件都要遵守的规则。审核会按照这些规则检查你的插件,所以请先读它们。
### 弄清协议
你需要知道服务用哪些地址、返回什么内容。如果 [`pkucli`](https://github.com/pkuinfo/pkucli) 已经对接过这个服务,就移植它的代码,并注明参考的文件。具体做法见[从 pkucli 移植](/docs/build/porting-from-pkucli)。
### 编写工具
一个操作写成一个工具。如实声明它的效果,给每个输入加上限制,描述要写给模型看。插件文档的写法见[声明式插件](/docs/build/declarative-plugins),代码插件的写法见[工具与 schema](/docs/build/tools)。
### 添加界面
插件文档可以添加生成式界面的小组件和页面(用 JSON 描述,由 App 绘制),还可以添加设置项和快捷指令。内置插件还能添加原生页面。见[界面扩展](/docs/build/ui)和[多语言](/docs/build/i18n)。
### 测试
每种插件有各自的测试方法。插件文档用 `bun run plugin validate|test` 检查文件、回放录制好的响应;TypeScript 逻辑用 `bun test`;想在没有 API key 的情况下跑通助手的调用流程,可以用模拟模型;远程插件可以拿演示用的远程插件来对照。四种方法都在[测试](/docs/build/testing)里介绍。
### 发布
先分享安装链接,再把插件登记到插件市场。见[发布](/docs/build/publishing),以及插件市场部分的[清单参考](/docs/marketplace/manifest)和[提交流程](/docs/marketplace/publishing)。
## 需要的仓库
从 [`pkuinfo/pku-harness-plugins`](https://github.com/pkuinfo/pku-harness-plugins) 开始。这个仓库包含 SDK 和插件语言、作者命令行工具(`bun run plugin`)、官方插件文档和内置插件、插件市场的注册表,以及远程插件的模板和示例。
[`pkuinfo/pku-harness`](https://github.com/pkuinfo/pku-harness) 是 App 本体,只有在手机或模拟器上运行内置插件时才需要。写插件文档时,在你想实际发出校园请求之前都用不到它。
[`pkuinfo/pkucli`](https://github.com/pkuinfo/pkucli) 是一组访问北大各项服务的 Rust 命令行工具,可以用来查阅各个服务的协议细节。
```bash
mkdir pku-info && cd pku-info
git clone https://github.com/pkuinfo/pku-harness-plugins.git # SDK、插件、注册表、模板
git clone --recurse-submodules https://github.com/pkuinfo/pku-harness.git # 只在 App 里运行内置插件时需要
cd pku-harness-plugins && bun install && bun test
```
SDK 和插件都不发布到 npm,而是直接从 GitHub 仓库使用。请在 `pku-harness-plugins` 的本地副本里开发:它的 bun workspace 和 `tsconfig.json` 会把 `@pku-harness/sdk` 解析到 `packages/sdk/src`。App 也以 git 子模块 `plugins/` 的形式引入同一个仓库。远程插件完全不需要 SDK。
## 基本规则(摘自 CONTRIBUTING.md)
1. **工具就是 API。** 每个工具要有清楚的中文 `title`,给用户看;还要有写给模型的 `description`,说明什么时候用、怎么用。每个输入都要校验:插件文档用 `input` 字段,内置插件用 zod,远程插件用 JSON Schema。
2. **如实声明效果。** 只读工具设置 `effect: read`(插件文档和内置插件)或 `readOnlyHint: true`(MCP)。会修改状态、花钱或公开发布内容的工具,必须声明出来。
3. **绝不索要校园密码。** 账号凭据只保存在用户的手机上。
4. **返回数据,不要返回指令。** 模型会把工具结果当作数据看待。
5. **声明要访问的范围。** 插件文档写 `campus`、`network` 和 `abilities`,`requires` 由它们自动推导;代码插件写 `requires`。不要依赖手机上的 shell 或文件系统。
6. **校园协议从 pkucli 移植**(如果它已经实现),并注明来源。
7. **中英文都要写。** 插件文档要有 `i18n.en` 部分。内置插件要给清单里每个面向用户的字符串提供英文版本,页面和小组件里的文字放在 `src/strings.ts` 表里。
# 从 pkucli 移植 (/docs/build/porting-from-pkucli)
> pkucli 是北大各项服务的协议参考。本页介绍怎样把它 Rust crate 里的接口、登录步骤和各种特殊情况移植到插件里,把登录交给 App,并注明出处文件。
[`pkuinfo/pkucli`](https://github.com/pkuinfo/pkucli) 是一组访问北大网络服务的命令行工具,用 Rust 编写。每个服务最难弄清的部分,它的作者都已经摸索过了:有哪些接口,登录要经过哪几步,哪些请求头和参数必不可少,以及 HTML 的结构。
如果你要写插件的服务 pkucli 已经支持,请直接移植 pkucli 的做法,不必重新摸索,并在注释里注明出处文件。本页介绍在 pkucli 里去哪里找代码,哪些部分放进插件、哪些留在 App 里,并给出一个示例。
大多数服务都通过北大统一身份认证 IAAA(iaaa.pku.edu.cn)登录。通过 IAAA 之后,每个服务通常还要经过一次或几次自己的单点登录(SSO)跳转,我们把每一次这样的跳转称为「登录跳转」。
## 对应关系
| pkucli crate | 服务 | 在 PKU harness 中 |
| --- | --- | --- |
| `common` | IAAA 登录(密码、扫码)、TOTP 验证码、图形验证码、会话、凭据 | App 本身(`src/core/campus`);**不**放在插件里 |
| `campuscard` | 校园卡(bdcard) | `wallet` 插件,`card` 校园服务 |
| `treehole` | 树洞 | `treehole` 插件,`treehole` 服务 |
| `course` | 教学网(Blackboard) | `courses` 插件,`blackboard` 服务 |
| `elective` | 选课 | `elective` 插件,`elective` 服务 |
| `cwfw`、`cwpay` | 财务、财务缴费 | `finance` 插件,`cwfw` / `cwpay` 服务 |
| `bdkj` | 场地预约 | `room-booking` 插件,`bdkj` 服务 |
| `portal` | 门户 | App 内部的门户 SSO 跳转 |
| `claspider` | 课程回放(智云课堂) | 计划中 |
| `info-spider`、`info-auth` | 微信公众号文章 | `notices` 插件可读取单篇文章(正文清洗移植自 `info-spider`) |
TOTP(基于时间的一次性密码)就是身份验证器 App 上显示的六位数字。在一个 crate 里,`api.rs`(或 `client.rs`)存放接口和响应类型,`login.rs` 存放该服务自己的登录跳转,`commands.rs` 存放用户可以执行的操作。
## 代码搬到哪里
pkucli crate 里的每个文件,在 PKU harness 里都有对应的位置。登录部分搬进 App,其余部分搬进插件:
```
pkucli crate PKU harness
───────────────────────────── ──────────────────────────────────────────────
login.rs(IAAA → 服务的 SSO) App:一个校园服务适配器(由 App 维护)
client.rs(请求头、基础 URL) 插件:services.campus.request 的 `url` / `headers`
api.rs(接口、类型) 插件:src/api.ts 加 zod / TS 类型
commands.rs / display.rs 插件:工具(返回数据,而不是排好版的文本)
```
- **登录留在 App 里。** 插件(plugin)从不自己实现 IAAA 或某个服务的 SSO 跳转,所以永远接触不到密码。App 目前能登录的服务有 `card`、`its`、`treehole`、`blackboard`、`bdkj`、`cwfw`、`cwpay`、`elective`、`library` 和 `library-space`。如果你需要的服务不在其中,请去 `pku-harness` 开 issue。新服务需要在 `src/core/campus/adapters/` 里新增一个适配器。
- **请求通过 `services.campus.request(service, req)` 发出。** 路径写成相对于服务域名的路径,cookie 或 token 由 App 加上。
- **公开页面**(不需要登录)可以直接用 `fetch`,并声明 `requires: ['network']`。请把 pkucli 的请求头一起照搬过来,比如桌面浏览器的 User-Agent 和 `accept-language: zh-CN`,因为有些校园服务器会拒绝不带这些请求头的请求。
- **解析成数据。** pkucli 的 `display.rs` 是为终端排版输出的。工具(tool)则返回结构化的 JSON,因为读取它的是模型或页面。
## 示例
下面是 pkucli 里查询校园卡的代码,用 Rust 编写:
```rust title="crates/campuscard/src/api.rs"
/// 查询校园卡信息
pub async fn query_card(&self) -> Result {
let url = format!("{CARD_BASE}/berserker-app/ykt/tsm/queryCard?synAccessSource=h5");
let resp = self.http.get(&url).send().await.context("查询校园卡失败")?;
let api_resp: ApiResp = parse_response(resp).await?;
check_resp(api_resp)
}
```
同一个请求在插件里的写法,用 TypeScript 编写:
```ts title="plugins/shared/campus-card.ts (abridged)"
/**
* 校园卡(bdcard.pku.edu.cn)响应处理,移植自 pkucli
* crates/campuscard/src/api.rs。金额单位是分;每次请求都带 synAccessSource=h5。
*/
import type { HostServices } from '@pku-harness/sdk';
type Services = Pick;
const get = (services: Services, url: string) => services.campus.request('card', { url });
export async function cardAccount(services: Services) {
const data = unwrap<{ card: CardInfo[] }>(await get(services, '/berserker-app/ykt/tsm/queryCard?synAccessSource=h5'));
const account = data.card[0]?.account;
if (!account) throw new Error('校园卡缺少账户信息');
return account;
}
```
顶部的注释写明了出处文件,还记下了两个特殊之处:金额单位是分(0.01 元),每次请求都要带上 `synAccessSource=h5`。
## 二次验证
有些服务会要求额外验证,比如短信验证码。pkucli 在终端里请用户输入验证码,插件做不到这一点。在插件里,服务层面的验证要变成一个工具结果,例如 `{ status: 'verification_required', methods: ['sms'], message }`,再配一个发送或提交验证码的工具。树洞的短信验证(错误码 `40002`)和课程令牌(错误码 `40077`)都是这样处理的。插件的页面还可以调用 `services.sms.awaitCode()`,从短信里读取验证码。
IAAA 层面的二次验证由 App 处理,不归插件管。
## 规则
- **必须**在模块顶部的注释里写明移植自 pkucli 的哪个文件。
- **必须**把登录留在 App 里,插件只发请求。
- **应当**在移植解析器时,一起移植测试,以及用真实响应(已脱敏)做成的 fixture。
- **应当**两边一起修 bug。如果发现了 pkucli 没处理的特殊情况,也去 pkucli 开 issue 或提 pull request。
# 发布 (/docs/build/publishing)
> 远程插件和内置插件怎样送到用户手上。远程插件登记到插件市场的注册表;内置插件合并进 pku-harness-plugins,通过签名的 OTA 更新或下一个 App 版本到达用户手机。
本页介绍写好的插件怎样送到用户手上。远程插件和内置插件的步骤不同。插件文档只需把文件夹推送到 GitHub,见[声明式插件](/docs/build/declarative-plugins#publish)和插件市场的[提交流程](/docs/marketplace/publishing)。
## 远程插件
1. 用 HTTPS 部署你的 MCP 服务器(见[快速上手](/docs/build/remote-plugin#4-deploy))。
2. 确认 MCP 服务器的 `name` 和你要使用的注册表 `id` 一致。
3. 把插件登记到插件市场。一种方式是在仓库里加一份清单,然后在本网站提交(见[清单参考](/docs/marketplace/manifest)和[提交流程](/docs/marketplace/publishing))。另一种方式是向 [`pku-harness-plugins`](https://github.com/pkuinfo/pku-harness-plugins) 提交 pull request,在 `registry/registry.json` 里加一条记录。条目怎样审核、`verified` 标记怎样授予,见[审核规则](/docs/marketplace/review)。
4. 用户在「设置 → 插件 → 插件市场」里安装,也可以手动填写你的地址来添加。
你一部署,更新就生效了。App 下次刷新这个插件时,就会拿到新的工具列表。
工具名要保持稳定。用户的小组件和定时检查按名字引用工具,改名会让它们失效。
## 内置插件
1. 先在 `pku-harness-plugins` 开一个 issue,说明插件做什么,以及为什么必须内置。
2. 提交一个 pull request,包含三部分:`plugins//` 文件夹,`plugins/index.ts` 里 `builtinPlugins` 的条目,以及一条 `"kind": "builtin"` 的注册表记录。
3. 持续集成(CI)和审核者会运行以下检查:
```bash
bun test
bunx tsc --noEmit -p .
bun scripts/validate-registry.ts
```
4. 合并之后,App 会把 `plugins/` 子模块更新到新的提交。改动怎样到达手机,取决于它涉及哪些部分。只改了 JavaScript 的改动,通过签名的空中更新(OTA,over-the-air)推送,App 下载后在下次启动时生效。涉及原生代码的改动,要等下一个 App 版本,也就是新的 APK。
我们不往 npm 发布任何东西。App 和其他开发者都直接从 GitHub 仓库使用 SDK 和插件。
## 改名或合并
用户可能已经在小组件、定时检查或设置里保存了插件 id、工具 id 或小组件的 key。这些 id 不要复用,也不要悄悄改名。请用 `PluginDef.legacy` 把旧的插件 id、工具 id 和小组件 key 映射到新的上面(见 [SDK → plugin](/docs/sdk/plugin#pluginlegacy))。
## 使用 PKU harness Cloud
PKU harness Cloud 是我们为需要服务器的功能提供的托管服务。如果插件声明了 `PluginDef.cloud`,请把其中的 `cloud` 声明原样复制到注册表记录里。这份声明包含 `uses`、`e2e`(存储的数据是否必须端到端加密)、`disclosure`,以及可选的 `backend`。
插件市场会在安装前展示这份声明。`disclosure.zh` 必须用平实的话写清楚哪些数据会离开手机。详见 [Cloud → 插件指南](/docs/cloud/plugin-guide#6-registry-entry)。
# 编写第一个插件 (/docs/build/quickstart)
> 写一个只有一个工具的 PLUGIN.md 插件,用录制的响应测试它,再以链接的形式分享。示例是空闲教室插件的简化版。
本页带你写一个只有一个工具的小型插件文档。这个工具回答一个问题:**现在各教学楼分别有多少间空闲教室**。写好之后,你会离线测试它,并把它作为链接分享出去。
示例是插件仓库里真实插件 [`plugins/classrooms`](https://github.com/pkuinfo/pku-harness-plugins/tree/main/plugins/classrooms) 的简化版。它只调用一个公开的校园接口,所以不需要登录。
开始之前,请装好 [bun](https://bun.sh) 和 git。在最后一步之前,你都不需要 App 或手机。
完整的语言说明见[声明式插件](/docs/build/declarative-plugins),规范原文见 [`PLUGIN-LANGUAGE.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/PLUGIN-LANGUAGE.md)。
### 获取作者 CLI
插件仓库里有一个给插件作者用的命令行工具(CLI)。它做的检查和 App 完全一样,所以你在自己电脑上就能发现错误,不必等到有人安装时才出问题。
```bash
git clone https://github.com/pkuinfo/pku-harness-plugins
cd pku-harness-plugins
bun install
```
命令的格式是:`bun run plugin validate|test|run [tool] [json-input]`。
### 编写 `PLUGIN.md`
一个插件就是一个文件夹,`PLUGIN.md` 是其中的主文件。这个文件分为两部分。
顶部两行 `---` 之间的 YAML frontmatter 是插件的「清单」(manifest)。它写明插件的名字、包含哪些工具、可以访问哪些网站。下面的 Markdown 正文是写给 AI 助手看的,告诉它什么时候、怎样使用你的工具。
```md title="my-classrooms/PLUGIN.md"
---
spec: 1
id: my-classrooms
name: 教室概况
description: 各教学楼现在有多少空闲教室。
version: 0.1.0
author: your-github-name
network: [portal.pku.edu.cn]
tools:
overview: tools/overview.yaml
i18n:
en:
name: Classroom overview
description: How many classrooms are free in each teaching building right now.
tools: { overview: { title: Free rooms by building, summary: Free classrooms in each building } }
---
my-classrooms__overview:各楼现在空闲教室数量。用户想找自习的地方时先用它挑楼。
```
`network` 列出插件可以访问的全部公开域名。访问其他域名的请求都会失败,而同学们在安装前看到的也正是这份列表。`i18n.en` 里放英文文字,使用英文界面的用户会看到它们,而不是中文。
App 只加载 frontmatter 里写明的文件,比如这里的 `tools/overview.yaml`。它从不列出文件夹的内容,所以插件放在任何静态文件服务器上都能用。
### 编写工具
一个工具文件描述一个操作。它包含给人看的字段、给模型看的字段、声明的效果,以及按顺序执行的一组步骤。
```yaml title="my-classrooms/tools/overview.yaml"
title: 各楼空闲教室数
summary: 各教学楼现在的空闲教室数量
description: 'How many classrooms are free right now in each teaching building: building, free, total.'
effect: read
steps:
- let: r
request:
url: https://portal.pku.edu.cn/publicQuery/classroomQuery/retrClassRoomInfos.do
headers: { referer: 'https://portal.pku.edu.cn/publicQuery/', accept: 'application/json' }
as: json
- check: "r.success && isList(r.rows)"
error: 教室概况查询未成功
return: |
{
buildings: r.rows.map(b => ({ building: b.buildingName, free: b.classroomFree, total: b.classroomSum })),
}
```
各字段的作用如下:
- `title` 是工具在 App 里显示的名字,用中文写;英文版写在 `i18n.en.tools.overview.title`。
- `summary` 可以不写。它是一行不超过 80 个字符的说明,告诉用户这个工具做什么,App 会把它显示在安装审核页的工具名下方。英文版写在 `i18n.en.tools.overview.summary`;如果不写英文版,App 就不会给使用英文界面的用户显示这一行说明。
- `description` 只给模型看,从不显示给用户。写清楚工具返回什么、什么时候该用。
- `effect`(效果)取 `read`、`write`、`money` 或 `public`。用户会按效果分别决定助手能否不经询问直接运行工具,所以必须如实声明。
- 第一个步骤发出请求,把解析后的 JSON 绑定到名字 `r` 上。如果响应和预期不符,`check` 步骤会带着错误信息停止。`return` 负责组装结果。
返回数据,不要返回指令。模型会把结果当作数据来读。
如果表达式或 `${…}` 模板里含有 `: `,或者以 `{`、`[` 开头,就要给它加引号。不加引号时,YAML 会把它当成映射或列表,而不是字符串。遇到这种情况,`validate` 会给出提示。
### 录制测试
测试回放事先录好的响应,而不去访问真实网站。所以它可以离线运行,每次结果都一样,校园网页改版后也不受影响。把一段真实响应裁剪到需要的部分,贴进 `body`。如果响应很长,可以存成测试旁边的文件,再用 `file:` 引用。
```yaml title="my-classrooms/tests/overview.yaml"
tool: overview
responses:
- match: retrClassRoomInfos.do
headers: { content-type: application/json }
body: '{"success":true,"rows":[{"buildingName":"一教","classroomFree":16,"classroomSum":32}]}'
expect: { buildings: [{ building: 一教, free: 16, total: 32 }] }
```
测试用 `tool` 指明要运行的工具,还可以给出 `input` 和一个固定的时间 `now`。`responses` 里的每一项,会回应 URL 中包含 `match` 文字的请求,内容来自 `body` 或 `file`。如果是校园请求,还要加上 `campus`,写明服务名。
测试的最后写下面两个字段之一。`expect` 会和工具的结果做**严格**比较;`expectError` 是一个模式,错误信息必须与它匹配。
### 校验、测试、运行
```bash
bun run plugin validate path/to/my-classrooms
bun run plugin test path/to/my-classrooms
bun run plugin run path/to/my-classrooms overview '{}'
```
`validate` 检查清单、工具的 schema、每个表达式和每个文件路径,和 App 以及插件市场做的检查完全一样。`test` 回放 `tests/` 里的文件。`run` 会真实调用工具,但只限公开域名。校园请求必须在 App 里发出,因为只有 App 掌握用户的登录状态,所以要用录制的测试覆盖它们。
### 分享
把文件夹推送到一个公开的 GitHub 仓库。文件夹的 URL 就是安装链接:
```
https://github.com///tree/main/my-classrooms
```
其他同学可以在 App 里粘贴这个链接、扫描它的二维码,或者打开 `pkuharness://plugin?src=`。安装之前,App 会先显示审核页:标明这是社区插件还是官方插件,列出它能访问的网站(这里是 `portal.pku.edu.cn`),并列出每个工具及其说明和效果。
此后每一次 `git push` 都是一次更新。想把插件登记到插件市场,就去[提交仓库](/marketplace/submit)。一个仓库里可以放任意多个插件。见[发布](/docs/marketplace/publishing)。
## 下一步
- 输入、步骤、表达式、宿主能力和错误处理:[声明式插件](/docs/build/declarative-plugins)
- 在 App 里和助手一起起草、校验、试运行插件:[让 AI 帮你写插件](/docs/build/ai-authoring)
- 需要用到同学校园登录状态的请求:声明 `campus`(见规范 §4 和 §9)
- 审核会检查什么:[审核与安全](/docs/marketplace/review)
# 快速上手:远程插件 (/docs/build/remote-plugin)
> 基于 templates/remote-plugin 编写远程插件,也就是一个使用 Streamable HTTP 的 MCP 服务器。在本地运行它,添加到 App,再部署上线并登记到插件市场。
远程插件(remote plugin)是你自己运行的服务器,App 通过网络连接它、调用它的工具。本页带你复制模板、编写一个工具、在电脑上运行服务器、把它添加到 App,最后部署上线并登记到插件市场。
服务器使用 [MCP](https://modelcontextprotocol.io)(Model Context Protocol,模型上下文协议)。MCP 是一项开放标准,让 AI 应用能够发现并调用服务器上的工具。PKU harness 使用 MCP 的 Streamable HTTP 传输方式,每次调用都是一个 HTTP `POST` 请求。
用户可以通过 URL 或插件市场添加远程插件。添加之后,它的工具和内置插件的工具进入同一个注册表,遵守同一套权限,由同一个助手调用。你的代码不会在手机上运行,远程插件也永远拿不到校园账号密码。
你需要 bun 和一些 TypeScript 基础。模板使用 TypeScript 和官方 MCP SDK,不过任何有 MCP 服务端库的语言,做法都一样。
## 1. 从模板开始
```bash
cd pku-harness-plugins
cp -r templates/remote-plugin ../my-plugin
cd ../my-plugin
bun install
```
模板只有一个文件 `server.ts`,外加一个 `package.json`,依赖 `@modelcontextprotocol/sdk`、`express` 和 `zod`:
```ts title="server.ts"
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
function build() {
// name 就是你在注册表里的 id:App 会以 `mcp-` 的名字安装这个插件。
const server = new McpServer(
{ name: 'my-plugin', version: '0.1.0', title: '我的插件' },
{ instructions: 'What this plugin is for, in one sentence.' },
);
server.registerTool(
'hello',
{
title: '打个招呼',
description: 'Return a greeting. Replace with a real capability.',
inputSchema: { name: z.string().describe('who to greet') },
annotations: { readOnlyHint: true },
},
async ({ name }) => ({
content: [{ type: 'text', text: `你好,${name}` }],
structuredContent: { greeting: `你好,${name}` },
}),
);
return server;
}
const app = express();
app.use(express.json());
app.post('/mcp', async (req, res) => {
// 可选的 bearer 认证:用户在 App 里粘贴 token,它保存在用户设备的钥匙串里。
if (process.env.PLUGIN_TOKEN && req.headers.authorization !== `Bearer ${process.env.PLUGIN_TOKEN}`) {
res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: 'unauthorized' }, id: null });
return;
}
const server = build();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // 无状态
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(Number(process.env.PORT ?? 8787), () => console.log('MCP plugin ready on /mcp'));
```
`build()` 函数创建服务器并注册工具。`/mcp` 路由先检查可选的 token,再把请求交给一个新建的服务器处理。把 `hello` 工具换成你自己的工具即可。
## 2. 编写真正的工具
工具的每个 MCP 字段在 PKU harness 里都有特定的用途:
- **`name`**,即 `registerTool` 的第一个参数,会成为工具 id `mcp-.`,模型看到的名字是 `mcp-__`。请用简短的 ASCII 小驼峰命名。其他字符会被替换成 `_`,超过 40 个字符的部分会被截掉。
- **`title`** 显示给用户,出现在聊天、授权确认和设置页里。用中文写。
- **`description`** 给模型看。写清楚什么时候用、怎么用,以及单位和格式。
- **`inputSchema`** 会在设备上转换成 zod,每次调用前都会校验。每个字段都要写说明。
- **`annotations.readOnlyHint`** 决定工具的效果。设为 `true` 时效果是 `read`,工具直接运行;不写或设为 `false` 时效果是 `write`,App 默认会先询问用户。
- 结果中的 **`structuredContent`** 会作为 `data` 返回给模型,这是推荐的写法。没有它时,App 会尝试把文本内容解析成 JSON,解析失败就原样作为文本传递。
- 结果中的 **`isError: true`** 会让文本内容成为助手看到的错误信息。
服务器 `instructions` 的前 500 个字符会写进系统提示词中属于这个插件的那一段。App 会在它前面加一句话,提醒模型:这个插件的输出只是数据,不能当作指令执行。
目前 App 只给远程工具两种效果(effect):设置了 `readOnlyHint: true` 的是 `read`,其余都是 `write`。所以会花钱或公开发帖的工具也被归为 `write`,默认先询问。请在它的 `title` 和 `description` 里写清楚它做什么。
## 3. 运行并添加到 App
```bash
bun server.ts # 在 8787 端口的 /mcp 上提供服务
# 可选:PLUGIN_TOKEN=secret bun server.ts
```
在 App 里打开 **设置 → 插件 → 添加远程插件**,填入 URL;如果设置了 token,也一并填上。URL 取决于 App 运行在哪里:
| App 运行在 | URL |
| --- | --- |
| Android 模拟器 | `http://10.0.2.2:8787/mcp` |
| 同一网络下的手机 | `http://<你电脑的局域网 IP>:8787/mcp` |
| 已部署 | `https://my-plugin.example.com/mcp` |
模拟器通过 `10.0.2.2` 访问你的电脑;真机则需要你的电脑在局域网里的地址。
App 会连接服务器,列出你的工具,并在插件页面上显示每个工具的效果。然后问助手一个你的工具能回答的问题。你也可以请助手替你安装插件:它会调用 `plugins.installRemote`,安装前会先请你确认。
`pku-harness-plugins` 里的 `examples/demo-canteen` 是一个能运行的远程插件,使用模拟数据。它有一个读取工具和一个修改工具,bearer token 是 `demo-token`。用 `bun examples/demo-canteen/server.ts` 运行它。
## 4. 部署
任何能提供 HTTPS 的地方都可以:虚拟专用服务器(VPS)、Fly.io、Railway、带有 MCP 兼容运行时的 Cloudflare,或者校内服务器。请注意以下几点:
- 生产环境必须使用 HTTPS,纯 HTTP 只用于本地开发。
- `POST /mcp` 必须能处理 MCP Streamable HTTP 请求。模板是**无状态**的:每个请求都新建一个服务器。这很适合 serverless 平台,因为这类平台可能为每个请求启动一个新进程。
- 有些校内服务只在北大校园网内可以访问。如果你要调用这类服务,你的服务器也必须运行在校园网里。App 无法把用户的校园登录状态借给你的服务器。
- 你自己的密钥(比如 API key)留在服务器上。如果要保护接口,请使用 bearer token,并在注册表条目里写上 `"auth": "bearer"`。
## 5. 登记到插件市场
登记之后,插件会出现在插件市场里,同学们就能找到它。登记有两种方式。一种是在你的仓库里加一份清单,再通过本站的插件市场提交(见[清单参考](/docs/marketplace/manifest)和[提交插件](/docs/marketplace/publishing))。另一种是在 `pku-harness-plugins` 的 `registry/registry.json` 里加一条记录。无论哪种方式,`id` 都必须和 MCP 服务器的 `name` 一致:
```json
{
"id": "my-plugin",
"name": "我的插件",
"description": "一句话说明它做什么。",
"author": "your-github-name",
"source": "https://github.com/you/my-plugin",
"kind": "remote",
"endpoint": "https://my-plugin.example.com/mcp",
"auth": "none",
"requires": ["network"],
"scopes": ["example.com"],
"tags": ["canteen"]
}
```
向注册表提交 pull request 之前,请先运行 `bun scripts/validate-registry.ts`。另见[发布](/docs/build/publishing)和[审核规则](/docs/marketplace/review)。
## 检查清单
- [ ] MCP 服务器的 `name` 和注册表 `id` 相同(`^[a-z0-9][a-z0-9-]{1,40}$`)。
- [ ] 每个工具都有中文 `title`、写给模型的 `description`,每个输入字段都有说明。
- [ ] 只读工具设置了 `annotations.readOnlyHint: true`,其他工具都没有设置。
- [ ] 返回的是数据(`structuredContent`),不含指令,体积适中。
- [ ] 插件从不索要校园账号密码。
- [ ] 接口使用 HTTPS;如果用了 bearer 认证,已在注册表中声明。
# 测试 (/docs/build/testing)
> 怎样测试各种插件。插件文档回放录制的响应,解析器和纯逻辑用 bun test 写单元测试,用按脚本应答的模拟模型完整跑通助手的流程,远程插件用 demo-canteen 示例来试。
大多数校园插件分三层:一次 HTTP 请求、一个读取响应的解析器,以及一个整理结果的工具(tool)。请求依赖真实的校园服务器,所以测试时用录制好的响应代替它。解析和整理都是普通代码,可以在你的电脑上测试。之后,再在 App 里把整个插件跑一遍。
本页先介绍插件文档,然后依次介绍 TypeScript 插件、模拟模型、演示用的远程插件和真机测试。除非另有说明,所有命令都在插件仓库里运行。
## 插件文档(PLUGIN.md)
`PLUGIN.md` 插件用插件仓库里的作者命令行工具(CLI)测试。它会回放插件 `tests/*.yaml` 里录制的响应:
```bash
bun run plugin validate plugins/classrooms # App 会做的检查
bun run plugin test plugins/classrooms # 回放 tests/*.yaml
bun run plugin run plugins/classrooms overview '{}' # 真实请求,仅限公开域名
```
`validate` 执行的就是 App 安装插件时做的那套检查。`test` 回放 `tests/*.yaml`。`run` 会发出真实请求,而且只能访问公开域名。
`bun test` 包含 `plugins/documents.test.ts`。它加载每个 `plugins//PLUGIN.md`,回放其中所有录制好的测试。持续集成(CI)会在每次推送到 `main` 和每个 pull request 上运行 `bun test`,所以只要某个插件文档或它录制的响应(fixture)出了问题,CI 就会失败。测试文件的格式见[快速上手](/docs/build/quickstart#record-a-test)。本页其余部分讲的是 TypeScript 插件。
## 单元测试(bun test)
在插件仓库里运行:
```bash
bun test # 工作区里所有的 *.test.ts
bun test plugins/focus # 只测一个插件
bunx tsc --noEmit -p . # 对 SDK 和插件做类型检查
```
`focus` 是[内置插件](/docs/build/bundled-plugin)一页里的示例插件,请换成你自己插件的目录。
下面几个习惯能让测试真正有用:
- 测试和代码放在一起:`src/parse.test.ts` 紧挨着 `src/parse.ts`。
- **解析器。** 把真实响应存成 fixture(`src/fixtures/*.html|json`),替换掉其中的个人信息,然后断言解析结果。校园网页时常改版,改版之后,fixture 能准确地告诉你哪里出了问题。
- **时间。** 把 `now` 作为参数传进去,不要在逻辑里直接读取 `Date.now()`,这样测试就能固定时间。SDK 的重复规则、课表和格式化函数都接受 `now` 参数。
- **React Native。** 导入了 `react-native` 的模块,不做 mock 的话 bun 无法加载。要么把逻辑放在普通模块里,要么像 `plugins/i18n.test.ts` 那样 mock 掉 `react-native`:
```ts
import { mock } from 'bun:test';
mock.module('react-native', () => ({
StyleSheet: { create: (s: unknown) => s },
Platform: { OS: 'android', select: (o: Record) => o.android ?? o.default },
View: () => null,
}));
```
### 用假的上下文测试工具
工具的 `execute` 函数接收输入和一个上下文对象 `ctx`。测试时,你可以自己构造一个简单的假上下文。下面的假上下文带有一个内存里的 KV 存储,测试可以先开始一次专注,再把它读出来:
```ts title="plugins/focus/src/index.test.ts"
import { expect, mock, test } from 'bun:test';
import type { KV, ToolContext } from '@pku-harness/sdk';
// index.ts 引入了小组件,小组件又引入了 react-native:加载插件之前先 mock 它。
mock.module('react-native', () => ({ View: () => null }));
const { focusPlugin } = await import('./index');
function memoryKV(): KV {
const m = new Map();
return {
get: async (k: string) => m.get(k) as T | undefined,
set: async (k, v) => void m.set(k, v),
delete: async (k) => void m.delete(k),
};
}
test('start, then today shows the running session', async () => {
const ctx = { pluginId: 'focus', actor: 'user', kv: memoryKV(), services: {} } as unknown as ToolContext;
await focusPlugin.tools.start.execute({ label: '高数' }, ctx);
const today = (await focusPlugin.tools.today.execute({}, ctx)) as { running: { label: string } | null };
expect(today.running?.label).toBe('高数');
});
```
由于 `index.ts` 引入了小组件,小组件又引入了 `react-native`,所以测试要先 mock 它,再加载插件。
`execute` 本身不校验输入,App 会在调用它之前完成校验。如果校验逻辑很重要,请用 `tool.input.safeParse(...)` 单独测试。
## 模拟模型
助手按循环工作:把对话发给模型,运行模型要求调用的工具,再把结果发回给模型。想在没有 API key、也没有真实模型的情况下测试这个循环,可以用 `examples/mock-llm`。它是一个小服务器,接口和 OpenAI 的 Chat Completions API 相同,按固定的脚本应答:
```bash
bun examples/mock-llm/server.ts # 地址 http://localhost:8788/v1,模型名 "mock-agent"
```
在 App 里打开「设置 → AI 服务 → OpenAI 兼容」。Base URL 填 `http://10.0.2.2:8788/v1`,模拟器通过这个地址访问你的电脑;模型填 `mock-agent`。模拟模型会根据你消息里的关键词挑选一个工具来调用,再总结工具的结果:
| 消息里包含 | 调用 |
| --- | --- |
| `提醒` / `remind` | `scheduler__create`(每 3 天 09:00,共 16 次) |
| `马上` / `soon` | `scheduler__create`(一次性,2 分钟后) |
| `看板` / `dash` | `dashboard__upsertWidget`(一个绑定 `timetable.today` 的小组件) |
| `表单` / `form` | `ui__render`(一个充值表单) |
| `remember` | `memory__remember` |
| `食堂` / `canteen` | `mcp-demo-canteen__reserveSeat`(需要先装好演示插件) |
| `who` | 原样回显注入的 `` 块 |
想让它调用你自己的工具,就在本地扩展它的 `plan()` 函数,写上这些工具的模型名。
## 演示用远程插件
```bash
bun examples/demo-canteen/server.ts # 地址 :8787/mcp,bearer token 为 demo-token
```
`examples/demo-canteen` 是一个使用模拟数据的远程插件,在 8787 端口的 `/mcp` 上提供服务,bearer token 是 `demo-token`。在「设置 → 插件 → 添加远程插件」里填入 `http://10.0.2.2:8787/mcp` 和 token `demo-token`。它有一个读取工具(`menu`)和一个修改工具(`reserveSeat`),你可以借此看到远程修改类工具弹出的确认提示。
## 在真机上
- 开发版能识别几个特殊的深链接(deep link),可以直接运行一个工具或发起一轮对话:`pkuharness://dev?tool=.&input=` 和 `pkuharness://dev?chat=`。加上 `&actor=agent` 就以助手的身份运行工具,从而看到「先询问」的确认提示。
- 页面和小组件要在深色、浅色两种主题下,以及中英两种语言下各检查一遍。
- 真实的校园数据需要真实账号。读取类工具先在自己的账号上测试,再请别人帮忙;含有个人信息的响应绝不要提交。
# 工具与 schema (/docs/build/tools)
> 怎样设计模型好用的工具。内容包括命名、描述、用 zod(内置插件)或 JSON Schema(MCP)定义输入、如实声明效果、只返回数据,以及处理用户可以补救的状态。
工具(tool)是插件的 API。模型调用它,你自己的页面也调用它。好的工具很小,只做一件事;它的描述写清楚什么时候该用,返回的数据模型拿来就能用。
本页介绍怎样用代码编写工具,包括内置插件(zod)和远程插件(JSON Schema)。插件文档里的工具写法见[声明式插件](/docs/build/declarative-plugins#tools),本页关于描述、效果和返回结果的建议同样适用。工具背后的概念见[工具与效果](/docs/concepts/tools-and-effects)。
## 命名
一个工具有几个名字,分别给不同的读者看:
| 名字 | 规则 | 示例 |
| --- | --- | --- |
| 工具名 | ASCII 小驼峰的动词或名词,插件内唯一 | `cardBalance`、`search`、`reserveSeat` |
| 工具 id | `.`(自动生成) | `wallet.cardBalance` |
| 模型名 | `__`(自动生成) | `wallet__cardBalance` |
| `title` | 给用户看的简短中文名称 | `校园卡余额` |
App 每次请求都会把所有启用的工具发给模型,所以相似的工具越多,每次请求消耗的 token 就越多。相关的读取操作应该合并成一个带筛选参数的工具,不要写十个几乎一样的工具。
## 给用户看的文字
用户会在聊天、授权确认和设置页里看到工具的 `title`。`title` 用中文写,英文版写在 `i18n.en.tools..title`。
插件文档还可以给工具写一个 `summary`:一行不超过 80 个字符的说明,用平实的话讲清楚工具做什么。安装审核页和插件市场的插件页会把它显示在工具名下方。英文版写在 `i18n.en.tools..summary`;缺少英文版时,App 不会给使用英文界面的用户显示这一行。内置插件没有 `summary` 字段。
下一节介绍的 `description` 从不显示给用户。
## 描述
`description` 是写给模型看的,中文或英文都可以。它应该写清楚三件事:
- **什么时候**用这个工具,用同学们自己会说的话来写。例如:「Use when the user asks which canteen is least crowded」。
- **返回什么**:有哪些字段,单位是什么(元、分钟、北京时间),数据有多新。
- **限制**和**后续步骤**:分页大小、必须遵守的调用顺序(「call `treehole__sendCode` first」),以及遇到 `verification_required` 时该怎么办。
不要在描述里重复输入 schema。每个字段的说明请用 `.describe()` 写,因为模型会在字段旁边看到这些说明。
如果描述文字取决于设备,`description` 可以写成一个接收宿主服务的函数:
```ts
description: (services) => `Show a native UI in the chat… Components:\n${services.genui.describeCatalog()}`,
```
## 输入
内置插件使用 zod 4。模型看到的是由 zod schema 生成的 JSON Schema。无论调用来自助手、页面还是定时任务,App 都会在 `execute` 运行之前校验输入,所以 `execute` 可以放心使用它。
```ts
input: z.object({
from: z.string().trim().max(20).optional().describe('boarding place, e.g. 燕园 / 新燕园'),
at: z.string().optional().describe('Beijing time "YYYY-MM-DD HH:mm", default now'),
count: z.number().int().min(1).max(10).optional().describe('departures per direction, default 3'),
}),
```
- 每个数字和字符串都要设上下限(`.min`、`.max`),默认值写在描述里。
- 取值范围固定的集合用枚举,例如 `z.enum(['weekday', 'holiday'])`。
- 需要模型填写时间时,让它写本地时间字符串 `"YYYY-MM-DD HH:mm"`。SDK 的 `scheduleInputSchema` 和 `parseLocal` 在处理日程时已经采用这种格式。
- 远程插件:设备会用 `z.fromJSONSchema` 把 MCP 的 `inputSchema`(即 JSON Schema)转换成 zod。请只用 JSON Schema 的基础特性,以保证转换能成功。
## 效果
每个工具只能有一种效果(effect)。效果告诉 App 这个工具做的是哪一类事情,用户会按效果分别决定助手能否不经询问直接执行(见[权限](/docs/concepts/permissions))。
```ts
effect: 'read', // 读取:余额、列表、搜索
effect: 'write', // 修改:添加待办、关注帖子、预约房间
effect: 'money', // 花钱:创建支付订单、充值、把校园卡切换为电子卡
effect: 'public', // 公开:在树洞发帖或回复
```
有些工具还要加上另外两个标记。返回第三方文本(比如帖子或通知)的工具要设置 `untrusted: true`。调用这样的工具后,助手这一轮剩下的操作都会被标记为「受污染」(tainted),这一轮里会长期保存的写入要等用户确认(见[安全](/docs/concepts/security))。只能由用户发起的操作,比如批准或解锁某样东西,要设置 `userOnly: true`。这样工具对模型不可见,来自助手或定时任务的调用都会被拒绝。很少用到的工具可以设置 `onDemand: true`:对话开始时不提供给模型,助手用 `plugins.use` 加载插件后才提供,这样每次请求都更小。
## 返回结果
```ts
execute: async ({ name, sort = 'least' }, ctx) => {
const { time, canteens } = await fetchCrowd(ctx.signal);
return { time, canteens: canteens.filter((c) => !name || c.name.includes(name)), source: CROWD_SOURCE };
},
```
结果会交回给模型,所以要按模型方便阅读的方式组织:
- 返回 JSON 对象,字段名和单位要一看就懂。
- 需要时注明数据来源和时效(`source`、`time`)。
- 返回模型可以传给下一个工具的 id。
- 把中止信号 `ctx.signal` 传给网络请求,这样调用被取消时请求能尽快停止。
- 失败时抛出 `Error`,附上用户能看懂的简短中文信息,例如 `throw new Error('校园卡登录已失效')`。助手会把它转告给用户。
有些情况是预料之中的,需要用户介入,比如短信验证。这时请返回一个状态对象,而不是抛出异常。助手就能向用户索要所需的信息,再重新调用工具:
```ts
export type VerificationRequired = { status: 'verification_required'; kind: 'login'; methods: ['sms']; message: string };
// 在 execute 里:
if (needsSms) {
return {
status: 'verification_required',
kind: 'login',
methods: ['sms'],
message: '树洞需要短信验证。先调用 treehole__sendCode 发送验证码,再带上 code 重试。',
} satisfies VerificationRequired;
}
```
## 上下文与说明
插件可以给模型提供两种额外的文字。
`instructions` 写在系统提示词里,每次请求都会发送。它适合写简短的规则:什么问题用哪个工具,以及「以预约系统为准」之类的注意事项。
`context({ query, kv, services })` 在每轮对话之前运行,返回和当前消息相关的事实,比如用户记忆的快照。它的结果会被标记为数据,函数必须在 800 ms 内返回。没有相关内容时返回 `undefined`。
两者每次请求都会消耗 token,也都会改变提示词的开头部分,降低模型服务商复用缓存的机会。所以要写得短,而且内容要稳定。不要写入日期和时间,App 每轮都会自动加上。
## 在界面里调用工具
页面和小组件也会调用工具。`invoke` 以用户身份运行工具,`host.useToolData` 通过 App 的共享缓存读取工具结果:
```ts
// 在页面或小组件里(actor: 'user',不弹授权确认)
await invoke('focus.start', { label: '高数' });
// 通过宿主的共享缓存实时读取
const data = host.useToolData<{ minutes: number }>('focus.today', {}, { ttlMinutes: 1 });
```
## 检查清单
- [ ] 每个工具只负责一件事,读取和修改分成不同的工具。
- [ ] 每个工具都有中文 `title`、`i18n.en.tools` 里的英文标题,以及写给模型的 `description`。
- [ ] 每个输入字段都有范围限制和说明。
- [ ] `effect` 如实声明;需要 `untrusted` 或 `userOnly` 的地方都设置了。
- [ ] 返回的是数据:体积小,带单位,不含密钥,也不含指令。
- [ ] 错误信息简短、用户能看懂;可以补救的状态用状态对象表示。
# 界面扩展 (/docs/build/ui)
> 内置插件怎样通过 `contributes` 添加页面、设置项、标签页内容、看板小组件和快捷指令,并用 App 传入的 HostUI 组件搭建界面。
本页介绍内置插件怎样给 App 添加自己的界面。阅读前请先看[内置插件](/docs/build/bundled-plugin)。插件文档的小组件和页面用生成式界面的 JSON 描述,写法见[声明式插件](/docs/build/declarative-plugins)。
内置插件添加原生界面时,不需要导入任何 App 代码。每个页面和小组件都会通过 props 收到一个名为 `HostUI` 的对象,里面有 App 的组件、主题 token、触感反馈、导航和数据 hook。插件的界面全部用这些东西绘制,所以看起来和 App 的其他部分一致,App 改版后也照样能用。
插件添加的所有界面,都列在 `definePlugin` 的 `contributes` 里:
```ts
contributes: {
screens: { main: { title: '专注', component: FocusScreen } },
settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
widgets: { today: { title: '今日专注', description: '…', sizes: ['sm', 'md'], component: FocusWidget } },
commands: { focus: { description: '打开专注', aliases: ['专注'], run: (_i, { navigate }) => navigate('/p/focus/main') } },
tabs: [{ slot: 'schedule', screen: 'main' }], // 只有负责某个标签页的插件才需要
},
```
## 页面
```ts
interface ScreenContribution {
title: string;
component: ComponentType;
presentation?: 'push' | 'modal'; // modal → /m//(表单);默认 push → /p//
}
```
页面是一个 React 组件。默认以普通页面(`push`)的方式打开,表单请设置 `presentation: 'modal'`。组件会收到 `PluginScreenProps`,其中有 `host`、`services`、`kv`、`invoke(toolId, input?)`、`params`(路由的查询参数)和 `cloud?`。
```tsx
import type { PluginScreenProps } from '@pku-harness/sdk';
import { useState } from 'react';
export function EditScreen({ host, params, invoke }: PluginScreenProps) {
const { SheetScreen, Field } = host.components;
const [text, setText] = useState(params.text ?? '');
return (
{ await invoke('memory.update', { id: params.id, text }); host.dismiss(); }}>
);
}
```
打开页面用 `host.navigate('/p//')`,打开弹出页用 `host.navigate('/m//?id=…')`,关闭弹出页用 `host.dismiss()`。普通页面以 `host.components.Screen` 为根组件,弹出的表单页以 `SheetScreen` 为根组件。
## 设置项
```ts
interface SettingsContribution { screen: string; title: string; subtitle?: string; icon?: string }
```
设置项是通往你某个页面的入口,点开后进入它指定的页面。除了这些设置项,「设置 → 插件」里的插件详情页还会列出插件的页面、小组件、工具和权限。
## 小组件
```ts
interface WidgetContribution {
title: string;
description: string; // 写给助手看:显示什么内容,config 起什么作用
sizes?: ('sm' | 'md' | 'lg')[];
defaultSize?: 'sm' | 'md' | 'lg';
configSchema?: z.ZodType>;
component: ComponentType; // host, services, kv, invoke, size, config, cloud?
}
```
小组件是看板上的一张卡片。编写时请注意以下几点:
- 卡片的外框由看板绘制,包括标题、背景、数据更新时间和长按菜单。你只需要绘制内容,并根据 `size` 调整布局:`sm` 是半宽的小卡片,`md` 占满一行,`lg` 是大号主卡片。
- 用 `host.useToolData(tool, args, { ttlMinutes })` 加载数据(见[看板](/docs/concepts/dashboard#refresh-owned-by-the-host))。刷新时机由 App 决定,所以不要轮询。
- 处理好三种状态。`status` 为 `'loading'` 时显示骨架屏;没有数据时显示错误文字,并让用户可以通过 `data.refresh()` 重试;还要处理未登录的情况。
- 小组件的 `config` 由助手或用户选择,并用 `configSchema` 校验。每个字段用 `.describe()` 写说明,并在 `description` 里提到这些字段(「config.names limits it to certain canteens」),这样助手才知道有哪些配置。
- 每个小组件都放在一个错误边界(error boundary)里。即使它崩溃,看板也只在它的位置显示一条提示,其他小组件照常工作。
## 快捷指令
```ts
type CommandContribution = {
description: string; // 在 / 菜单里显示的一行说明
aliases?: string[]; // 别名,例如中文名:['余额']
argHint?: string; // 输入框占位提示;写了它,表示指令接受输入
} & (
| { prompt: string } // 作为用户消息发送;{{input}} 会被替换
| { run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise | string | void }
);
```
快捷指令是聊天输入框 `/` 菜单里的一项。指令名只能用小写字母、数字和连字符(`^[a-z][a-z0-9-]*$`)。如果两个插件用了同一个名字,App 会给它们加上插件 id 作为前缀,变成 `/plugin:name`。
指令有两种。`prompt` 指令把一段文字发给助手,就像用户自己输入的一样。`run` 指令在设备上运行,它返回的字符串作为提示显示在聊天里,不会发给模型。
```ts
commands: {
canteen: { description: '现在去哪个食堂', aliases: ['食堂', '吃饭'], argHint: '想吃什么 / 在哪(可空)', prompt: '现在哪些食堂还开着、人少?帮我挑一个{{input}}' },
shuttle: { description: '下一班校车(本地计算)', aliases: ['校车'], argHint: '如 燕园到新燕园', run: (input, { locale }) => quickAnswer(input, Date.now(), locale) },
},
```
## 标签页
App 有两个标签页位置:`dashboard` 和 `schedule`,分别由提供它们的已启用插件填充,目前是 `dashboard` 插件和 `schedule` 插件。对话和设置两个标签页属于 App 本身。只有在你要替换这两个插件之一时,才需要提供标签页。
## HostUI 要点
下面是 `host` 里最常用的成员:
| 成员 | 用途 |
| --- | --- |
| `host.components` | `Screen`、`SheetScreen`、`Text`、`Button`、`IconButton`、`ListGroup`、`ListRow`、`Chip`、`Surface`、`Glass`、`Stat`、`Ring`、`Sparkline`、`Field`、`DateTimeField`、`Switch`、`Checkbox`、`Segmented`、`Stepper`、`WeekdayPicker`、`SortableList`、`EmptyState`、`Skeleton`、`FadeIn`、`Icon`、`QRCode`、`GenUI`、`PluginWidget`、`Row`、`Column`、`Pressable`、`Hourglass` |
| `host.useTheme()` | 主题 token:`colors`、`spacing`、`radius`、`typography`、`glass`、`courseColor(i)`、`contentWidth` |
| `host.useToolData`、`useWidgetData`、`useBindings`、`refreshAll` | 使用共享缓存的实时数据 |
| `host.useLocale()` | `'zh-Hans'` 或 `'en'` |
| `host.navigate`、`back`、`dismiss`、`ask(text)` | 导航;`ask` 会切换到对话并发送一条消息 |
| `host.haptics` | `tap`、`select`、`success`、`warning`(遵循用户设置) |
| `host.useNow(ms)`、`formatAgo(ms)` | 每隔 `ms` 更新一次的当前时间;相对时间 |
| `host.useSecureDisplay({ brightness })` | 挂载期间禁止截屏(用于付款码) |
| `host.useStoreVersion()` | 定时任务、待办、小组件或课表变化时重新渲染 |
| `host.clipboard?`、`host.shareLink?` | 可选,使用前先判断是否存在 |
完整签名见 [SDK → ui](/docs/sdk/ui)。
## 设计规则
- **必须**使用 `host.components` 和主题 token,不要写死颜色、字号或间距。
- **必须**把所有面向用户的文字放进 `strings.ts`,用 `host.useLocale()` 选择语言。
- **必须**使用宿主图标集里的图标名:`card`、`wifi`、`book`、`bell`、`clock`、`location`、`sparkles`、`key`、`person`、`qr`、`warning`、`check`、`calendar`、`bus`、`food`、`wallet`、`library`、`room`、`school`、`ticket`、`medal`、`chart`、`search`、`globe`、`lock` 等(完整列表见 App 的 `src/components/icon.tsx`)。
- **应当**给可点击的自定义内容加上 `accessibilityLabel`。
- 文案:中文要简短,不用感叹号;英文只有句首字母大写(sentence case),撇号用弯撇号(’)。
# 通道(所有者) (/docs/cloud/api/channels)
> 设备开启的端到端加密中继通道,用来和打开分享链接的人实时共享。所有者通过 WebSocket 与对端通信,服务器只转发信封。
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/channels
**Open a channel** (`createChannel`)
Opens a relay channel owned by the calling device and plugin. The client generates the share
secret `k`, derives `authKey = HKDF(k, "pku-harness-cloud/v1/join")` and
`encKey = HKDF(k, "pku-harness-cloud/v1/enc")`, and sends only `joinHash = base64url(sha256(authKey))`,
the channel's X25519 public key and `meta` sealed with `encKey`. The server can gate joins
but can't read anything. Limits: 5 open channels per device, TTL 60 s – 24 h (default 15 min).
Auth: `pluginToken`
Request body (`application/json`): `CreateChannelRequest`
- **200**: The channel id and expiry. → `application/json` `CreateChannelResponse`
- **default**: see [Error](#response-error) below
## PATCH /v1/channels/{id}
**Extend a channel** (`extendChannel`)
Sets the expiry to now + `ttlS`, capped at `limits.channels.maxTtlS`. Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `PatchChannelRequest`
- **200**: The new expiry. → `application/json` `PatchChannelResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}
**Close a channel** (`closeChannel`)
Closes the channel; connected peers receive `{ t: "closed", reason: "owner" }`. Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Closed. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## GET /v1/channels/{id}/ws
**Owner WebSocket** (`ownerSocket`)
Upgrades to a WebSocket for the channel owner. Every frame is one JSON object.
- Server → owner: `OwnerInFrame` (`hello`, `join`, `leave`, `peer` presence, `msg`, `closed`, `error`, `pong`).
- Owner → server: `OwnerOutFrame` (`msg` to one peer, `kick` a peer and free its seat, `ping`).
Frames carry envelopes sealed with the per-peer key
`HKDF(X25519(chanPriv, peerPub) ‖ encKey, salt = chanPub ‖ peerPub, info = "pku-harness-cloud/v1/peer")`.
Limits: frames ≤ 4 KB of ciphertext, ≤ 60 frames per minute per party (the owner gets
60 × maxPeers); a violating frame is dropped with an `error` frame. A frame to an offline
party is dropped with `error: conflict` (use the mailbox for offline delivery).
The token may also be passed as `?t=` (WebSocket clients can't always set headers). Close
codes: `4003` kicked, `4009` replaced by a newer connection of the same party, `4010` channel
closed or expired (a `closed` frame comes first), `1001` server shutdown (reconnect).
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **101**: Switching to the WebSocket protocol. Frames are described by the `OwnerInFrame` and `OwnerOutFrame` schemas.
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}/mailbox
**Clear the mailbox** (`clearMailbox`)
Removes every mailbox slot of the channel (`Channel.mailbox.clear()` in the SDK). Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Cleared. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## DELETE /v1/channels/{id}/mailbox/{slot}
**Delete a mailbox slot** (`deleteMailboxSlot`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `slot` | path | `MailboxSlot` | yes | |
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## PUT /v1/channels/{id}/mailbox/{slot}
**Put a sealed blob in a mailbox slot** (`putMailboxSlot`)
Batch mode. Sealed blobs that peers can read while the owner is offline (at most `limits.channels.mailboxSlots` slots). Owner only.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `slot` | path | `MailboxSlot` | yes | |
Request body (`application/json`): `MailboxPutRequest`
- **200**: Stored. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### CloseReason
```ts
type CloseReason = "owner" | "expired" | "revoked" | "locked" | "server";
```
### CreateChannelRequest
```ts
type CreateChannelRequest = {
ttlS: number; // minimum 1
maxPeers?: number; // minimum 1, default 1
joinHash: Key32;
meta: Envelope;
chanPub: Key32;
page?: PageName;
};
```
### CreateChannelResponse
```ts
type CreateChannelResponse = {
id: ChannelId;
expiresAt: Ms;
};
```
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### MailboxPutRequest
```ts
type MailboxPutRequest = {
env: Envelope;
ttlS: number; // minimum 1
};
```
### MailboxSlot
pattern `^[A-Za-z0-9._:-]{1,64}$`
```ts
type MailboxSlot = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### OwnerInFrame
Server → owner WebSocket frame.
```ts
type OwnerInFrame = {
t: "hello";
id: ChannelId;
expiresAt: Ms;
peers: {
peerId: PeerId;
peerPub: Key32;
joinedAt: Ms;
online: boolean;
}[];
} | {
t: "join";
peerId: PeerId;
peerPub: Key32;
at: Ms;
} | {
t: "leave";
peerId: PeerId;
at: Ms;
} | {
t: "peer";
peerId: PeerId;
online: boolean;
at: Ms;
} | {
t: "msg";
peerId: PeerId;
env: Envelope;
} | {
t: "closed";
reason: CloseReason;
} | {
t: "error";
code: WireErrorCode;
message: string;
} | {
t: "pong";
at: Ms;
};
```
### OwnerOutFrame
Owner → server WebSocket frame. `kick` drops a peer and frees its seat (重置查看者).
```ts
type OwnerOutFrame = {
t: "msg";
to: PeerId;
env: Envelope;
} | {
t: "kick";
peerId: PeerId;
} | {
t: "ping";
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PatchChannelRequest
```ts
type PatchChannelRequest = {
ttlS: number; // minimum 1
};
```
### PatchChannelResponse
```ts
type PatchChannelResponse = {
expiresAt: Ms;
};
```
### PeerId
pattern `^[A-Za-z0-9_-]{4,64}$`
```ts
type PeerId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 设备与鉴权 (/docs/cloud/api/devices)
> 设备的云端身份只有一把 Ed25519 签名密钥和一把 X25519 加密密钥,没有学号,也没有姓名。注册需要完成一次工作量证明;插件 token 由签名质询换取,有效期 10 分钟。
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/devices
**Register a device** (`registerDevice`)
Registers a device identity. Authenticated by proof of work instead of a token: find a
decimal `nonce` such that `SHA-256(prefix ‖ UTF-8(nonce))` starts with at least
`pow.difficulty` zero bits (from `GET /v1/meta`), where
```
prefix = UTF-8("pku-harness-cloud/v1/pow\n" + salt + "\n" + signPub + "\n" + boxPub + "\n")
```
The server accepts its current salt and the previous one. Binding the keys into the prefix
means one solution registers exactly one identity. Clients refuse a difficulty above 28
(`MAX_POW_DIFFICULTY`); the default is 16 (about one second on a phone). Use `solvePow` from
`@pku-harness/cloud-protocol`.
Auth: none
Request body (`application/json`): `RegisterDeviceRequest`
- **200**: The server-generated device id. → `application/json` `RegisterDeviceResponse`
- **default**: see [Error](#response-error) below
## POST /v1/auth/token
**Mint a plugin token** (`createToken`)
Exchanges an Ed25519-signed challenge for a 10-minute bearer token bound to one device, one
plugin and a set of services (claims `sub = deviceId`, `plg = plugin`, `svc = services`).
The signed bytes are these lines joined by `\n` (no trailing newline):
```
pku-harness-cloud/v1/auth
provider origin, lowercase scheme://host[:port], no path
deduplicated, sorted, joined by ","
Unix ms, decimal
16 random bytes, base64url
```
The server compares `aud` with its own origin, accepts `|now − ts| ≤ 5 min` and rejects a
reused nonce within that window. Use `signAuthChallenge` from
`@pku-harness/cloud-protocol`.
Auth: none
Request body (`application/json`): `AuthTokenRequest`
- **200**: A bearer token for this plugin's requests. → `application/json` `AuthTokenResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/devices/me
**Delete this device and all its data** (`deleteDevice`)
Wipes every record of the device (store, channels, push schedules). Any plugin token of the device works. The app calls it from Settings → 删除云端数据 and before a full reset.
Auth: `pluginToken`
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### AuthTokenRequest
```ts
type AuthTokenRequest = {
aud: string; // pattern `^https?:\/\/[a-z0-9.\-:[\]]+$`
deviceId: DeviceId;
plugin: PluginId;
services: CloudService[]; // minItems 1
ts: Ms;
nonce: Nonce16;
sig: Sig64;
};
```
### AuthTokenResponse
```ts
type AuthTokenResponse = {
token: string; // minLength 16
expiresAt: Ms;
};
```
### CloudService
```ts
type CloudService = "store" | "channels" | "push" | "files" | "functions";
```
### DeviceId
Server-generated, base64url. pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type DeviceId = string;
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce16
16 bytes, base64url. pattern `^[A-Za-z0-9_-]{22}$`
```ts
type Nonce16 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### RegisterDeviceRequest
```ts
type RegisterDeviceRequest = {
signPub: Key32;
boxPub: Key32;
pow: {
salt: string; // pattern `^[A-Za-z0-9_-]{1,64}$`
nonce: string; // pattern `^(0|[1-9][0-9]{0,14})$`
};
};
```
### RegisterDeviceResponse
```ts
type RegisterDeviceResponse = {
deviceId: DeviceId;
};
```
### Sig64
64-byte Ed25519 signature, base64url. pattern `^[A-Za-z0-9_-]{86}$`
```ts
type Sig64 = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 服务端函数 (/docs/cloud/api/functions)
> 插件声明的服务端函数。参考服务器已经实现,但生产配置默认关闭(`CLOUD_FN_POLICY=allowlist`,列表为空),官方服务器也不会开放;自建服务器可以自行开启。
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/fn/{plugin}/{name}
**Invoke a server function (planned)** (`invokeFunction`)
**Planned (stage 6).** The reference server answers `501 unsupported` until then. Runs a function the plugin declared in `CloudManifest.functions`, on
the user's provider. A function is `name@version`; the server keeps the previous major for
90 days. Default limit: 60 calls per minute.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `plugin` | path | `PluginId` | yes | |
| `name` | path | `FunctionName` | yes | |
Request body (`application/json`): `FunctionRequest`
- **200**: The function output. → `application/json` `FunctionResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### FunctionName
pattern `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`
```ts
type FunctionName = string;
```
### FunctionRequest
```ts
type FunctionRequest = {
input?: unknown;
version?: number; // minimum 1
};
```
### FunctionResponse
```ts
type FunctionResponse = {
output?: unknown;
};
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 元数据 (/docs/cloud/api/meta)
> 服务商发现。用户连接服务商时,宿主会读取它并检查 `protocol`、`services` 和 `limits`。
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/meta
**Provider metadata** (`getMeta`)
Protocol version, offered services, limits, proof-of-work parameters, viewer pages, server functions and the operator. No authentication.
Auth: none
- **200**: Provider metadata. → `application/json` `MetaResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### CloudService
```ts
type CloudService = "store" | "channels" | "push" | "files" | "functions";
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### FunctionName
pattern `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`
```ts
type FunctionName = string;
```
### Limits
Defaults (`DEFAULT_LIMITS`): store 1,000,000 bytes / 1,000 keys / 64,000 bytes per value; channels 5 open, TTL 60–86,400 s (default 900), 5 peers, 4,096-byte frames, 60 frames/min, 64 mailbox slots; push 100 pending; functions 60 calls/min; 20 joins per IP per minute.
```ts
type Limits = {
store: {
maxBytes: number;
maxKeys: number;
maxValueBytes: number;
};
channels: {
maxOpen: number;
minTtlS: number;
maxTtlS: number;
defaultTtlS: number;
maxPeers: number;
frameBytes: number;
framesPerMin: number;
mailboxSlots: number;
};
push?: {
maxPending: number;
};
functions?: {
callsPerMin: number;
};
joinsPerIpPerMin: number;
};
```
### MetaResponse
```ts
type MetaResponse = {
protocol: 1;
services: CloudService[];
limits: Limits;
pow: {
difficulty: number; // minimum 0, maximum 64
salt: string; // pattern `^[A-Za-z0-9_-]{1,64}$`
};
pages: {
plugin: PluginId;
page: PageName;
}[];
functions: {
plugin: PluginId;
name: FunctionName;
version: number; // minimum 1
}[];
operator: {
name: string; // minLength 1
contact: string;
privacyURL?: string;
};
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 分享链接与页面 (/docs/cloud/api/pages)
> 给没装 App 的人用的公开跳转和静态查看页。
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /s/{id}
**Open a share link** (`shareRedirect`)
Redirects to the viewer page the channel was opened with, `/w//?c=`. The browser keeps the `#k=` fragment across the redirect; the server never sees it.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **302**: Redirect to the plugin's viewer page.
- **default**: see [Error](#response-error) below
## GET /w/{plugin}/{page}
**Static viewer page** (`viewerPage`)
A static page built from `plugins//web` and listed in `CloudManifest.pages`, served with a strict CSP (`connect-src 'self'`). It reads `#k` from `location.hash`, decrypts locally with `@pku-harness/cloud-protocol` and talks to `/v1/c/{id}/…`.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `plugin` | path | `PluginId` | yes | |
| `page` | path | `PageName` | yes | |
- **200**: The page. → `text/html` `string`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### PageName
pattern `^[a-z0-9][a-z0-9-]{0,40}$`
```ts
type PageName = string;
```
### PluginId
Registry id rule, plus `mcp-…` host ids for remote plugins. pattern `^[a-z0-9][a-z0-9-]{1,63}$`
```ts
type PluginId = string;
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 通道(对端) (/docs/cloud/api/peers)
> 查看方(网页或另一个 App)打开分享链接 `https:///s/#k=` 后调用的接口。片段 `k` 不会发到服务器,查看方用它推导出 `authKey` 来加入通道。
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/c/{id}
**Public channel info** (`getChannelInfo`)
What a viewer reads before joining. `meta` is sealed with `encKey`, so only someone holding the link can read it. Public and rate-limited (20 requests per IP per minute by default).
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Channel status, sealed meta and the channel public key. → `application/json` `ChannelInfoResponse`
- **default**: see [Error](#response-error) below
## POST /v1/c/{id}/join
**Join a channel** (`joinChannel`)
Claims a seat. The server checks `sha256(authKey)` against the channel's `joinHash` and
enforces `maxPeers`: when the channel is full the answer is `409 conflict`, so a forwarded
link is useless once the first viewer claimed it. `peerPub` is the viewer's ephemeral X25519
key.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `JoinRequest`
- **200**: The peer id and a peer token for the socket, SSE stream, send and mailbox endpoints. → `application/json` `JoinResponse`
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/ws
**Peer WebSocket** (`peerSocket`)
Upgrades to a WebSocket for a joined peer.
- Server → peer: `PeerInFrame` (`hello`, `owner` online state, `extended`, `msg`, `closed`, `error`, `pong`).
- Peer → server: `PeerOutFrame` (`msg`, `ping`).
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `t` | query | `string` | yes | The `peerToken` from `POST /v1/c/{id}/join`. minLength 16 |
- **101**: Switching to the WebSocket protocol. Frames are described by the `PeerInFrame` and `PeerOutFrame` schemas.
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/events
**Peer event stream (SSE)** (`peerEvents`)
Fallback for webviews without WebSocket (WeChat). Each `data:` line is one `PeerInFrame` as JSON. Send with `POST /v1/c/{id}/send`.
Auth: none
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
| `t` | query | `string` | yes | The `peerToken` from `POST /v1/c/{id}/join`. minLength 16 |
- **200**: Server-sent events. → `text/event-stream` `PeerInFrame`
- **default**: see [Error](#response-error) below
## POST /v1/c/{id}/send
**Send a frame (SSE clients)** (`peerSend`)
The body is one `PeerOutFrame`, as it would be sent on the WebSocket.
Auth: `peerToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
Request body (`application/json`): `PeerOutFrame`
- **200**: Accepted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## GET /v1/c/{id}/mailbox
**Read the mailbox** (`peerMailbox`)
Batch mode. The sealed blobs the owner left in the channel's mailbox.
Auth: `peerToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `id` | path | `ChannelId` | yes | |
- **200**: Mailbox slots. → `application/json` `MailboxListResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ChannelId
Server-generated, base64url (16 random bytes → 22 characters). pattern `^[A-Za-z0-9_-]{8,64}$`
```ts
type ChannelId = string;
```
### ChannelInfoResponse
```ts
type ChannelInfoResponse = {
status: ChannelStatus;
meta: Envelope;
chanPub: Key32;
expiresAt: Ms;
ownerOnline: boolean;
};
```
### ChannelStatus
```ts
type ChannelStatus = "open" | "closed" | "expired";
```
### CloseReason
```ts
type CloseReason = "owner" | "expired" | "revoked" | "locked" | "server";
```
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### JoinRequest
```ts
type JoinRequest = {
authKey: Key32;
peerPub: Key32;
};
```
### JoinResponse
```ts
type JoinResponse = {
peerId: PeerId;
peerToken: string; // minLength 16
expiresAt: Ms;
};
```
### Key32
32 bytes, base64url (Ed25519 or X25519 public key, hash). pattern `^[A-Za-z0-9_-]{43}$`
```ts
type Key32 = string;
```
### MailboxListResponse
```ts
type MailboxListResponse = {
items: {
slot: MailboxSlot;
env: Envelope;
expiresAt: Ms;
}[];
};
```
### MailboxSlot
pattern `^[A-Za-z0-9._:-]{1,64}$`
```ts
type MailboxSlot = string;
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PeerId
pattern `^[A-Za-z0-9_-]{4,64}$`
```ts
type PeerId = string;
```
### PeerInFrame
Server → peer frame (WebSocket message or SSE `data:` line).
```ts
type PeerInFrame = {
t: "hello";
peerId: PeerId;
ownerOnline: boolean;
expiresAt: Ms;
} | {
t: "owner";
online: boolean;
} | {
t: "extended";
expiresAt: Ms;
} | {
t: "msg";
env: Envelope;
} | {
t: "closed";
reason: CloseReason | "kicked";
} | {
t: "error";
code: WireErrorCode;
message: string;
} | {
t: "pong";
at: Ms;
};
```
### PeerOutFrame
Peer → server frame (WebSocket message, or the body of `POST /v1/c/{id}/send`).
```ts
type PeerOutFrame = {
t: "msg";
env: Envelope;
} | {
t: "ping";
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 推送(规划中) (/docs/cloud/api/push)
> 推送 token 和定时推送,载荷端到端加密。尚未实现,参考服务器对这些接口返回 `501 unsupported`。
Base URL: `https://{provider}` or `http://localhost:8787`
## POST /v1/push/tokens
**Register a push token (planned)** (`registerPushToken`)
**Planned (stage 6);** `501 unsupported` until then. UnifiedPush (ntfy) on Android, APNs on iOS, FCM optional.
Auth: `pluginToken`
Request body (`application/json`): `PushTokenRequest`
- **200**: Registered. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## POST /v1/push/schedule
**Schedule a push (planned)** (`schedulePush`)
**Planned (stage 6);** `501 unsupported` until then. The payload is an envelope: the server learns the time only. At most 100 pending schedules.
Auth: `pluginToken`
Request body (`application/json`): `PushScheduleRequest`
- **200**: The schedule id. → `application/json` `PushScheduleResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/push/schedule
**Cancel a scheduled push (planned)** (`cancelPush`)
**Planned (stage 6);** `501 unsupported` until then.
Auth: `pluginToken`
Request body (`application/json`): `PushCancelRequest`
- **200**: Cancelled. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### Envelope
XChaCha20-Poly1305 sealed JSON. `n` = 24-byte random nonce, `c` = ciphertext ‖ 16-byte tag. Open it with `open()` from `@pku-harness/cloud-protocol` and validate the result.
```ts
type Envelope = {
v: 1;
n: Nonce24;
c: string; // pattern `^[A-Za-z0-9_-]*$`, minLength 22
};
```
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Nonce24
24 bytes, base64url. pattern `^[A-Za-z0-9_-]{32}$`
```ts
type Nonce24 = string;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### PushCancelRequest
```ts
type PushCancelRequest = {
id: string; // minLength 1
};
```
### PushScheduleRequest
```ts
type PushScheduleRequest = {
at: Ms;
payload: Envelope;
};
```
### PushScheduleResponse
```ts
type PushScheduleResponse = {
id: string; // minLength 1
};
```
### PushTokenRequest
```ts
type PushTokenRequest = {
kind: "unifiedpush" | "apns" | "fcm";
token: string; // minLength 1, maxLength 2048
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# 存储 (/docs/cloud/api/store)
> 键值存储,命名空间 `(deviceId, plugin)` 取自 token,而不是路径。插件声明了 `e2e` 时,值都是信封。
Base URL: `https://{provider}` or `http://localhost:8787`
## GET /v1/store
**List keys** (`listStore`)
Keys of the calling `(device, plugin)` namespace, optionally filtered by prefix. Values are not included.
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `prefix` | query | `string` | no | Only keys starting with this string. |
- **200**: Matching keys. → `application/json` `StoreListResponse`
- **default**: see [Error](#response-error) below
## GET /v1/store/{key}
**Read a value** (`getStoreValue`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
- **200**: The value with its version. → `application/json` `StoreGetResponse`
- **default**: see [Error](#response-error) below
## DELETE /v1/store/{key}
**Delete a value** (`deleteStoreValue`)
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
- **200**: Deleted. → `application/json` `Ok`
- **default**: see [Error](#response-error) below
## PUT /v1/store/{key}
**Write a value** (`putStoreValue`)
Creates or replaces a value. Optimistic concurrency with `If-Match: `: the write
fails with `409 conflict` unless the stored version matches; `If-Match: 0` means the key must
not exist yet. Default quotas: 1 MB and 1,000 keys per plugin per device, 64 KB per value
(`limits.store` in `GET /v1/meta`).
Auth: `pluginToken`
| Parameter | In | Type | Required | Notes |
| --- | --- | --- | --- | --- |
| `key` | path | `StoreKey` | yes | Path-like key (`/` allowed, each segment URL-encoded), no `.` or `..` segments. |
| `If-Match` | header | `string` | no | Expected current version; `0` = the key must not exist. pattern `^[0-9]+$` |
Request body (`application/json`): `StorePutRequest`
- **200**: The new version (also sent as the `ETag` header). → `application/json` `StorePutResponse`
- **default**: see [Error](#response-error) below
## Response: Error
Any error. `error.code` maps to HTTP statuses as usual (`bad_request` 400, `unauthorized`
401, `forbidden` 403, `not_found` 404, `conflict` 409, `gone` 410, `payload_too_large` 413,
`rate_limited` 429). A 429 carries `Retry-After` and `RateLimit-*` headers; the SDK turns it
into `CloudError('rate_limited', retryAfterS)`.
Body: `application/json` `ErrorBody`
## Schemas
### ErrorBody
```ts
type ErrorBody = {
error: {
code: WireErrorCode;
message: string;
retryAfterS?: number; // minimum 0
};
};
```
### Ms
Unix time in milliseconds. minimum 0
```ts
type Ms = number;
```
### Ok
```ts
type Ok = {
ok: true;
};
```
### StoreGetResponse
```ts
type StoreGetResponse = {
key: StoreKey;
value?: unknown;
version: number; // minimum 1
updatedAt: Ms;
expiresAt?: Ms;
};
```
### StoreKey
Path-like key; no `.` or `..` segments. pattern `^[A-Za-z0-9._:/-]{1,256}$`
```ts
type StoreKey = string;
```
### StoreListResponse
```ts
type StoreListResponse = {
items: {
key: StoreKey;
version: number; // minimum 1
updatedAt: Ms;
}[];
cursor?: string;
};
```
### StorePutRequest
```ts
type StorePutRequest = {
value?: unknown;
ttlS?: number; // minimum 1
};
```
### StorePutResponse
```ts
type StorePutResponse = {
version: number; // minimum 1
};
```
### WireErrorCode
`unauthorized`: missing or expired token, bad signature. `forbidden`: the token lacks the
service, not the owner, wrong `authKey`. `gone`: channel closed or expired. `conflict`:
`If-Match` mismatch, or the channel is full (`maxPeers`). `unsupported`: service not
offered by this provider.
```ts
type WireErrorCode = "bad_request" | "unauthorized" | "forbidden" | "not_found" | "gone" | "conflict" | "payload_too_large" | "rate_limited" | "quota" | "pow_invalid" | "unsupported" | "server";
```
# PKU harness Cloud (/docs/cloud)
> PKU harness Cloud 是一套可选的插件后端标准,包含存储、中继通道、推送、文件和服务端函数。官方实例、自建服务器或任何符合标准的服务器都可以提供这些服务。数据默认端到端加密,云端不保存校园身份。
大多数插件只在手机上运行,但有些功能需要服务器。例如,把校园卡付款码实时分享给一位没有安装 App 的朋友,或者在几台设备之间同步一小份数据。PKU harness Cloud(下文简称「云服务」)是为这类功能制定的一套后端标准。它规定了服务器要提供哪些接口,以及 App 和插件如何使用这些接口。
设计云服务时,我们的目标是让你在使用服务器的同时不必信任它。下面几段说明这一点是怎样做到的。
## 设计原则
数据在离开手机之前就已经加密。这种方式叫作端到端加密(end-to-end encryption,E2E):只有发送方和接收方持有密钥,服务器只负责转发和保存密文,自己无法解密。
云端不保存你的校园身份。服务器只通过设备生成的一对密钥来识别设备,不知道你的学号和姓名,也收不到你的校园账号密码或登录会话。
同一份协议可以由多家服务商提供。你可以使用官方实例、自己部署的参考服务器,或者任何实现了这份协议的服务器地址。插件调用的接口完全相同,也无法分辨当前连接的是哪一家。
App 会把各个插件隔离开。App 是运行插件的一方,我们称它为「宿主」。宿主给每个插件一个只属于它自己的云服务客户端,每个插件有独立的 token、独立的存储空间,并且要单独获得你的授权。
AI 助手只能通过插件提供的工具使用云服务。它看不到云服务的凭据和 token,也看不到分享链接。
官方云服务实例还没有部署。在它上线之前,如果要使用云服务,请在「设置 → 云服务」里填写自建服务器的 URL。
已经完成的部分有:协议 v1(`@pku-harness/cloud-protocol`)、SDK 类型、参考服务器(`packages/cloud-server`),以及 App 内的云服务客户端。客户端支持设备身份、按插件绑定和授权、端到端加密的存储与通道,并在「设置 → 云服务」里提供连接测试。第一个使用云服务的插件是 `card-share`,它实时分享校园卡付款码,全程端到端加密。推送服务(`push`)还没有完成,参考服务器目前对它返回 `501 unsupported`。
官方实例上线后会刻意保持精简,只提供三样东西:实时分享用的中继(通道和信箱,服务器只转发看不懂的密文),小容量的端到端加密存储,以及静态查看页。插件的[服务端函数](#server-functions)已经在参考服务器里实现,但官方服务器不开启这项功能。
## 三个组成部分
云服务由三部分组成。第一部分是服务定义,也就是 SDK 里的类型和一份带版本号的 HTTP 协议。第二部分是服务商,即真正运行服务器的一方。第三部分是使用方,也就是调用云服务的插件。
| 部分 | 内容 | 位置 |
| --- | --- | --- |
| 服务定义 | SDK 类型(`CloudManifest`、`PluginCloud`)和 HTTP 协议(`/v1`) | `@pku-harness/sdk`(`cloud.ts`)、`@pku-harness/cloud-protocol`、[API 参考](/docs/cloud/api/meta) |
| 服务商 | 官方实例、开源参考服务器(自建)、任何符合标准的 URL | [服务商与自建](/docs/cloud/providers) |
| 使用方 | 插件,通过 `ctx.cloud` 或 `props.cloud` 访问 | [插件指南](/docs/cloud/plugin-guide) |
## 云服务提供的服务
协议定义了五种服务。服务商可以只提供其中一部分。
`store` 是键值存储。每台设备上的每个插件各有一块独立的空间,按 `(device, plugin)` 划分。它支持版本号(`If-Match`)和过期时间(time to live,TTL)。如果插件声明了 `e2e`,值会先在手机上加密再上传。
`channels` 是实时分享用的中继通道。设备开一个通道,查看者用分享链接加入,服务器在两者之间转发数据帧。通道始终端到端加密,每个查看者的密钥都由分享链接里的密钥派生。
`push` 用于推送 token 和定时推送。这项服务还在计划中,官方实例不会提供。推送内容会加密,服务器只知道发送时间。
`files` 是文件存储,目前也在计划中。
`functions` 是插件在 `PLUGIN.md` 的 `server:` 段里声明的服务端函数,[下文](#server-functions)有详细说明。官方服务器不开启这项功能。服务端函数必须事先声明并经你同意,它也是整个云服务里唯一可能处理明文的地方。
App 通过 `GET /v1/meta` 查询服务商提供哪些服务。如果插件在 `uses` 里列出的某项服务对方不提供,App 会把这个插件标为不可用,并说明原因。
## 一次请求的流程
下面以插件写入一条存储为例。插件调用 `ctx.cloud.store.put`,宿主先检查授权、插件清单里的 `uses` 和 `e2e`。如果插件声明了 `e2e`,宿主用 XChaCha20-Poly1305 算法加密这个值。然后宿主用设备的 Ed25519 密钥签名一段挑战,换取一个有效期 10 分钟的 token,附在请求上发给服务商。服务商从 token 里读出设备和插件,确定存储空间,最后把密文保存下来。
```
plugin ──ctx.cloud.store.put(key, value)──► host(为这个插件绑定的客户端)
│ 检查授权、清单里的 `uses` 和 `e2e`
│ 声明了 e2e 时加密值(XChaCha20-Poly1305)
│ 签发或刷新 10 分钟有效的 token(Ed25519 签名挑战)
▼
provider PUT /v1/store/ Authorization: Bearer
│ 存储空间 = token 里的 (deviceId, plugin)
▼
保存密文
```
## 服务端代码在哪里运行
插件需要服务器参与时,有三种做法。我们建议按下面的顺序考虑。
1. 标准服务,包括 store、channels、mailbox、push 和 files。每个符合标准的服务器都会实现它们,服务器上不运行任何插件代码。能用这一层解决问题时,请优先使用它。
2. 插件服务端函数,写在 `PLUGIN.md` 的 `server:` 段里。服务器用和 App 相同的解释器执行它们。官方服务器不开启这项功能。
3. 外部后端,即任何实现了这份协议的 URL。插件要在清单里声明它(`backend: { kind: 'external', url }`),并经你同意后才能使用。
## 服务端函数
请让插件在手机端完成工作,或者使用你自己的后端。设计文档 [`docs/CLOUD-FUNCTIONS.md`](https://github.com/pkuinfo/pku-harness/blob/master/docs/CLOUD-FUNCTIONS.md) 已暂停推进:已有的功能保留,但集合、定时任务、推送、群组和 webhook 都不再开发。
插件文档可以声明服务端函数。服务端函数使用的步骤和表达式与插件工具相同,只是在服务器上执行。下面是一个例子:
```yaml title="PLUGIN.md(frontmatter 节选)"
cloud:
disclosure: { zh: ['你输入的名字会发到云端服务器。'], en: ['The name you enter is sent to the cloud server.'] }
server:
network: [api.example.org] # 服务端步骤可以访问的主机(HTTPS)
secrets: [API_KEY] # 由运营者保管,只能在请求模板里使用
functions:
stats:
effect: read
access: public # 目前支持 user(默认)和 public;peer、group、webhook 尚不支持
cache: 30s
steps:
- let: c
request: { url: 'https://api.example.org/v1/count', headers: { authorization: 'Bearer ${secrets.API_KEY}' } }
as: json
return: "{ count: c.count }"
```
使用服务端函数时要注意以下几点:
- 写了 `server:` 段,就等于声明了 `cloud.uses: [functions]`,并且必须同时写 `cloud.disclosure`。
- 服务端步骤有自己的一组宿主能力:按用户隔离的服务端键值存储(`kv.get/set/delete`),以及 `crypto.sha256`、`crypto.hmacSha256`(按名字引用密钥)和 `crypto.randomId`。服务端步骤访问不了校园服务。`caller.id` 是设备在这个插件下的假名。
- 手机端通过 `cloud.call { fn, input }` 能力调用函数。小组件也可以绑定 `{ fn }`,而不只是 `{ tool }`。
- token 里记录了你同意时的插件文档版本(链接加内容哈希),服务器只运行这个版本。是否运行还取决于运营者的策略 `CLOUD_FN_POLICY`,可选值为 `verified`、`allowlist` 和 `open`。相关环境变量见参考服务器的 README。
## 继续阅读
- [插件指南](/docs/cloud/plugin-guide):声明 `cloud`、获取授权、使用 `store` 和 `channels`。
- [安全](/docs/cloud/security):设备密钥、工作量证明、token、信封和分享链接。
- [服务商与自建](/docs/cloud/providers):选择服务商,以及运行参考服务器。
- [`@pku-harness/cloud-protocol`](/docs/cloud/protocol-package):服务器和查看页共用的 schema 与加密实现。
- [API 参考](/docs/cloud/api/meta):全部接口,由 OpenAPI 3.1 规范生成。
# 插件指南 (/docs/cloud/plugin-guide)
> 在内置插件里使用 PKU harness Cloud:声明云服务清单和数据说明,处理授权与可用性,使用存储和中继通道,并安全地展示分享链接。
本页说明内置插件(用 TypeScript 编写、随 App 打包的插件)怎样使用云服务。我们以 `card-share` 插件为例,它把校园卡付款码实时分享给朋友。下面的步骤按开发顺序排列:先在清单里声明,再处理授权和可用性,然后使用存储或通道。
## 1. 在清单里声明云服务
插件要在 `definePlugin` 的 `cloud` 字段里说明自己用到哪些云服务、数据是否端到端加密,以及哪些数据会离开设备。宿主(运行插件的 App)会按这份声明限制插件的调用,并把其中的说明展示给你。
```ts
import { definePlugin } from '@pku-harness/sdk';
export const cardSharePlugin = definePlugin({
id: 'card-share',
name: '付款码共享',
description: '把校园卡付款码实时分享给朋友,端到端加密。',
version: '0.1.0',
requires: ['campus-session', 'network', 'cloud'],
cloud: {
protocol: 1,
uses: ['channels'],
e2e: true,
pages: ['view'],
disclosure: {
zh: ['分享期间,加密后的付款码经云服务转发给你分享的人;服务器无法解密', '服务器能看到:分享的开始/结束时间、查看者的 IP'],
en: ['While sharing, encrypted payment codes are relayed to the person you shared with; the server can’t decrypt them', 'The server sees when a share starts and ends, and the viewer’s IP address'],
},
},
defaultModes: { read: 'auto', write: 'auto', money: 'ask' },
scopes: ['bdcard.pku.edu.cn'],
tools: { /* … */ },
});
```
各字段的规则如下:
| 字段 | 规则 |
| --- | --- |
| `protocol` | 固定为 `1` |
| `uses` | 只列出你实际调用的服务。调用未列出的服务会被宿主拒绝。 |
| `disclosure.zh` | `uses` 不为空时必填,说明见下文。 |
| `e2e` | 设为 `true` 后,宿主会拒绝明文写入存储。只要可行,就应该开启。 |
| `optional` | 插件不依赖云服务也能工作时设为 `true`。 |
| `pages` | 给没装 App 的人使用的静态查看页,源码在 `plugins//web`,部署在 `/w//`。 |
| `functions`、`backend` | 服务端函数,或外部后端 URL。外部后端会在你授权前展示出来。 |
`disclosure` 是写给用户看的数据说明。请用通俗的话写清楚哪些数据会离开设备、服务器能看到什么,每行只写一件事。如果同时写了 `en`,英文必须与中文逐行对应。
如果设置了 `optional: true`,就不要在 `requires` 里写 `'cloud'`,并在使用前检查 `ctx.cloud?.available`。服务端函数在官方服务器上不开启,见[下文](#server-functions)。
## 2. 获取用户授权
每个插件都要单独获得用户授权。在设置里打开插件的详情页,可以看到一个**云服务**区块。它显示服务商的主机名、你写的数据说明、端到端加密标识、用到的服务和配额用量,并提供**允许使用云服务**开关和**清除此插件云端数据**按钮。
用户允许之前,插件的所有云服务调用都会抛出 `CloudError('consent_required')`。在页面里,你可以先调用 `cloud.requestConsent()` 询问用户。它会弹出宿主的授权面板,用户同意时返回 `true`,拒绝时返回 `false`。如果用户之前已经同意过,它会立即返回 `true`。
```tsx
const start = async () => {
if (!(await props.cloud?.requestConsent())) return; // 用户拒绝,或者当前宿主没有云服务
const channel = await props.cloud!.channels.open({ ttlS: 30 * 60, maxPeers: 1, page: 'view' });
// …
};
```
AI 助手调用插件工具时,宿主不会弹出授权面板。工具里不需要特别处理,让错误直接抛出即可,助手会告诉用户去哪里开启授权。如果你想返回更明确的结果,也可以像下面这样捕获错误:
```ts
import { isCloudError } from '@pku-harness/sdk';
try {
await ctx.cloud?.store.put('prefs', prefs);
} catch (e) {
if (isCloudError(e, 'consent_required')) return { status: 'cloud_consent_required' };
if (isCloudError(e, 'offline')) throw new Error('网络不可用,稍后再试');
throw e;
}
```
## 3. 检查云服务是否可用
用户可能没有配置服务商、没有授权,或者服务商暂时连不上。这些情况下 `ctx.cloud?.available` 都是 `false`,插件应该直接返回明确的结果,不再发起调用。
```ts
const cloud = ctx.cloud;
if (!cloud?.available) {
// 没有配置服务商、没有授权,或者连不上
return { status: 'cloud_unavailable', provider: cloud?.status().provider ?? 'off' };
}
```
`status()` 返回 `{ provider: 'official' | 'custom' | 'off', baseURL?, consented, online }`。
手机离线时,各项操作的表现不同。存储的读操作会改为读取本地镜像。写操作和函数调用会立即以 `offline` 失败,宿主不会把它们放进待发队列,事后悄悄补发。通道会按退避策略自动重连。
## 4. 使用存储
存储是一个键值存储,适合保存小份的设置或状态。下面的例子读出一个计数器,加一后写回:
```ts
const cur = await ctx.cloud!.store.get<{ count: number }>('counter');
await ctx.cloud!.store.put('counter', { count: (cur?.value.count ?? 0) + 1 }, { ifVersion: cur?.version ?? 0 });
const keys = await ctx.cloud!.store.list('history/');
```
- 服务商按 `(device, plugin)` 划分命名空间,其他插件读不到你的数据。
- `ifVersion` 用来做乐观并发控制:只有当前版本与给出的版本一致时,写入才会成功。`0` 表示这个键必须还不存在。版本不一致时服务器返回 `409 conflict`,宿主会把它抛成 `CloudError('protocol')`。
- `ttlS` 设置值的有效期(秒),到期后自动删除。
- 默认配额是每台设备每个插件 1 MB、1,000 个键,单个值最大 64 KB。
## 5. 使用中继通道
通道在机主的手机和打开分享链接的人之间转发端到端加密的数据帧。服务商只负责转发密文,并控制谁能加入通道。只有持有分享链接的人才能解密。
```ts
const channel = await cloud.channels.open({
ttlS: 30 * 60, // 必填,60 秒到 24 小时
maxPeers: 1, // 第一个加入的人占住名额,转发出去的链接没有用
page: 'view', // 给没装 App 的人看的查看页
meta: { nameHint: '王_明', pinRequired: true }, // 已加密;查看者加入前就能读到
});
const off = channel.on('message', (e) => {
if (e.type !== 'message') return;
const msg = frameSchema.safeParse(e.msg); // 对端发来的帧一律先校验
if (!msg.success) return;
// …
});
channel.on('join', (e) => e.type === 'join' && void channel.send(e.peerId, { t: 'hello' }));
// 之后
await channel.extend(15 * 60);
await channel.close();
off();
```
通道还提供以下方法:
- `send(peerId | '*', msg)` 为每个对端分别加密并发送。每帧最大 4 KB,每分钟最多 60 帧。
- `mailbox.put(slot, msg, ttlS)` 留下一个加密数据块。你离线时,对端也能读到它。
- `kick(peerId)` 踢掉一个查看者并释放名额,之后同一个链接可以给新的查看者使用。
- `channels.get(id)` 和 `list()` 在 App 重启后依然有效,因为通道密钥保存在系统密钥库里。
- 在 Android 上做长时间分享时,用 `services.ongoing.start({ title, until, onStop })` 让 App 保持运行。`services.ongoing` 只在 Android 上存在。
### 展示分享链接
`channel.link` 是一个 `SecretLink` 对象。插件代码读不到其中的 URL,把它序列化也只会得到 `"[secret link]"`。要把链接交给用户,请使用宿主提供的组件:
```tsx
const { QRCode, Button } = host.components;
]