PKU harness

界面扩展

内置插件怎样通过 `contributes` 添加页面、设置项、标签页内容、看板小组件和快捷指令,并用 App 传入的 HostUI 组件搭建界面。

本页介绍内置插件怎样给 App 添加自己的界面。阅读前请先看内置插件。插件文档的小组件和页面用生成式界面的 JSON 描述,写法见声明式插件。

内置插件添加原生界面时,不需要导入任何 App 代码。每个页面和小组件都会通过 props 收到一个名为 HostUI 的对象,里面有 App 的组件、主题 token、触感反馈、导航和数据 hook。插件的界面全部用这些东西绘制,所以看起来和 App 的其他部分一致,App 改版后也照样能用。

插件添加的所有界面,都列在 definePlugin 的 contributes 里:

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' }],   // 只有负责某个标签页的插件才需要
},

页面

interface ScreenContribution {
  title: string;
  component: ComponentType<PluginScreenProps>;
  presentation?: 'push' | 'modal';   // modal → /m/<plugin>/<screen>(表单);默认 push → /p/<plugin>/<screen>
}

页面是一个 React 组件。默认以普通页面(push)的方式打开,表单请设置 presentation: 'modal'。组件会收到 PluginScreenProps,其中有 host、services、kv、invoke(toolId, input?)、params(路由的查询参数)和 cloud?。

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 (
    <SheetScreen title="编辑" primary="保存" onPrimary={async () => { await invoke('memory.update', { id: params.id, text }); host.dismiss(); }}>
      <Field label="内容" value={text} onChangeText={setText} multiline />
    </SheetScreen>
  );
}

打开页面用 host.navigate('/p/<plugin>/<screen>'),打开弹出页用 host.navigate('/m/<plugin>/<screen>?id=…'),关闭弹出页用 host.dismiss()。普通页面以 host.components.Screen 为根组件,弹出的表单页以 SheetScreen 为根组件。

设置项

interface SettingsContribution { screen: string; title: string; subtitle?: string; icon?: string }

设置项是通往你某个页面的入口,点开后进入它指定的页面。除了这些设置项,「设置 → 插件」里的插件详情页还会列出插件的页面、小组件、工具和权限。

小组件

interface WidgetContribution {
  title: string;
  description: string;                 // 写给助手看:显示什么内容,config 起什么作用
  sizes?: ('sm' | 'md' | 'lg')[];
  defaultSize?: 'sm' | 'md' | 'lg';
  configSchema?: z.ZodType<Record<string, unknown>>;
  component: ComponentType<PluginWidgetProps>;  // host, services, kv, invoke, size, config, cloud?
}

小组件是看板上的一张卡片。编写时请注意以下几点:

  • 卡片的外框由看板绘制,包括标题、背景、数据更新时间和长按菜单。你只需要绘制内容,并根据 size 调整布局:sm 是半宽的小卡片,md 占满一行,lg 是大号主卡片。
  • 用 host.useToolData(tool, args, { ttlMinutes }) 加载数据(见看板)。刷新时机由 App 决定,所以不要轮询。
  • 处理好三种状态。status 为 'loading' 时显示骨架屏;没有数据时显示错误文字,并让用户可以通过 data.refresh() 重试;还要处理未登录的情况。
  • 小组件的 config 由助手或用户选择,并用 configSchema 校验。每个字段用 .describe() 写说明,并在 description 里提到这些字段(「config.names limits it to certain canteens」),这样助手才知道有哪些配置。
  • 每个小组件都放在一个错误边界(error boundary)里。即使它崩溃,看板也只在它的位置显示一条提示,其他小组件照常工作。

快捷指令

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> | string | void }
);

快捷指令是聊天输入框 / 菜单里的一项。指令名只能用小写字母、数字和连字符(^[a-z][a-z0-9-]*$)。如果两个插件用了同一个名字,App 会给它们加上插件 id 作为前缀,变成 /plugin:name。

指令有两种。prompt 指令把一段文字发给助手,就像用户自己输入的一样。run 指令在设备上运行,它返回的字符串作为提示显示在聊天里,不会发给模型。

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.componentsScreen、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.hapticstap、select、success、warning(遵循用户设置)
host.useNow(ms)、formatAgo(ms)每隔 ms 更新一次的当前时间;相对时间
host.useSecureDisplay({ brightness })挂载期间禁止截屏(用于付款码)
host.useStoreVersion()定时任务、待办、小组件或课表变化时重新渲染
host.clipboard?、host.shareLink?可选,使用前先判断是否存在

完整签名见 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),撇号用弯撇号(’)。

本页目录