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 / GlassEdges | App 的面板材质。卡片和磁贴用 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 前缀。