# 让 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}