# plugin (/docs/sdk/plugin)

> 定义插件时用到的全部类型：definePlugin、defineTool、definePreference、PluginDef、ToolDef、ToolContext、效果、调用方、能力需求、KV、轮次信息、数据源、设置项、模型、语音和 OCR 服务商、翻译和旧 id。

每个内置插件都导出一个用 `definePlugin` 定义的对象，里面写明插件的名称、需要的能力、提供的工具和界面等。这一页列出这个对象和工具的全部字段，以及工具运行时收到的上下文。

源码：`packages/sdk/src/plugin.ts`。

## definePlugin

```ts
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` 是插件对象本身。下面先给出完整的类型，再逐个说明字段。

```ts
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;
}
```

<TypeTable
  type={{
    id: { type: 'string', required: true, description: '注册 id：小写字母、数字和连字符（`PLUGIN.md` 插件必须匹配 `^[a-z0-9][a-z0-9-]{1,40}$`）。所有工具 id 都以它为前缀。' },
    name: { type: 'string', required: true, description: '显示名称，用中文（英文写在 `i18n.en.name`）。' },
    description: { type: 'string', required: true, description: '一句话，中文。' },
    version: { type: 'string', required: true, description: '插件版本号，遵循 semver。' },
    maxAgentSteps: { type: 'number', description: '仍可填写但会被忽略：从 App 1.0.16 起，单次回复没有步骤上限（改为在回复进行中压缩上下文）。' },
    icon: { type: 'string', description: "宿主图标集里的图标名，如 'card'、'book'、'alarm'。" },
    core: { type: 'boolean', description: '属于 Harness 核心体验：始终启用，不能关闭。' },
    requires: { type: 'Capability[]', description: '插件需要设备提供什么；有不满足的需求时插件会被隐藏。' },
    cloud: { type: 'CloudManifest', description: "如何使用 PKU harness Cloud（云服务）。没有写 `optional: true` 时，还要在 `requires` 里列出 'cloud'。" },
    preferences: { type: 'Record<string, PluginPreferenceDef>', description: '插件自己的设置项，以 `<plugin id>.<name>` 列入宿主的设置注册表，用户和助手在同一处读取和修改。见[设置项](#preferences)。注意它和 `contributes.settings`（设置页上的入口行）不同。' },
    remote: { type: '{ url: string; server: string }', description: '宿主为已安装的远程插件（通过 Model Context Protocol，即 MCP 连接）填写，不要自己设置。' },
    defaultModes: { type: "Partial<Record<ToolEffect, 'auto' | 'ask' | 'off'>>", description: '每种效果的默认授权方式，用户可以修改。', default: '全部 auto' },
    scopes: { type: 'string[]', required: true, description: '插件会访问的校园服务或主机，展示给用户看。' },
    tools: { type: 'Record<string, ToolDef>', required: true, description: '插件的 API，键是工具名。' },
    dataSources: { type: 'Record<string, DataSourceDef>', description: '适合给小组件和定时检查用的读取工具。' },
    providers: { type: 'Record<string, ProviderDef>', description: '模型服务商（服务商插件用）。' },
    speech: { type: 'Record<string, SpeechProviderDef>', description: '语音输入使用的语音识别服务，以本地 id 为键（宿主中的 id 为 `<plugin>.<key>`）。见[语音服务商](#speech-providers)。' },
    ocr: { type: 'Record<string, OcrProviderDef>', description: '识别手机自己读不出文字的文档（没有文字层的扫描版 PDF），以本地 id 为键。只有用户附上这类文件、并且 `credentialFrom` 指定的服务商已有 Key 时才会调用。见 [OCR 服务商](#ocr-providers)。' },
    instructions: { type: 'Dynamic<string>', description: "追加到助手的系统提示词末尾。写短一些；提到工具时用模型侧名称（`plugin__tool`）。" },
    context: { type: '(input: ContextInput) => Promise<string | undefined>', description: '每轮 Agent 对话前调用，返回的文本会标记为数据，在这一轮展示给模型。每次请求都要消耗 token。' },
    contributes: { type: 'Contributions', description: '页面、设置项、标签页、小组件、命令。' },
    i18n: { type: '{ en?: PluginTranslation }', description: "清单中给用户看的字符串的英文版本。给模型看的文本不翻译。" },
    legacy: { type: 'PluginLegacy', description: '这个插件取代的旧 id，用来让已保存的数据继续可用。' },
  }}
