Semestra

插件开发

怎样写一个第一方插件:卡片、页面、设置、capability、数据与测试。

适用于第一方插件(计数器、习惯打卡、番茄钟、便签、世界时钟都按这个方式写)。参考实现是模板插件 hello-note:

  • 前端:apps/web/src/modules/_template-plugin/
  • 后端:apps/api/src/semestra/modules/plugins_builtin/_template/

模板不注册进应用,复制后改名即可。更完整的例子看 plugin-habit-streak + plugins_builtin/habit_streak/(有自己的表、事件订阅、导出适配器)。

1. 先想清楚:插件提供什么

形态说明谁来画外框
Card放在作用域(学业项目 / 学期 / 课程 / 今天)的 Board 上,4/6/8 列网格里占 s/m/l/xl 尺寸宿主画外框(标题、尺寸工具条、设置入口、移除),插件只写内容区
Page侧边栏里的一个入口(学业项目与学期页面)或课程页头的一个标签页,整页自由布局宿主只包一层 PluginBoundary
设置分区插件的账户级或作用域级配置按 schema 自动生成表单
Capability对外的读/写操作,同时是 REST、AI 助手工具和 MCP 工具无 UI

插件按账户安装/卸载。卸载只隐藏,不删数据;用户选择"同时删除数据"时发布 plugins.purge,插件必须清理自己的行。

2. 目录与命名

apps/web/src/modules/plugin-<id>/       前端;入口 manifest.ts
apps/api/src/semestra/modules/plugins_builtin/<id_with_underscores>/   后端,只在需要持久化或 Agent 工具时才有
  • 插件 id 是 kebab-case(habit-streak),前后端必须一致;CI 测试会把前端 manifest 列表与后端 /plugins/catalog 比对。
  • 卡片 id 是 <前缀>.<名称>(habit.ring),capability 是 <前缀>.<对象>.<动作>(habit.checkin.toggle),表名 plugin_<前缀>_<名称>,设置命名空间 plugin.<id>。
  • 文案 key 用插件前缀(habit_…),两种语言都要写;错误码 habit.date_out_of_range 对应文案 error_habit_date_out_of_range。

3. 前端

3.1 Manifest

export default defineModule({
  id: 'hello-note', kind: 'plugin',
  name: m.hello_name, description: m.hello_description, icon: NotebookPen,
  pages: [{ id, scopes, title, icon, component: preloadable(...), order: 1000 }],
  cards: [{
    id: 'hello.note', scopes: ['program','term','course','today'],
    title, icon, sizes: ['s','m','l'], defaultSize: 'm',
    component: preloadable(...),
    prefetch: (scope, queryClient) => ...,   // 可选:卡片代码加载时同时开始取数据(queryClient.prefetchQuery)
    settings: { schema: zodSchema, component: preloadable(...) },   // component 可省,省略则按 schema 自动生成表单
    multiple: true,
    headerActions: ...,        // 卡片头部操作(计数器的"重置"),由宿主在悬停时显示
    keepTitleWhenSmall: true,  // 可选:s 尺寸也保留标题行(默认隐藏)
  }],
  settingsSections: [{ id, level: 'account', pluginId, title, order, namespace: 'plugin.hello-note' }],
  statusItems: [{ id, order, useStatus }],   // 侧边栏状态条(番茄钟用它显示进行中的计时)
})
  • 所有重组件按需加载;页面的 component、卡片和设置分区的 component 用 preloadable()(@/app/extensions),路由和设置对话框才能提前拉代码、拉好后直接渲染,用普通 lazy() 每次首次显示都会闪一下占位(registry.test.ts 会拦下);m.xxx 传函数本身,不要在模块级调用(语言切换会重挂载)。
  • 新插件的 manifest 要加到 app/registry.ts 的列表里。
  • 自定义的卡片设置表单和设置分区长一个样:用 @/app/settings 的 SettingsGroup + SettingRow(名称在左、控件在右;开关用 Switch,多选一用 ChoiceControl),需要整行宽度的选择器(图标、颜色、城市列表)单独占一行或放进 SettingsBlock。取消和保存按钮由宿主提供。

