内置插件
用 definePlugin 编写随 App 一起发布的插件。本页通过一个完整示例,依次介绍工具、数据源、页面、设置项、小组件、快捷指令、英文文字和测试。
内置插件(bundled plugin)是 pku-harness-plugins/plugins/<id> 下的一个 TypeScript 模块,只依赖 @pku-harness/sdk。它在手机上的 App 内部运行,只能通过「宿主服务」(host services)访问 App 的其他部分。宿主服务是 App 传给每个插件的一组对象,用于发起校园请求、发送通知、管理待办和定时任务等。和插件文档不同,内置插件可以添加原生界面。
内置插件只能随 App 版本发布,所以请先开一个 issue,说明插件做什么,以及为什么不能写成插件文档或远程插件。
大多数校园服务已经改用插件文档
TypeScript 用于 App 自身的功能:界面、看板、定时任务、待办、日程、课表、记忆、插件管理、模型服务商、身份验证器和付款码页面。插件语言暂时表达不了的接入也用 TypeScript。官方的校园接入 classrooms、campus-events、grades、canteen、shuttle 和 notices 都是随 App 内置的插件文档,其余的正在迁移。如果你的插件只是向校内或公开接口发请求、再提取数据,请改写 PLUGIN.md。如果它需要插件语言还没有的功能,请提议新增一项宿主能力(ability)。
本页从头编写一个完整的插件 focus。它记录专注时段(比如做一小时作业),并显示你今天一共专注了多少分钟。这个插件不调用任何外部服务,可以直接运行。如果要对接校园服务,就把其中的存储调用换成 ctx.services.campus.request(...)(见校园会话)。
你需要 bun,并了解 TypeScript 和一些 React Native。第 1 到第 6 步都在你电脑上的插件仓库里完成,最后一节会在模拟器或手机上的 App 里运行这个插件。
目录结构
插件放在 plugins/ 下自己的文件夹里。除此之外,你还要修改两个共用文件:内置插件列表 plugins/index.ts,以及 registry/registry.json。
1. 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 当作参数传入,而不是自己读取时钟,所以测试可以传入任意时间。
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<Session[]> {
return (await kv.get<Session[]>(KEY)) ?? [];
}
export async function saveSessions(kv: KV, list: Session[]): Promise<void> {
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 里的页面、设置项、小组件和快捷指令。
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/<screen>)。 |
contributes.settings | 设置页里的一行,点开进入某个页面。 |
contributes.widgets | 一个原生的看板小组件(widget),助手会读取它的 description。 |
contributes.commands | 聊天输入框里的 /focus 快捷指令(这里是一个本地操作)。 |
tools | 插件的 API:每个工具一种效果(effect),用 zod 定义输入,返回数据。 |
4. 界面
页面和小组件通过 host 属性拿到 App 的组件,从不直接从 App 导入。这样插件就不依赖 App 的代码,App 改版后插件照样能用。页面、小组件和字符串表的写法如下:
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<Today>('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 (
<Screen title={s.today}>
<Column gap="lg">
<Stat label={s.today} value={data.value?.minutes ?? null} suffix={host.useLocale() === 'en' ? ' min' : ' 分钟'} icon="clock" />
<Text tone="muted">{active ? s.running(active.label) : s.idle}</Text>
<Button title={active ? s.stop : s.start} variant={active ? 'secondary' : 'primary'} loading={data.fetching} onPress={() => void toggle()} />
</Column>
</Screen>
);
}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<Today>('focus.today', {}, { ttlMinutes: 1 });
if (data.status === 'loading') return <Skeleton width="50%" height={28} />;
if (!data.value) return <Text tone="muted">{data.error ?? ''}</Text>;
return (
<View style={{ gap: t.spacing.xs }}>
{size === 'sm' ? (
<Text variant="micro" tone="faint">
{s.today}
</Text>
) : null}
<Text variant={size === 'lg' ? 'numberXL' : 'numberL'}>{s.minutes(data.value.minutes)}</Text>
<Text variant="caption" tone={data.value.running ? 'accent' : 'muted'} numberOfLines={1}>
{data.value.running ? s.running(data.value.running.label) : s.idle}
</Text>
</View>
);
}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<Strings>({
'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 当作参数,每个测试都可以固定时间。
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 里列出的插件,所以要把你的插件加进这个列表。列表的顺序也就是插件在设置页和系统提示词里的顺序:
import { focusPlugin } from './focus/src';
export const builtinPlugins: PluginDef[] = [
// …
focusPlugin,
];然后在 registry/registry.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)相同的检查:
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
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:
bun run android # 在模拟器或设备上运行开发版
# 或者,已经装好开发版时:
bunx expo start --dev-clientMetro 是 React Native 的 JavaScript 打包工具,你保存文件后它会自动重新加载 App。要推送分支,就把子模块的远程仓库指向你的 fork(git -C plugins remote add …)。pull request 提交到 pku-harness-plugins,之后再由 App 仓库更新子模块指向的提交。
试一试
有四种方式可以试用:打开「设置 → 专注」;在看板的小组件库里添加小组件;在对话里输入 /focus;或者对助手说「开始专注高数作业」。
如果你没有模型的 API key,可以用模拟模型。在设备上,有一种只在开发版里可用的深链接,可以直接运行某个工具:pkuharness://dev?tool=focus.today&input={}。