plugin
定义插件时用到的全部类型:definePlugin、defineTool、definePreference、PluginDef、ToolDef、ToolContext、效果、调用方、能力需求、KV、轮次信息、数据源、设置项、模型、语音和 OCR 服务商、翻译和旧 id。
每个内置插件都导出一个用 definePlugin 定义的对象,里面写明插件的名称、需要的能力、提供的工具和界面等。这一页列出这个对象和工具的全部字段,以及工具运行时收到的上下文。
源码:packages/sdk/src/plugin.ts。
definePlugin
function definePlugin(def: PluginDef): PluginDef;
function defineTool<I extends z.ZodType, O>(def: ToolDef<I, O>): ToolDef;
function definePreference<T extends SettingValue>(def: PluginPreferenceDef<T>): PluginPreferenceDef<T>;三个函数都原样返回参数,用途只是类型检查。defineTool 会根据 zod 的 input schema 推断出 execute 的入参类型;definePreference 让设置项的值类型和它的 input schema 保持一致。
PluginDef
PluginDef 是插件对象本身。下面先给出完整的类型,再逐个说明字段。
interface PluginDef {
id: string;
name: string;
description: string;
version: string;
maxAgentSteps?: number;
icon?: string;
core?: boolean;
requires?: Capability[];
cloud?: CloudManifest;
preferences?: Record<string, PluginPreferenceDef<any>>;
remote?: { url: string; server: string };
defaultModes?: Partial<Record<ToolEffect, 'auto' | 'ask' | 'off'>>;
scopes: string[];
tools: Record<string, ToolDef>;
dataSources?: Record<string, DataSourceDef>;
providers?: Record<string, ProviderDef>;
speech?: Record<string, SpeechProviderDef>;
ocr?: Record<string, OcrProviderDef>;
instructions?: Dynamic<string>;
context?: (input: ContextInput) => Promise<string | undefined>;
contributes?: Contributions;
i18n?: { en?: PluginTranslation };
legacy?: PluginLegacy;
}属性
类型
ToolDef
工具是插件提供给助手、用户和调度器调用的函数。tools 里的每一项都是一个 ToolDef。
interface ToolDef<I extends z.ZodType = z.ZodType, O = unknown> {
title: string;
description: Dynamic<string>;
input: I;
effect: ToolEffect;
resolveEffect?: (input: z.infer<I>, services: HostServices) => Promise<ToolEffect | ToolAssessment>;
untrusted?: boolean;
userOnly?: boolean;
onDemand?: boolean;
grant?: boolean;
alwaysAsk?: boolean;
serial?: boolean;
dayScoped?: boolean;
execute: (input: z.infer<I>, ctx: ToolContext) => Promise<O>;
}
/** 宿主观察到的操作对象,和模型给出的工具参数分开显示。 */
type ToolAssessment = {
effect: ToolEffect;
target?: { label: string; context?: string };
};属性
类型
ToolContext
宿主调用 execute 时会传入第二个参数 ctx。它说明这次是谁在调用(actor),并提供插件私有的键值存储 kv 和宿主服务 services。
interface ToolContext {
/** 本次调用经过(可选的)原生分类后被批准的效果。 */
effect?: ToolEffect;
pluginId: string;
actor: Actor;
/** 助手在一轮对话中调用时才有值。 */
turn?: TurnInfo;
/** 本插件的私有存储。 */
kv: KV;
services: HostServices;
/** 绑定到本插件的 PKU harness Cloud;在所有宿主版本都接入之前是可选的。 */
cloud?: PluginCloud;
/** 用户对 `grant` 工具提示的回答。用户直接调用时不设置。 */
grant?: 'once' | 'always';
/**
* 本插件自己的某个模型服务商(`PluginDef.providers` 的键)的地址和 Key,不能取其他插件的。
* 在所有宿主版本都接入之前是可选的。
*/
provider?: (key: string) => Promise<{ baseURL: string; credential?: string }>;
signal?: AbortSignal;
}
type Actor = 'user' | 'agent' | 'scheduler';
interface TurnInfo {
sessionId?: string;
/** 本轮中助手此前调用过不受信任的工具(或任何远程插件)。 */
tainted: boolean;
/** 用户在本轮自己发的消息中要求记住某件事。 */
userAsked?: boolean;
/** 本轮使用的模型能看图片(模型目录的 `inputModalities` 中有 'image')。 */
acceptsImages?: boolean;
}
interface KV {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
}ctx.provider(key) 返回插件自己的某个模型服务商的地址和 Key,也就是用户在「设置 → AI 服务」中填写的内容。调用同一个服务、用同一个 Key 的工具可以用它,例如 DeepSeek 插件的 web_search 用的就是 DeepSeek 对话的 Key。用户还没保存 Key 时 credential 为空,这时抛出一个 Error,告诉用户去哪里填写。插件读不到其他插件的服务商。
效果与能力需求
效果(ToolEffect)说明一个工具会产生什么后果,宿主据此决定调用前是否要征求用户同意。能力需求(Capability)说明插件需要设备具备什么条件。
type ToolEffect = 'read' | 'write' | 'money' | 'public';
type Capability =
| 'network' // 普通的互联网访问
| 'campus-network' // 只能从北大校园网访问的主机
| 'campus-session' // 经宿主发出的已登录校园 HTTP 请求(不会暴露凭据)
| 'notifications'
| 'background' // 定期在后台运行
| 'shell' // 手机上永远不可用;需要它的插件必须远程运行
| 'filesystem'
| 'cloud' // 已配置 PKU harness Cloud 服务;见 PluginDef.cloud
| 'phone-control' // 操作手机上的其他 App(Copilot)
| 'ongoing'; // 常驻通知 / 前台服务(仅 Android)动态值
有些字段(如 instructions 和工具的 description)既可以写成固定的值,也可以写成函数。写成函数时,宿主在构建提示词时调用它并传入 HostServices,你可以据此按当前状态生成内容。resolveDynamic 对这两种写法统一求值。
type Dynamic<T> = T | ((services: HostServices) => T);
function resolveDynamic<T>(value: Dynamic<T>, services: HostServices): T;DataSourceDef
数据源指向插件里一个适合给小组件和定时检查使用的读取工具,并给出调用参数 args 和缓存时长 ttlMs。
interface DataSourceDef {
title: string;
/** 本插件内的工具名。 */
tool: string;
args?: Record<string, unknown>;
ttlMs: number;
}请求上下文
PluginDef.context 函数在每轮对话前被调用,收到的参数是 ContextInput。
interface ContextInput {
/** 用户最新的一条消息。 */
query: string;
kv: KV;
services: HostServices;
}
/** 用来撤回先前展示过的事实的上下文行标记。 */
const CONTEXT_MARK = { deleted: '[已删除]', corrected: '[更正]', arrow: ' → ' } as const;当一条事实被删除或更正时,宿主会追加 - [已删除] old 或 - [更正] old → new 这样的行。它只在历史里仍然显示 old 的对话中追加,而且只追加一次。因此,事实文本本身不能含有这个箭头。
PluginTranslation
插件清单中的名称、描述、标题等默认用中文写。i18n.en 用这个类型提供英文版本,没有写出的字段沿用中文。
type PluginTranslation = {
name?: string;
description?: string;
/** 工具标题,以工具名为键。 */
tools?: Record<string, { title?: string }>;
widgets?: Record<string, { title?: string; description?: string }>;
screens?: Record<string, { title?: string }>;
/** 设置项,以它打开的页面为键。 */
settings?: Record<string, { title?: string; subtitle?: string }>;
/** 设置项(PluginDef.preferences)的标题和说明,以名称为键。 */
preferences?: Record<string, { title?: string; description?: string; options?: Record<string, string> }>;
/** `prompt`:提示词类命令的英文模板。 */
commands?: Record<string, { description?: string; argHint?: string; aliases?: string[]; prompt?: string }>;
providers?: Record<string, { name?: string; description?: string; credentialHint?: string; models?: Record<string, { name?: string; description?: string }> }>;
speech?: Record<string, { name?: string; description?: string; credentialHint?: string; models?: Record<string, { name?: string; description?: string }> }>;
dataSources?: Record<string, { title?: string }>;
/** 用文字描述(而非主机名)的访问范围,以 `scopes` 中的原文为键。 */
scopes?: Record<string, string>;
};设置项
设置项是属于某个插件的设置,例如 Copilot 允许使用的 App。你在 PluginDef.preferences 中声明它,宿主就会把它列入设置注册表,键名为 <plugin id>.<name>,和 App 自己的设置放在一起。用户在插件的页面里修改它,助手通过核心插件 settings 修改它,两者都经过 get 和 set。宿主不需要任何改动。详见架构 → 助手能操作一切。
源码:packages/sdk/src/settings.ts。
type SettingValue = string | number | boolean | string[];
interface PluginPreferenceDef<T extends SettingValue = SettingValue> {
title: string;
description?: string;
kind: 'choice' | 'boolean' | 'text' | 'list';
options?: { value: string; label: string }[];
/** 校验新的值(来自助手或页面)。 */
input: z.ZodType<T>;
get(ctx: SettingContext): Promise<T>;
set(ctx: SettingContext, value: T): Promise<void>;
/** 显示这个设置项的页面路由(默认是插件的第一个页面)。 */
screen?: string;
}
/** 设置项读写自己的值时能用的东西。 */
interface SettingContext {
kv: KV;
services: HostServices;
}属性
类型
设置项不是机密。不要把 API Key 或密码声明为设置项:助手能读取和修改每一个设置项。宿主自己的机密是标记为 uiOnly 的条目,只有用户能设置。
Copilot 插件这样声明它的截图开关:
import { definePlugin, definePreference } from '@pku-harness/sdk';
import { z } from 'zod';
import { loadCopilotVision, saveCopilotVision } from './preferences';
export const copilotPlugin = definePlugin({
id: 'copilot',
// …
i18n: {
en: {
preferences: {
screenshots: { title: 'Let the AI see screenshots', description: 'For apps that hide their contents from Copilot (Taobao, WeChat).' },
},
},
},
preferences: {
screenshots: definePreference({
title: '允许 AI 查看截图',
description: '用于不向 Copilot 显示内容的 App(淘宝、微信)。',
kind: 'boolean',
input: z.boolean(),
get: async (c) => (await loadCopilotVision(c.kv)).screenshots,
set: async (c, screenshots) => { await saveCopilotVision(c.kv, { screenshots }); },
}),
},
});这样,助手就能用 settings__change 和键名 copilot.screenshots 修改它。
PluginLegacy
interface PluginLegacy {
/** 现在由本插件承接的旧插件 id,例如 ['campus-card', 'netfee']。 */
pluginIds?: string[];
/** 旧的完整工具 id → 本插件中的工具名,例如 { 'campus-card.getBalance': 'cardBalance' }。 */
tools?: Record<string, string>;
/** 旧的 `<plugin>.<widget>` → 本插件 `contributes.widgets` 中的小组件键。 */
widgets?: Record<string, string>;
}合并或重命名插件之后,用它保留旧 id。宿主会把旧插件 id(设置、/p/<old>/… 页面、小组件)和旧工具 id 映射到新插件,一次性改写已保存的小组件和检查任务,并沿用原有设置,每种效果取最严格的授权方式。如果某个旧 id 对应的插件实际仍然安装着,宿主使用那个插件,不会用旧 id 映射遮盖它。
模型服务商
模型服务商为助手提供语言模型,例如 DeepSeek 或任何兼容 OpenAI 接口的服务。插件在 providers 中用 ProviderDef 描述一个服务商。
type ProviderAuth = 'apiKey' | 'account' | 'none';
interface ProviderDef {
name: string;
description: string;
auth: ProviderAuth;
defaultBaseURL: string;
editableBaseURL?: boolean;
models: ProviderModel[];
defaultModel: string;
/** API 支持的思考强度,从低到高,例如 DeepSeek 的 ['low', 'high', 'max']。用户在模型选择里选「自动」或其中一档。 */
efforts?: readonly ('minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max')[];
credentialHint?: string;
listModels?: (config: ProviderConfig) => Promise<ProviderModel[]>;
signIn?: () => Promise<string>;
/** 用用户自己的凭据读取账户余额。 */
balance?: (config: ProviderConfig) => Promise<ProviderAccount>;
/** 返回一个 AI SDK 的 `LanguageModel`(类型写得比较宽松)。 */
createModel: (config: ProviderConfig) => unknown;
}
interface ProviderConfig {
baseURL: string;
model: string;
/** 来自宿主安全存储的 API key 或账户 token。 */
credential?: string;
/** 宿主提供的支持流式响应的 fetch(例如 expo/fetch)。 */
fetch: typeof fetch;
}
interface ProviderModel {
id: string;
name?: string;
description?: string;
/** 上下文窗口大小,单位为 token。未知时按 128k 处理。 */
contextWindow?: number;
/** 只有列出 'image' 时才允许添加图片附件。 */
inputModalities?: ('text' | 'image')[];
pricing?: ModelPricing;
}
interface ModelPricing {
currency: 'CNY' | 'USD';
/** 每 100 万 token 的价格。 */
inputCacheHit: number;
inputCacheMiss: number;
output: number;
/** 服务商时区内的高峰时段:星期几(0 = 周日)和 [startHour, endHour)。 */
peak?: { multiplier: number; utcOffsetMinutes: number; weekdays: number[]; hours: [number, number][] };
/** 价格的出处(URL 和核对日期)。 */
source?: string;
}
interface ProviderBalance { currency: string; total: number; granted?: number; toppedUp?: number }
interface ProviderAccount { available: boolean; balances: ProviderBalance[] }服务商插件就是设置了 providers 的普通插件,通常不提供工具。下面是一个最小的例子。实际使用中的服务商插件可以参考插件仓库里的
plugins/deepseek/src/index.ts 和 plugins/openai-compatible/src/index.ts。
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { definePlugin } from '@pku-harness/sdk';
export const myProviderPlugin = definePlugin({
id: 'my-provider',
name: '我的模型',
description: '接入 example.com 的模型。',
version: '0.1.0',
scopes: ['api.example.com'],
tools: {},
providers: {
example: {
name: 'Example',
description: 'OpenAI-compatible models from example.com',
auth: 'apiKey',
defaultBaseURL: 'https://api.example.com/v1',
models: [{ id: 'example-large', name: 'Example Large', contextWindow: 128_000 }],
defaultModel: 'example-large',
createModel: ({ baseURL, model, credential, fetch }) =>
createOpenAICompatible({ name: 'example', baseURL, apiKey: credential, fetch })(model),
},
},
});语音服务商
语音服务商把用户说的话转成文字,供对话框的语音输入使用。它和模型服务商类似:用户使用自己的 Key,录音从手机直接发送到 baseURL。插件在 speech 中用 SpeechProviderDef 描述每个服务,宿主中的 id 为 <plugin>.<key>。
实时服务实现 stream,用户说话时文字同步出现;只接受完整录音的服务实现 transcribe。使用 transcribe 时,宿主会在用户停顿处把录音切成小段陆续发送,所以用户说完时大部分已经转好。只有 transcribe 能处理作为附件的音频文件。
interface SpeechProviderDef {
name: string;
description: string;
defaultBaseURL: string;
editableBaseURL?: boolean;
models: { id: string; name: string; description?: string }[];
defaultModel: string;
credentialHint?: string;
/** 申请 Key 的网址,显示在输入框下方。 */
keyURL?: string;
/** 改用本插件中这个 id 的模型服务商的 Key,不需要单独的 Key。 */
credentialFrom?: string;
transcribe?: (config: SpeechConfig, audio: SpeechAudio, signal?: AbortSignal) => Promise<string>;
stream?: (config: SpeechConfig, handlers: SpeechStreamHandlers) => Promise<SpeechStream>;
}
interface SpeechConfig {
baseURL: string;
model: string;
/** 来自宿主安全存储的 API Key。 */
credential?: string;
/** 宿主提供的 fetch(在 FormData 中上传 `SpeechAudio.file`)。 */
fetch: typeof fetch;
/** 打开一个带请求头的 WebSocket(供在握手时鉴权的实时服务使用)。 */
webSocket: (url: string, headers: Record<string, string>) => WebSocket;
}
/** 要转写的录音(除非 `mimeType` 另有说明,否则为 16 kHz 单声道 WAV)。 */
interface SpeechAudio {
file: Blob;
uri: string;
name: string;
mimeType: string;
durationMs?: number;
}
interface SpeechStreamHandlers {
/** 到目前为止的完整转写(已确认的文字加上尚未确定的结尾),替换上一次的结果。 */
onText: (text: string) => void;
}
interface SpeechStream {
/** 100 毫秒的 16 kHz 单声道 16 位小端 PCM,base64 编码。 */
send: (pcmBase64: string) => void;
/** 音频已结束:返回最终的转写结果。 */
finish: () => Promise<string>;
/** 放弃这次会话,不要结果。 */
cancel: () => void;
}packages/sdk/src/speech.ts 提供三个辅助函数:
openAITranscribe(config, audio, signal?)发送POST {baseURL}/audio/transcriptions(multipart 中带file和model),返回清理后的text。硅基流动、OpenAI 和大多数兼容服务都使用这种格式,所以插件可以直接把它作为transcribe。cleanTranscript(text)去掉<|zh|>这类控制标记,并去除多余空白。speechErrorMessage(status, body)把错误响应整理成"<status>: <message>"。
下面是精简后的硅基流动插件:
import { definePlugin, openAITranscribe } from '@pku-harness/sdk';
export const siliconflowPlugin = definePlugin({
id: 'siliconflow',
name: '硅基流动',
description: '用硅基流动的语音识别把语音输入转成文字(你自己的 API Key)。',
version: '0.1.0',
requires: ['network'],
icon: 'mic',
scopes: ['api.siliconflow.cn'],
tools: {},
speech: {
asr: {
name: '硅基流动',
description: 'SenseVoice 与 TeleSpeech,国内可直接访问;文字按句子出现',
defaultBaseURL: 'https://api.siliconflow.cn/v1',
models: [
{ id: 'FunAudioLLM/SenseVoiceSmall', name: 'SenseVoice', description: '普通话、英语、粤语、日语、韩语' },
{ id: 'TeleAI/TeleSpeechASR', name: 'TeleSpeech', description: '方言识别更好' },
],
defaultModel: 'FunAudioLLM/SenseVoiceSmall',
credentialHint: '在 cloud.siliconflow.cn 创建的 API Key',
keyURL: 'https://cloud.siliconflow.cn/account/ak',
transcribe: openAITranscribe,
},
},
});实时服务的写法见插件仓库中的 plugins/aliyun-bailian/src/realtime.ts。
如果语音服务和模型用的是同一个 Key(例如 DeepSeek),把 credentialFrom 设为插件自己 providers 中的某个键。宿主就不再显示 Key 输入框,而是把这个服务商的 Key 放进 SpeechConfig.credential;Key 还没填写时,语音输入页面会提示用户先到 AI 服务设置中填写。示例见 plugins/deepseek/src/index.ts。
OCR 服务商
OCR 服务商负责识别手机自己读不出文字的文档,目前就是没有文字层的扫描版 PDF。插件在 ocr 中用 OcrProviderDef 描述每个服务。它总是使用插件自己某个模型服务商(credentialFrom)的地址和 Key,用户不需要额外设置。只有当用户附上这类文件、插件已开启、并且这个服务商已有 Key 时,宿主才会调用 recognize;否则文件仍像以前一样被拒绝。
interface OcrProviderDef {
name: string;
/** 使用哪个本插件模型服务商(`PluginDef.providers` 的键)的地址和 Key。 */
credentialFrom: string;
/** 按阅读顺序排列的识别结果,Markdown 格式,每页以 `## Page n` 标题开头。 */
recognize: (
config: { baseURL: string; credential: string; fetch: typeof fetch },
doc: OcrDocument,
signal?: AbortSignal,
) => Promise<string>;
}
/** 要识别的文档:用户附上的本地文件(`file` 可以放进 FormData 上传)。 */
interface OcrDocument {
file: Blob;
uri: string;
name: string;
mimeType: string;
}返回的 Markdown 要在每页前加 ## Page n 标题,这样助手可以像读其他文档一样引用和翻页。recognize 抛出错误时,附件会失败并提示「<name> 未能识别这个扫描版 PDF:<message>」,所以错误消息要写得让用户能看懂。name 会原样显示给用户。完整示例见 plugins/deepseek/src/tools.ts 中 DeepSeek 插件的 deepseekOcr。