PKU harness

ui

插件界面用到的类型:页面和小组件收到的 props,HostUI(组件、主题、导航、触感反馈、实时数据 hook),以及全部扩展类型(页面、设置项、标签页、小组件、命令)。

插件可以给 App 添加自己的界面,比如一个页面、一个设置项或一个看板小组件。这些界面用 React 编写,所需的组件、主题和数据 hook 都由宿主通过 HostUI 提供。这一页列出界面相关的全部类型。

源码:packages/sdk/src/ui.ts。使用指南:界面扩展。

Props

宿主渲染插件的页面时传入 PluginScreenProps,渲染插件的小组件时传入 PluginWidgetProps。

interface PluginScreenProps {
  host: HostUI;
  services: HostServices;
  /** 本插件的存储。 */
  kv: KV;
  /** 以用户身份运行 App 中的某个工具(校验和权限与助手调用时相同)。 */
  invoke(toolId: string, input?: Record<string, unknown>): Promise<unknown>;
  params: Record<string, string | undefined>;
  /** 本插件的云服务客户端(声明了 `PluginDef.cloud` 的插件才有)。 */
  cloud?: PluginCloud;
}

interface PluginWidgetProps {
  host: HostUI;
  services: HostServices;
  kv: KV;
  invoke(toolId: string, input?: Record<string, unknown>): Promise<unknown>;
  size: WidgetSize;
  /** 助手或用户选定的配置,按 `configSchema` 校验。 */
  config: Record<string, unknown>;
  cloud?: PluginCloud;
}

HostUI

HostUI 是 props 中的 host。它提供宿主的组件、触感反馈、导航,以及读取主题、时间、语言和实时数据的 hook。

type AppLocale = 'zh-Hans' | 'en';

interface HostUI {
  components: HostComponents;
  haptics: { tap(): void; select(): void; success(): void; warning(): void };
  /** 跳转到 App 的某个路由或其他插件页面(`/p/<plugin>/<screen>`)。 */
  navigate(href: string): void;
  back(): void;
  /** 宿主的数据(任务、待办、小组件、课表)变化时重新渲染。 */
  useStoreVersion(): number;
  useTheme(): HostTheme;
  /** 当前时间,每隔 `intervalMs` 刷新一次。 */
  useNow(intervalMs?: number): Date;
  /** 关闭模态页面(从深链接打开时,改为跳转到给定的路由)。 */
  dismiss(fallback?: string): void;
  /** 切换到对话页,并向助手发送一条消息。 */
  ask(text: string): void;
  /** 只读工具的实时结果。`ttlMinutes` 默认为 5。 */
  useToolData<T = unknown>(tool: string, args?: Record<string, unknown>, opts?: { ttlMinutes?: number; path?: string }): LiveData<T>;
  /** AI 生成的小组件的 spec 状态,所有绑定都已填好。 */
  useWidgetData(widget: Pick<Widget, 'spec' | 'bindings'>): LiveData<Record<string, unknown>>;
  /** 同时获取多个绑定的实时值。 */
  useBindings(bindings: Binding[]): LiveData<unknown[]>;
  /** 强制刷新页面上所有实时查询(下拉刷新);全部完成后 resolve。 */
  refreshAll(): Promise<void>;
  /** 把过去的时间戳格式化为「刚刚」「3 分钟前」「昨天 08:00」。 */
  formatAgo(ms: number, now?: number): string;
  /** 挂载期间禁止截屏和录屏;`brightness` 会调高屏幕亮度,方便扫码。 */
  useSecureDisplay(opts?: { brightness?: boolean }): void;
  /** App 语言(变化时重新渲染)。 */
  useLocale(): AppLocale;
  /** 当前构建没有剪贴板模块时为 undefined。 */
  clipboard?: { setString(text: string): Promise<void> };
  /** 用分享链接打开系统分享面板,插件看不到 URL。宿主支持云端频道之前为 undefined。 */
  shareLink?(link: SecretLink, opts?: { message?: string }): Promise<void>;
}

