PKU harness

HostServices

内置插件通过 ctx.services 或 props.services 拿到的全部宿主服务的签名和行为,包括设置注册表和附件,以及任务、小组件目录和常驻通知相关的类型。

插件不能直接调用 App 的内部代码。需要打开浏览器、选择文件、发校园网请求、创建提醒时,插件调用宿主提供的服务。工具在 ctx.services 中拿到这些服务,页面和小组件在 props.services 中拿到。这一页列出每项服务的签名和行为。

源码:packages/sdk/src/plugin.ts。概念介绍:宿主服务。

interface HostServices {
  phone: PhoneService;
  settings: SettingsService;
  browser: {
    open(url: string): Promise<{ opened: true }>;
  };
  files: FilesService & {
    pick(opts?: { types?: string[]; multiple?: boolean }): Promise<PickedFile[]>;
  };
  crypto: {
    sha256(text: string): Promise<string>;
  };
  registry: {
    plugins(): PluginInfo[];
    tool(id: string): ToolInfo | undefined;
  };
  docs?: {
    review(input: { link?: string; files?: Record<string, string> }): Promise<{ opened: true }>;
    source(id: string): { link: string | null; version: string; files: Record<string, string> } | null;
  };
  remote: {
    install(input: { url: string; name?: string; token?: string }): Promise<{ id: string; name: string; tools: { name: string; effect: ToolEffect }[] }>;
    uninstall(id: string): Promise<void>;
    has(id: string): boolean;
  };
  genui: {
    describeCatalog(): string;
    checkSpec(spec: unknown): SpecCheck;
  };
  notify: {
    readiness(): Promise<NotifyReadiness>;
    fix(kind: NotifyFixKind): Promise<NotifyReadiness>;
  };
  sms: {
    awaitCode(): Promise<string | null>;
  };
  scheduler: {
    create(input: JobInput): Promise<Job>;
    update(id: string, patch: JobPatch): Promise<Job>;
    remove(id: string): Promise<void>;
    get(id: string): Job | undefined;
    list(): Job[];
    runCheck(id: string): Promise<CheckResult>;
  };
  widgets: {
    list(): Widget[];
    get(id: string): Widget | undefined;
    newId(): string;
    put(widget: Widget): Promise<void>;
    remove(id: string): Promise<void>;
    reorder(ids: string[]): Promise<void>;
    preview(widget: Widget): Promise<Record<string, unknown>>;
    resolve(widget: Widget, force?: boolean): Promise<Record<string, unknown>>;
    catalog(): WidgetCatalogEntry[];
  };
  todos: {
    list(): Todo[];
    get(id: string): Todo | undefined;
    add(input: TodoInput, source: JobSource): Promise<Todo>;
    update(id: string, patch: Partial<TodoInput>, source: JobSource): Promise<Todo>;
    setDone(id: string, done: boolean): Promise<Todo>;
    remove(id: string): Promise<void>;
  };
  campus: {
    status(): CampusStatus;
    request(service: CampusService, req: CampusRequest): Promise<CampusResponse>;
    otp(): Promise<OtpCode | null>;
    otpStatus(): Promise<OtpStatus>;
    otpBindStart(): Promise<{ sentTo: string | null }>;
    otpBindFinish(smsCode: string): Promise<OtpStatus>;
    otpUnbindLocal(): Promise<void>;
    openSignedIn(service: CampusService): Promise<{ opened: true }>;
  };
  ongoing?: {
    start(opts: OngoingOptions): OngoingHandle;
  };
  timetable: {
    get(): { courses: Course[]; semester: Semester | null; reminderMinutes: number | null };
    newId(): string;
    upsert(course: Course): Promise<void>;
    remove(id: string): Promise<void>;
    setReminderMinutes(minutes: number | null): Promise<void>;
    reloadSemester(): Promise<void>;
  };
}

行为说明