/>

## ToolDef

工具是插件提供给助手、用户和调度器调用的函数。`tools` 里的每一项都是一个 `ToolDef`。

```ts
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 };
};
```

<TypeTable
  type={{
    title: { type: 'string', required: true, description: '展示给用户（聊天中的工具标签、审批、设置），用中文。' },
    description: { type: 'Dynamic<string>', required: true, description: '写给模型看：什么时候用、怎么用这个工具。写成函数时可以读取宿主信息。' },
    input: { type: 'z.ZodType', required: true, description: '宿主在运行 `execute` 之前校验。' },
    effect: { type: 'ToolEffect', required: true, description: "'read' | 'write' | 'money' | 'public'." },
    untrusted: { type: 'boolean', description: "工具会返回第三方写的文本。调用后，这一轮 Agent 对话会被标记为受污染（tainted）。远程插件的工具一律算作此类。" },
    userOnly: { type: 'boolean', description: '只能由用户在页面里运行：不提供给模型，助手和调度器的调用都会被拒绝。' },
    onDemand: { type: 'boolean', description: '很少用到的工具（例如写插件用的工具）：即使属于核心插件，也不在对话开始时提供给模型，等助手用 `plugins.use` 加载这个插件后才提供。这样每次请求都更小。' },
    resolveEffect: { type: '(input, services) => Promise<ToolEffect | ToolAssessment>', description: '根据当前的外部状态判断出的额外效果（例如 Copilot 的一次点按会点到什么），和 `effect` 一起检查。返回的 `target` 会作为审批提示的标题。' },
    grant: { type: 'boolean', description: '这个工具会给助手它原本没有的访问权限。助手的每次调用都要等用户点按，不受授权方式影响（`auto` 不会跳过，`off` 照样拒绝）；定时任务的调用会被拒绝。提示提供「仅本次允许」和「始终允许」，用户的回答以 `ctx.grant` 传入。' },
    alwaysAsk: { type: 'boolean', description: '助手的每次调用都要经过审批，不受授权方式影响；定时任务的调用会被拒绝。用于宿主无法根据观察到的目标来判断的操作。`resolveEffect` 应说明将要发生什么。' },
    serial: { type: 'boolean', description: '这个插件中标记为 serial 的工具一次只运行一个（包括审批），按模型发出的顺序执行。用于作用在同一份共享状态上的工具。' },
    dayScoped: { type: 'boolean', description: '结果描述的是当天（按 App 的时区）。小组件不能显示更早某天取得的缓存结果，离线时的兜底除外。' },
    execute: { type: '(input: z.infer<I>, ctx: ToolContext) => Promise<O>', required: true, description: '执行实际工作。失败时抛出一个 Error，消息要写得让用户能看懂。' },
  }}
/>

## ToolContext

宿主调用 `execute` 时会传入第二个参数 `ctx`。它说明这次是谁在调用（`actor`），并提供插件私有的键值存储 `kv` 和宿主服务 `services`。

```ts
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`）说明插件需要设备具备什么条件。

```ts
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）
```

另见[工具与效果](/docs/concepts/tools-and-effects)和[能力需求](/docs/concepts/capabilities)。

## 动态值

有些字段（如 `instructions` 和工具的 `description`）既可以写成固定的值，也可以写成函数。写成函数时，宿主在构建提示词时调用它并传入 `HostServices`，你可以据此按当前状态生成内容。`resolveDynamic` 对这两种写法统一求值。