LiveData

useToolData、useWidgetData 和 useBindings 返回 LiveData。它包含当前的值、加载状态和最近一次错误。

interface LiveData<T = unknown> {
  /** 只有拿到第一个值之前才是 'loading';之后出错时保留上一次的值。 */
  status: 'loading' | 'error' | 'ready';
  value?: T;
  /** 最近一次刷新的错误(显示旧值时也会设置)。 */
  error?: string;
  /** 当前显示的值是什么时候获取的(epoch 毫秒)。 */
  updatedAt?: number;
  /** 正在请求中。 */
  fetching: boolean;
  /** 忽略 TTL,立即重新获取。 */
  refresh(): Promise<void>;
}

实时数据采用 stale-while-revalidate 策略:先显示缓存里的旧值,同时在后台获取新值。上一次的值在 App 重启后依然保留,打开页面时立即显示。

每个值都有一个缓存有效期(TTL,time to live)。宿主在以下情况重新获取:值已超过 TTL、页面获得焦点、App 回到前台、页面可见期间每隔一个 TTL,以及你调用 refresh() 时。小组件、页面和定时检查只要请求的是同一个工具和同样的参数,就共用一次请求和一条缓存。

HostTheme

host.useTheme() 返回当前主题。插件应当使用这里的颜色、间距、圆角和字体样式,不要自己写死数值,这样界面才会随浅色和深色主题一起变化。

type TextVariant = 'display' | 'title' | 'headline' | 'body' | 'bodyStrong' | 'callout' | 'caption' | 'micro' | 'numberXL' | 'numberL' | 'numberM';

interface HostTheme {
  name: 'light' | 'dark';
  colors: Record<
    | 'bg' | 'surface' | 'surface2' | 'surface3' | 'border' | 'text' | 'textMuted' | 'textFaint'
    | 'accent' | 'accentMuted' | 'onAccent' | 'up' | 'upMuted' | 'down' | 'downMuted'
    | 'warn' | 'warnMuted' | 'info' | 'infoMuted' | 'elevated' | 'brand' | 'onBrand',
    string
  >;
  spacing: Record<'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' | 'xxxl', number>;
  radius: Record<'sm' | 'md' | 'lg' | 'xl' | 'full', number>;
  typography: Record<TextVariant, Record<string, unknown>>;
  courseColor(index: number): { fill: string; ink: string };
  glass: { surface: string; rim: string; sheen: string };
  contentWidth: number;
}

HostComponents

这些都是宿主传进来的 React 组件。Style 就是普通的 React Native 样式,会原样透传。

type Tone = 'default' | 'muted' | 'faint' | 'accent' | 'up' | 'down' | 'warn' | 'info' | 'onAccent';
type ChipTone = 'neutral' | 'accent' | 'up' | 'down' | 'warn' | 'info';