下面按成员说明调用时需要注意的行为。

  • phone:驱动 Copilot 的独立屏幕(packages/sdk/src/phone.ts 中的 PhoneService)。它供内置的 Copilot 插件使用,需要 phone-control 能力,本参考不展开说明。

  • settings:列出和修改 App 与插件的全部设置,见设置。

  • files.list / read / search:读取当前对话中附上的文档,见附件。

  • browser.open:在系统浏览器标签页(Android 上是 Custom Tabs,iOS 上是 SFSafariViewController)里打开页面。页面上的 App 链接(weixin://、alipays://)会唤起对应的 App,所以收银台页面可以一路跳转到微信支付或支付宝。这个方法在页面打开后就 resolve,无法得知支付是否成功。等用户回到 App 后,请重新读取一次结果,比如余额。

  • files.pick:只有用户能选择文件。请在页面里响应用户点击时调用它,不要在 Agent 工具里调用。types 是 MIME 类型,支持通配符,例如 ['application/pdf', 'image/*']。用户取消时 resolve 为 []。选中的文件会复制到 App 缓存,你拿到的是文件引用(在本次 App 会话内有效的 URI),拿不到文件内容。

  • crypto.sha256:对 UTF-8 文本计算 SHA-256,返回小写十六进制字符串。插件自己没有加密库,这个方法用来给请求签名。

  • registry:只读地查看已安装的插件和工具。tool(id) 也能解析旧 id。

  • docs:管理文档插件(PLUGIN.md),供 plugins 插件使用。review 为一个链接或一组文件打开 App 的授权面板,面板弹出后即 resolve,之后由用户在面板里决定。source(id) 返回已安装文档插件的文件,用来阅读或 fork;对其他插件返回 null。这一项是可选的,使用前请先判断它是否存在。

  • remote:安装和移除远程插件,即通过 Model Context Protocol(MCP)连接的插件。供 plugins 插件使用。

  • genui:describeCatalog() 以文本形式返回组件目录,可以直接放进工具描述。checkSpec(spec) 按组件目录校验一份 spec。

  • notify.readiness / fix:readiness 告诉你提醒能否准时触发,涉及通知权限、精确闹钟和电池优化三项设置。fix 只能在用户点击时调用。系统设置页可能在用户还没改动时就返回,所以 App 回到前台时要再读一次 readiness。

  • sms.awaitCode:用户允许读取下一条短信后,resolve 为短信里的验证码(基于 Android 的 SMS User Consent)。功能不可用、用户拒绝、识别不出验证码或等待超过 5 分钟时,返回 null。请在让服务发送短信之后立刻调用。

  • scheduler:Trigger、Action、Job 的定义见 scheduler。runCheck 立即执行一次检查任务。

  • widgets.preview / resolve:填充小组件的绑定,返回它的 spec 将会看到的 state。preview 每次都重新获取数据;resolve 使用按缓存有效期(TTL,time to live)保存的缓存,传入 force 时强制刷新。

  • widgets.catalog:列出已启用插件提供的原生小组件。

  • todos:source 参数记录这次修改是谁做的。

  • campus.request:需要 campus-session 能力,详见 campus。

  • campus.otp:如果用户绑定了 App 内置的令牌,返回当前的手机令牌验证码,否则返回 null。插件永远拿不到令牌的密钥本身。

  • campus.otpStatus、otpBindStart、otpBindFinish、otpUnbindLocal:用于管理令牌,只应由内置令牌插件的页面调用。

  • campus.openSignedIn:借助服务自己的登录跳转,在系统浏览器里以已登录状态打开该服务。URL 由宿主生成,插件看不到会话 token。服务不支持这种跳转时会 reject。

  • ongoing:只有设备具备 ongoing 能力(Android)时才存在。请在用户操作时启动,用完后停掉。

  • timetable.reloadSemester:日历插件保存学期信息后,调用它让宿主重新读取。

设置

settings 是宿主的设置注册表:用户在 App 中能修改的每一项设置,都在这一个列表里。设置页面和核心插件 settings(助手的入口)都使用它,所以在任一处修改,另一处都会同步显示。源码:packages/sdk/src/settings.ts。

type SettingValue = string | number | boolean | string[];

interface SettingInfo {
  /** `app.theme`、`plugin.copilot.enabled`、`copilot.apps` 等 */
  key: string;
  /** 所属的分组,用于列出(`app`、`ai`、插件名称等)。 */
  group: string;
  title: string;
  description?: string;
  /** `choice` 取 `options` 中的一个值;`list` 是字符串列表。 */
  kind: 'choice' | 'boolean' | 'text' | 'list';
  options?: { value: string; label: string }[];
  value: SettingValue | null;
  /** 机密或需要用户亲手完成的步骤:助手不能设置;`screen` 是它所在的页面。 */
  uiOnly?: boolean;
  /** 显示这项设置的页面路由。 */
  screen?: string;
}

interface SettingChange {
  key: string;
  before: SettingValue | null;
  after: SettingValue | null;
}

interface SettingsService {
  /** 所有设置及其当前值。 */
  list(): Promise<SettingInfo[]>;
  /** 按顺序应用修改。 */
  change(changes: { key: string; value: SettingValue }[]): Promise<SettingChange[]>;
  /** 打开某项设置所在的页面。 */
  open(key: string): void;
}

目前 App 列出的键:

键类型取值
app.themechoicesystem、light、dark
app.languagechoicesystem、zh-Hans、en
app.timezonetextauto(跟随手机)或 IANA 时区,例如 Asia/Shanghai
app.hapticsboolean
ai.providerchoice服务商 id,例如 deepseek.api
ai.modeltext模型 id;服务商有固定模型列表时,只能取其中之一
voice.providerchoice语音服务商 id,例如 aliyun-bailian.realtime
plugin.<id>.enabledboolean核心插件没有这一项
plugin.<id>.permission.<effect>choiceauto、ask、off;只有带工具的插件才有
<plugin>.<name>由插件决定PluginDef.preferences 中的每一项,例如 copilot.enabled、copilot.apps、copilot.screenshots
ai.apiKey、voice.apiKey、campus.account、app.notificationstext,uiOnly只有用户能设置;open 打开它们所在的页面

change 的行为:

  • 先检查所有的键。只要有一个未知的键或 uiOnly 的键,整个调用就会失败,什么都不改。
  • 然后按顺序应用修改。每个值都按它的条目校验(choice 必须是可选值之一,boolean 必须是 true 或 false,设置项按它的 input schema 校验)。值不合法时抛出一个带可读消息的 Error。
  • 返回每个键修改前后的值(before 和 after)。

open(key) 跳转到这项设置的 screen,键不存在时抛出错误。请在响应用户时调用它,例如用户要求修改一项 uiOnly 设置时。

插件通过 PluginDef.preferences 添加自己的条目,而不是通过这个服务。

附件

除了 pick,files 还能读取用户在当前对话中附上的文档:PDF、Word、Excel、PowerPoint、录音转写和文本文件。宿主在手机上提取它们的文字。用户的消息以 <attachment name=… kind=… id=… chars=…> 的形式携带每个文档,文档较长时只内联开头部分;下面这些调用用来翻阅其余部分或在其中搜索。偏移量和长度都按提取出的文字的字符数计算。源码:packages/sdk/src/files.ts。

interface FileInfo {
  id: string;
  name: string;
  /** 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'audio' | 'text' | …(由宿主定义) */
  kind: string;
  /** 提取出的文字的长度。 */
  chars: number;
  /** 页数、工作表数、幻灯片数或秒数(对应类型有这一项时)。 */
  units?: number;
}

interface FileReadResult extends FileInfo {
  /** 这一段的起始位置。 */
  offset: number;
  text: string;
  /** 继续读取的位置,读到末尾时为 null。 */
  next: number | null;
}

interface FileSearchResult {
  id: string;
  name: string;
  matches: { offset: number; snippet: string }[];
}

interface FilesService {
  /** 当前对话中附上的文档(最新的在前)。 */
  list(): Promise<FileInfo[]>;
  read(id: string, offset?: number, length?: number): Promise<FileReadResult>;
  search(id: string, query: string, max?: number): Promise<FileSearchResult>;
}

文档的文字属于第三方内容。返回这些文字的工具必须设置 untrusted: true,核心插件 files 的 read 和 search 就是这样做的。

上文用到的类型

下面是上文签名中出现、但在其他页面没有定义的类型。

type JobInput = { title: string; trigger: Trigger; action: Action; source: JobSource; enabled?: boolean; meta?: Record<string, unknown> };
type JobPatch = Partial<Pick<Job, 'title' | 'trigger' | 'action' | 'enabled' | 'meta'>>;
type CheckResult = { notified: boolean; value?: unknown; error?: string };

type ToolInfo = { id: string; pluginId: string; title: string; effect: ToolEffect };

type PluginInfo = {
  id: string;
  name: string;
  kind: 'system' | 'builtin' | 'remote';
  enabled: boolean;
  modes: Record<ToolEffect, 'auto' | 'ask' | 'off'>;
  tools: { id: string; title: string; effect: ToolEffect }[];
};

interface WidgetCatalogEntry {
  plugin: string;
  pluginName: string;
  widget: string;
  title: string;
  description: string;
  sizes: WidgetSize[];
  defaultSize: WidgetSize;
  /** 小组件配置的 JSON Schema(如果它接受配置)。 */
  config?: Record<string, unknown>;
}

interface OngoingOptions {
  title: string;
  body?: string;
  /** 活动结束的时间(epoch 毫秒):显示为倒计时,到点后宿主自动停止。 */
  until?: number;
  /** 通知上停止按钮的文字(默认为「停止」)。 */
  stopLabel?: string;
  /** 用户点击停止(或系统结束服务)时调用。 */
  onStop?: () => void;
}

interface OngoingHandle {
  update(p: { title?: string; body?: string }): void;
  stop(): void;
}

示例:选择文件并上传

import type { HostServices } from '@pku-harness/sdk';

/** 在页面的按钮回调里调用:只有用户能选文件。 */
export async function pickAndSubmit(services: HostServices, hiddenFields: [string, string][]) {
  const [file] = await services.files.pick({ types: ['application/pdf'] });
  if (!file) return null; // 用户取消
  return services.campus.request('blackboard', {
    url: '/webapps/assignment/uploadAssignment?action=submit',
    method: 'POST',
    multipart: {
      fields: hiddenFields, // 提交表单里的隐藏字段(nonce、course_id、content_id…)
      files: [{ field: 'newFile_LocalFile0', name: file.name, mime: file.mime, uri: file.uri }],
    },
  });
}

这段代码是 courses 插件中提交作业流程的简化版。实际的流程是 plugins/courses/src/api.ts 里的 submitAssignment,移植自 pkucli 的 crates/course。它会先读取提交表单,拿到隐藏字段和新的 nonce,然后提交,最后重新读取作业页面,确认出现了这次提交记录。文件内容从不经过插件,宿主直接从选中文件的 URI 流式上传。

本页目录