3.2 卡片组件

  • Props:{ scope, instanceId, size, settings }。settings 是宿主用 settings.schema 解析后的结果,所以每个字段给默认值,旧卡片才不会因为新增字段而解析失败。
  • 每个声明的尺寸都要有设计。s 只放最少的信息和一个主操作;不要用缩放凑合。测试里遍历所有尺寸渲染。
  • s 尺寸下宿主会隐藏标题行,把整张卡片留给内容(编辑 Board 时标题会回来),所以内容的第一行要能说明这是什么:计数器的名称、时钟的城市、"专注"。只有内容做不到时才设 keepTitleWhenSmall。
  • headerActions 和设置按钮一起放在右上角,指针移到卡片上或焦点在卡片内时出现(触屏上一直显示),插件不要自己控制显隐。
  • 可能放不下的列表用 overflow-y-auto scroll-fade:还有内容的那一侧会渐隐,用户能看出可以滚动。
  • 修改设置用 useCardSettings(instanceId, settings, delayMs):本地立即生效,延迟后保存,失败回滚并给出 status。计数器 300ms,便签 800ms。
  • 不要自绘卡片外框,也不要自己加内边距;外框之内的留白由宿主决定。
  • 主数据还是 undefined 时返回 <CardSkeleton />(@/app/board),不要返回 null、自己画转圈或先显示空状态。它是一个填满正文的圆角矩形,不改变卡片高度,150 ms 后才淡入;卡片代码加载时宿主也显示同一个骨架。
  • 业务数据(不属于设置的行,如打卡记录)走自己的 capability,用生成的 @semestra/api-client,不要手写 fetch。查询 key 由生成客户端决定,失效时用对象子集匹配。
  • 记忆性界面状态(折叠、上次选择、计时状态)用 useUiPref('<前缀>.<名称>', 默认值),不要放设置里,也不要直接用 localStorage。

3.3 样式与可访问性

  • 只用令牌:bg-primary、text-muted-foreground;课程/学科色用 subjectColor。不要写 oklch(...) 或 sky-500。
  • 控件用 @/ui/ 里的 shadcn 组件(Button、Input、Textarea、Checkbox、Switch、RadioGroup、Select…),不要手写 <input>、<button> 样式;新组件用 shadcn CLI 安装,不要手改 src/ui/。表格用 TanStack Table。
  • 图标按钮要有 aria-label;会变的数字用 aria-live="polite";动画尊重减少动效:系统的 prefers-reduced-motion(motion/react 的 useReducedMotion)和应用的减少动效偏好写到 <html data-reduce-motion="on">,两者任一为真就不播放(参考 plugin-kit/Burst.tsx)。
  • 破坏性操作只在"没有其他确认步骤且不可撤销"时才弹确认;表单提交本身就是确认。

4. 后端

4.1 设置命名空间

register_settings(namespace="plugin.hello-note", schema=HelloNoteSettings,
                  levels=("account", "course"), plugin_id="hello-note")

levels 是可以设置值的层级,更深的作用域读取继承后的结果。每个字段都要有默认值和 description。文案:settings_plugin_hello_note_<字段>_label|_description|_option_<取值>(后端测试检查两种语言齐全)。

4.2 Capability

@capability(name="hello.note.append", summary=..., description=..., effect="write",
            http=Http("POST", "/hello/notes/{instance_id}/lines"), tags=("hello-note",),
            plugin_id=PLUGIN_ID)
  • description 是 AI 读的:什么时候用、怎么找输入(卡片实例用 layouts.card.list)、什么情况不要用。
  • effect 为 read | write | destructive。带 plugin_id 的 capability 在插件未安装时被拒绝,也不会出现在 Agent 工具列表里。
  • 绑定卡片实例的 capability 用 instances.get_card_instance(ctx, instance_id, card_ids=CARD_IDS):卡片不存在、不是自己的、不是该插件的卡片,都统一返回 common.not_found。
  • 需要用户所在时区的"今天",通过卡片所在的学业项目取(instances.instance_program + academics.program_today),不要在前端算。
  • 领域错误用 AppError("<前缀>.<原因>", status, **params)。