interface HostComponents {
  Screen: ComponentType<{ title: string; subtitle?: string; back?: boolean; trailing?: ReactNode; children: ReactNode; refreshControl?: ReactNode }>;
  Text: ComponentType<{ variant?: TextVariant; tone?: Tone; align?: 'left' | 'center' | 'right'; numberOfLines?: number; selectable?: boolean; style?: Style; children?: ReactNode }>;
  Button: ComponentType<{ title: string; onPress?: () => void; variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: 'sm' | 'md' | 'lg'; icon?: string; loading?: boolean; disabled?: boolean; style?: Style }>;
  IconButton: ComponentType<{ icon: string; label: string; onPress?: () => void; variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: number; disabled?: boolean }>;
  ListGroup: ComponentType<{ title?: string; footer?: string; children: ReactNode }>;
  ListRow: ComponentType<{ icon?: string; title: string; subtitle?: string; value?: string; accessory?: ReactNode; onPress?: () => void; chevron?: boolean; titleLines?: number }>;
  Chip: ComponentType<{ label: string; tone?: ChipTone; icon?: string }>;
  Surface: ComponentType<{ level?: 1 | 2 | 3; padded?: boolean; children?: ReactNode; style?: Style }>;
  EmptyState: ComponentType<{ icon: string; title: string; message?: string; action?: ReactNode }>;
  Skeleton: ComponentType<{ width?: number | `${number}%`; height?: number; rounded?: number }>;
  FadeIn: ComponentType<{ index?: number; children: ReactNode; style?: Style }>;
  Icon: ComponentType<{ name: string; size?: number; color?: string }>;
  Stat: ComponentType<{ label: string; value: number | null; prefix?: string; suffix?: string; decimals?: number; caption?: string; icon?: string }>;
  Switch: ComponentType<{ value: boolean; onValueChange: (v: boolean) => void; label?: string; disabled?: boolean }>;
  Checkbox: ComponentType<{ value: boolean; onValueChange: (v: boolean) => void; label: string }>;
  Segmented: ComponentType<{ options: { label: string; value: string }[]; value: string; onChange: (v: string) => void }>;
  Row: ComponentType<{ gap?: 'xs' | 'sm' | 'md' | 'lg'; align?: 'start' | 'center' | 'end' | 'between'; children: ReactNode }>;
  Column: ComponentType<{ gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; children: ReactNode }>;
  Pressable: ComponentType<{ onPress?: () => void; onLongPress?: () => void; delayLongPress?: number; accessibilityLabel?: string; accessibilityRole?: string; accessibilityHint?: string; scaleTo?: number; haptic?: boolean; disabled?: boolean; style?: Style; children?: ReactNode }>;
  Field: ComponentType<{ label?: string; value: string; onChangeText: (v: string) => void; placeholder?: string; hint?: string; multiline?: boolean; secureTextEntry?: boolean; autoFocus?: boolean; keyboardType?: string; autoCapitalize?: 'none' | 'sentences' }>;
  DateTimeField: ComponentType<{ label: string; value: Date | null; onChange: (d: Date) => void; mode?: 'date' | 'time' | 'datetime'; placeholder?: string; onClear?: () => void }>;
  Stepper: ComponentType<{ value: number; onChange: (n: number) => void; min?: number; max?: number; suffix?: string; label: string }>;
  WeekdayPicker: ComponentType<{ value: number[]; onChange: (v: number[]) => void; multiple?: boolean }>;
  SheetScreen: ComponentType<{ title: string; children: ReactNode; primary: string; onPrimary: () => void; primaryDisabled?: boolean; loading?: boolean; danger?: string; onDanger?: () => void; secondary?: string; onSecondary?: () => void }>;
  Sparkline: ComponentType<{ values: number[]; tone?: 'accent' | 'up' | 'down' | 'info'; height?: number }>;
  Ring: ComponentType<{ value: number; label?: string; caption?: string; tone?: 'accent' | 'up' | 'down' | 'warn'; size?: number }>;
  Hourglass: ComponentType<{ period?: number; size?: number; tone?: 'accent' | 'up' | 'down' | 'warn' }>;
  GenUI: ComponentType<{ spec: Record<string, unknown>; onSubmit?: (form: Record<string, unknown>, label?: string) => void }>;
  SortableList: ComponentType<{ ids: string[]; rowHeight: number; onReorder: (ids: string[]) => void; renderRow: (id: string, handle: ReactNode) => ReactNode }>;
  PluginWidget: ComponentType<{ source: { plugin: string; widget: string; config?: Record<string, unknown> }; size: WidgetSize }>;
  Glass: ComponentType<{ children?: ReactNode; style?: Style; radius?: number; tone?: 'regular' | 'strong' | 'brand'; onPress?: () => void; onLongPress?: () => void; accessibilityLabel?: string; accessibilityRole?: 'button' | 'summary' | 'none'; haptic?: boolean }>;
  GlassEdges: ComponentType<{ radius?: number }>;
  QRCode: ComponentType<{ value?: string; link?: SecretLink; size?: number; ecl?: 'L' | 'M' | 'Q' | 'H' }>;
}
组件说明
Screen推入式页面的根组件:带标题、返回按钮和可选尾部元素的页头,支持下拉刷新。
SheetScreen模态页面的表单面板布局:标题和关闭按钮、可滚动的内容区、一个主操作,以及可选的次要 / 危险操作。
Pressable可点击的容器,带 App 统一的按压反馈。
Hourglass沙漏按 Unix 时间对齐,在 period 秒内漏完,到边界时翻转,比如一个基于时间的一次性密码(TOTP)的刷新周期。
GenUI用宿主的组件目录渲染一份生成式界面 spec。
SortableList纵向列表,用户拖动把手即可排序。每一行由插件自己渲染。
PluginWidget挂载某个插件提供的小组件。出错时不会影响外部;对应插件关闭时会显示提示。
Glass / GlassEdgesApp 的面板材质。卡片和磁贴用 Glass;容器自己画 colors.surface* 底色时,用 GlassEdges 补上边缘和顶部高光。
QRCode无论什么主题都是白底黑码。link 用来编码插件自己读不到的分享链接,和 value 同时设置时以 link 为准。