```ts
type Dynamic<T> = T | ((services: HostServices) => T);
function resolveDynamic<T>(value: Dynamic<T>, services: HostServices): T;
```

## DataSourceDef

数据源指向插件里一个适合给小组件和定时检查使用的读取工具，并给出调用参数 `args` 和缓存时长 `ttlMs`。

```ts
interface DataSourceDef {
  title: string;
  /** 本插件内的工具名。 */
  tool: string;
  args?: Record<string, unknown>;
  ttlMs: number;
}
```

## 请求上下文

`PluginDef.context` 函数在每轮对话前被调用，收到的参数是 `ContextInput`。

```ts
interface ContextInput {
  /** 用户最新的一条消息。 */
  query: string;
  kv: KV;
  services: HostServices;
}

/** 用来撤回先前展示过的事实的上下文行标记。 */
const CONTEXT_MARK = { deleted: '[已删除]', corrected: '[更正]', arrow: ' → ' } as const;
```

当一条事实被删除或更正时，宿主会追加 `- [已删除] old` 或 `- [更正] old → new` 这样的行。它只在历史里仍然显示 `old` 的对话中追加，而且只追加一次。因此，事实文本本身不能含有这个箭头。

## PluginTranslation

插件清单中的名称、描述、标题等默认用中文写。`i18n.en` 用这个类型提供英文版本，没有写出的字段沿用中文。

```ts
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`。宿主不需要任何改动。详见[架构 → 助手能操作一切](/docs/concepts/architecture#agent-operates-everything)。

源码：`packages/sdk/src/settings.ts`。

```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;
}
```

<TypeTable
  type={{
    title: { type: 'string', required: true, description: '显示在设置列表里，用中文；英文写在 `i18n.en.preferences.<name>.title`。' },
    description: { type: 'string', description: '一句话说明它改变什么。' },
    kind: { type: "'choice' | 'boolean' | 'text' | 'list'", required: true, description: '`choice` 取 `options` 中的一个值；`list` 是字符串列表。' },
    options: { type: '{ value: string; label: string }[]', description: '`choice` 类型的可选值。标签可以在 `i18n.en.preferences.<name>.options` 中翻译。' },
    input: { type: 'z.ZodType<T>', required: true, description: '在 `set` 运行前校验每个新值，校验失败时这次修改失败。' },
    get: { type: '(ctx: SettingContext) => Promise<T>', required: true, description: '读取当前值，通常来自 `ctx.kv`。' },
    set: { type: '(ctx: SettingContext, value: T) => Promise<void>', required: true, description: '写入校验过的值。也可以调用宿主服务，例如结束正在运行的会话。' },
    screen: { type: 'string', description: '显示这个设置项的 App 路由，供 `settings.open` 使用。默认是插件的第一个页面。' },
  }}
/>

设置项不是机密。不要把 API Key 或密码声明为设置项：助手能读取和修改每一个设置项。宿主自己的机密是标记为 `uiOnly` 的条目，只有用户能设置。

Copilot 插件这样声明它的截图开关：

```ts
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

```ts
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` 描述一个服务商。

```ts
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`。

```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),
    },
  },
});
```

## 语音服务商

语音服务商把用户说的话转成文字，供对话框的[语音输入](/docs/manual/voice-input)使用。它和模型服务商类似：用户使用自己的 Key，录音从手机直接发送到 `baseURL`。插件在 `speech` 中用 `SpeechProviderDef` 描述每个服务，宿主中的 id 为 `<plugin>.<key>`。

实时服务实现 `stream`，用户说话时文字同步出现；只接受完整录音的服务实现 `transcribe`。使用 `transcribe` 时，宿主会在用户停顿处把录音切成小段陆续发送，所以用户说完时大部分已经转好。只有 `transcribe` 能处理作为附件的音频文件。

```ts
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>"`。

下面是精简后的硅基流动插件：

```ts
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`；否则文件仍像以前一样被拒绝。

```ts
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`。