4.3 数据与清理

  • 表用 OwnedMixin(带 user_id),以 instance_id 关联卡片;instance_id 不是外键(卡片实例归布局模块管理)。
  • 订阅 layouts.card.deleted(按 card_id 过滤)删除该卡片的行;订阅 plugins.purge(按 plugin_id 过滤)删除用户的全部行;它在卸载事务内发布,订阅要写 @events.subscribe("plugins.purge", in_transaction=True),抛错会让卸载回滚。想让统计在卡片删除后保留的,可以只响应 plugins.purge(番茄钟这样做)。数据放在 useUiPref 里的插件,在同一订阅里用 settings.service.purge_ui_state(ctx, user_id, '<前缀>') 一并清掉。
  • 要按时提醒(习惯打卡的每日提醒)用 M18 的 notifications.reminders.reminder_source(kind, ReminderSource(plan=…, message=…)):plan 给出未来一段时间的 Planned,message 在到点时再判断是否还值得发(返回 None 就不发);设备、免打扰和发送由通知模块负责,文本加在 notifications/texts/{en,zh-CN}.json。插件自己的设置变化后调用 reminders.plan(ctx, [program_id]) 重新计划。
  • 迁移走 Alembic 单一链:在 migrations/env.py import 你的 models,alembic revision --autogenerate,检查生成结果。

4.4 导出/导入适配器

提供 export / import_ / preview / purge 四个回调,导入要幂等(重复导入不产生重复行),id_map 用来重映射跨模块 id。完整例子是 plugins_builtin/portability.py,它在 plugins_builtin/__init__.py 里用 register_portability(..., tables=(...)) 注册;新插件的表要加进这两处,否则数据导出的覆盖测试(每张带 user_id 的表都要有适配器负责并能清空)会失败。没有自己数据的插件(如便签,文本在卡片设置里)适配器可以为空。

插件改了导出格式时,把 schema_version 加一,并传 upgrades={旧版本: step}:step(payload, archive) 把旧版本的 payload 转成下一版,改动之前做的备份仍然能恢复。step 是纯函数,和插件表的迁移一样只改数据。

4.5 Agent skill

<插件包>/skills/<名称>.md,frontmatter 是 +++ 包住的 TOML,写 name、title = { en = "…", zh-CN = "…" }(界面显示名)、description(以 “Use when …” 开头,写清什么时候加载它,模型在目录里只看得到这一句)和 tools(正文用到的 capability 都要列上,全部可用时 skill 才出现,测试会检查),可选 command(斜杠命令,如 /note,全局唯一);正文说明遇到什么请求按什么顺序调用哪些 capability(模板里的 hello-note.md)。每个 expose 含 agent 的 capability 都要在 messages/*.json 里有 capability_<名称下划线化>_title,后端测试会检查。

5. 测试要求

  • 前端:每个卡片在所有声明的尺寸下渲染;设置 schema 的校验(含缺字段的旧数据);两个实例互不干扰;有计时/日期逻辑的用纯函数 + 假时钟测。使用 @/test-utils 的 renderWithQuery 与 msw server。卡片调用的每个接口都要有 handler:没有 handler 的请求会让测试失败。
  • 后端:capability 走 REST 的成功/失败路径(错误码与 params);归属检查(别人的实例、别的卡片 id 返回 404);事件订阅(卡片删除、purge);导出→清空→导入的往返。卡片实例在测试里用 plugins_builtin/tests/conftest.py 的 cards fixture 模拟。
  • 改了后端接口要跑 pnpm gen:api 并提交生成结果。

6. 发布清单

  • 前后端 id 一致,PLUGIN 信息(名称/描述 key、版本、default_installed、提供的卡片与 capability)已导出
  • 每个卡片的每个尺寸都有设计;s 尺寸可用
  • 中英文案齐全(含设置文案和错误码文案)
  • 颜色全用令牌,控件全用 shadcn 组件,图标按钮有 aria-label
  • 卸载(保留数据 / 删除数据)与重装的表现正确:卡片消失、重装后数据恢复,purge 后表里该用户行数为 0
  • 导出适配器已写并通过往返测试
  • Capability 的 description 对 AI 足够清楚;写操作的 effect 标对
  • pnpm lint typecheck test、uv run ruff check、uv run pyright、uv run pytest、pnpm gen:api 无差异