Contributions

插件在 PluginDef.contributes 中声明要添加到 App 里的界面,类型是 Contributions。

interface Contributions {
  /** 挂载在 `/p/<plugin>/<screen>` 的页面。 */
  screens?: Record<string, ScreenContribution>;
  /** 显示在设置里的条目,点击后打开插件的某个页面。 */
  settings?: SettingsContribution[];
  /** 用一个页面填充 App 的某个标签页位置。 */
  tabs?: TabContribution[];
  /** 原生看板小组件,以 id 为键。 */
  widgets?: Record<string, WidgetContribution>;
  /** 斜杠命令,以名称为键(小写,/^[a-z][a-z0-9-]*$/)。 */
  commands?: Record<string, CommandContribution>;
}

interface ScreenContribution {
  title: string;
  component: ComponentType<PluginScreenProps>;
  /** 'modal' 页面在 `/m/<plugin>/<screen>` 打开(用于表单);默认是 'push',在 `/p/…` 打开。 */
  presentation?: 'push' | 'modal';
}

interface SettingsContribution {
  /** `contributes.screens` 中某个页面的键。 */
  screen: string;
  title: string;
  subtitle?: string;
  icon?: string;
}

type TabSlot = 'dashboard' | 'schedule';

interface TabContribution {
  slot: TabSlot;
  /** `contributes.screens` 中某个页面的键。 */
  screen: string;
}

interface WidgetContribution {
  title: string;
  /** 显示什么内容;助手根据它挑选小组件。 */
  description: string;
  sizes?: WidgetSize[];
  defaultSize?: WidgetSize;
  configSchema?: z.ZodType<Record<string, unknown>>;
  component: ComponentType<PluginWidgetProps>;
}

type CommandContribution = {
  /** 一行文字,显示在 / 菜单里。 */
  description: string;
  /** 菜单额外匹配的名称,比如中文('余额')。 */
  aliases?: string[];
  /** 参数的占位提示。写了这一项,表示命令接受输入。 */
  argHint?: string;
} & (
  | { /** 作为用户消息发送;`{{input}}` 会被替换为输入。 */ prompt: string }
  | {
      /** 在本地运行;返回的文本作为提示显示(不会发给模型)。 */
      run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise<string | void> | string | void;
    }
);

插件小组件可以由助手通过 dashboard.addPluginWidget 添加,也可以由用户从看板的小组件库里添加。小组件的数据请用 host.useToolData 加载,这样它的刷新方式和其他小组件一致。卡片的外框(标题、背景)由看板绘制,小组件只需画出内容。

命令名通常直接使用。只有两个插件用了同一个命令名时,命令才会加上 /plugin:name 前缀。

本页目录