# 社区插件开发指南 > 本文档面向社区开发者,描述如何开发用户可安装的插件。 > 系统插件(内嵌到 app 的内置功能)使用相同的插件格式,放在项目 `plugins/` 目录下随 app 打包分发。 ## 快速开始 1. 创建一个文件夹,放入一个工具文件: ```text my-plugin/ └── tools/ └── hello.js ``` ```js // tools/hello.js export const name = "hello"; export const description = "Say hello to someone"; export const parameters = { type: "object", properties: { name: { type: "string" } }, required: ["name"], }; export async function execute(input) { return `Hello, ${input.name}!`; } ``` 2. 打开 HanaAgent → 设置 → 插件,把文件夹拖进安装区(或压缩成 .zip 拖入) 3. 安装后 Agent 立即可以调用 `my-plugin_hello` 工具 4. 卸载:在插件页面点删除按钮 ## 从想法到插件 开发时先选插件形态,再按本页后续章节配置清单、运行时能力与界面: | 形态 | 适合什么 | 权限 | |------|----------|------| | Tool-only | 没有 UI,只给 Agent 增加工具能力 | `restricted` | | Runtime | 需要生命周期、EventBus、后台任务、动态工具 | `full-access` | | UI | 需要 page / widget / WebView/iframe card / `chat.surface` | `full-access` | | Marketplace entry | 让插件出现在插件市场 | 写入 `OH-Plugins/plugins/.yaml` | 推荐先用 `hana-plugin-creator` 脚手架生成,再按需求删减: ```bash python3 skills2set/hana-plugin-creator/scripts/create_hana_plugin.py "My Plugin" --path examples/plugins --kind full ``` 调试顺序:本地文件夹安装 → 设置页诊断 → 补 README/manifest → 需要公开时再写 `OH-Plugins` 市场条目。 ### Agent 辅助开发循环 当 Hana / Codex 这类 Agent 直接帮用户开发插件时,优先走 dev loop,而不是把半成品复制到正式插件目录: 1. 插件源码放在当前工作区,或 `${HANA_HOME}/plugin-dev-sources/`。 2. 调用 EventBus `plugin.dev.install` 或 HTTP `POST /api/plugins/dev/install`,把源码复制到 `${HANA_HOME}/plugins-dev/` 并加载。 3. 修改源码后调用 `plugin.dev.reload` 或 `POST /api/plugins/dev/:id/reload`。 4. 需要控制生命周期时调用 `plugin.dev.disable`、`plugin.dev.enable`、`plugin.dev.reset`、`plugin.dev.uninstall`,或对应 HTTP:`PUT /api/plugins/dev/:id/enabled`、`POST /api/plugins/dev/:id/reset`、`DELETE /api/plugins/dev/:id`。 5. 工具插件用 `plugin.dev.invokeTool` 或 `POST /api/plugins/dev/:id/tools/:toolName/invoke` 做 smoke test。调用体优先传 `sessionId` 或 `sessionRef`;`sessionPath` 只作为旧插件兼容 locator。 6. 诊断用 `plugin.dev.diagnostics` 或 `GET /api/plugins/dev/diagnostics`。 Agent 可见的 dev 工具默认关闭。用户需要在设置 → 插件 → 权限中开启"允许 Agent 插件开发工具",开启后 Agent 才会看到 `plugin_dev_install`、`plugin_dev_reload`、`plugin_dev_disable`、`plugin_dev_enable`、`plugin_dev_reset`、`plugin_dev_uninstall`、`plugin_dev_invoke_tool`、`plugin_dev_diagnostics`、`plugin_dev_list_surfaces`、`plugin_dev_describe_surface`、`plugin_dev_run_scenario`。 开发态权限来自 Hana 记住的 dev slot,而不是 manifest 自己声明。`devRunId` 是一次 dev install/reload 的运行护栏,调用 enable/disable/reset/uninstall 时建议带上,避免旧上下文误操作新的开发槽。dev 操作只允许作用于 `${HANA_HOME}/plugins-dev/` 中的 runtime copy,不会写入 `${HANA_HOME}/plugins/`,也不会污染正式插件的禁用偏好。 `full-access` dev 插件必须显式传 `allowFullAccess: true`,全局社区插件开关不会自动授权开发态插件。 UI 插件调试时,先用 `plugin.dev.listSurfaces` 找到 page / widget,再用 `plugin.dev.describeSurfaceDebug` 获取 element-first 调试说明。Agent 应先读取可访问性树、文本、role、label 等语义元素并直接点击/输入,截图只用于视觉确认、布局检查,或语义信息不足时兜底。 #### Dev Scenarios `manifest.json` 可以声明 `dev.scenarios`,只供本地开发和 Agent smoke test 使用,生产运行时会忽略这组字段。 ```json { "dev": { "scenarios": [ { "id": "hello-tool", "steps": [ { "invokeTool": { "name": "hello", "input": { "name": "Hana" } } }, { "expectToolText": "hello Hana" } ] } ] } } ``` `invokeTool` 步骤可以包含 `sessionId`、`sessionRef`、`sessionPath` 和 `agentId`,其中 `sessionId/sessionRef` 是当前推荐的 session 身份;`sessionPath` 只保留给旧插件和 locator 兼容。第一阶段支持 `invokeTool`、`expectToolText` 和 `openSurface`。会改外部状态的场景必须声明 `"destructive": true`,运行时还要显式传 `allowDestructive: true`。 ## 安装与管理 ### 安装方式 - **拖拽安装**:将插件文件夹或 .zip 拖入设置 → 插件页面的安装区 - **文件选择器**:点击安装区,通过文件选择器选择插件文件夹或 .zip - **手动安装**:将插件目录放到 `${HANA_HOME}/plugins/`。实际目录可在设置 → 插件页面或 `/api/plugins/settings` 的 `plugins_dir` 查看 ### 管理操作 所有操作即时生效,无需重启: - **启用/禁用**:每个插件有独立开关 - **删除**:移除插件代码,插件数据(`plugin-data/{pluginId}/`)保留 - **升级**:拖入同名新版本会自动 unload 旧版并加载新版;生命周期资源由 `onunload` / disposables 清理 ### 插件数据 插件私有数据自动存放在 `${HANA_HOME}/plugin-data/{pluginId}/`。删除插件时此目录保留,重新安装后配置还在。 ## 目录结构 ```text my-plugin/ ├── manifest.json # 可选,复杂声明才需要 ├── tools/ # 工具(Agent 调用) │ └── *.js ├── skills/ # 知识注入(Markdown) │ └── my-skill/ │ └── SKILL.md ├── commands/ # 用户命令(斜杠触发) │ └── *.js ├── agents/ # Agent 模板(JSON) │ └── *.json ├── routes/ # HTTP 路由(需要 full-access) │ └── *.js ├── providers/ # Provider 声明:聊天/媒体能力(需要 full-access) │ └── *.js ├── extensions/ # Pi SDK extension 工厂(需要 full-access) │ └── *.js └── index.js # 可选,有状态 plugin 入口,最后加载(需要 full-access) ``` 标注"需要 full-access"的贡献类型,仅在 manifest 声明 `"trust": "full-access"` 且用户开启全权开关后才生效。 ## 权限模型 社区插件分两级权限。这个划分决定了插件能使用哪些系统能力。 ### Restricted(默认) 不需要在 manifest 里声明,社区插件默认就是 restricted。 **可以做的事:** | 能力 | 说明 | |------|------| | `tools/*.js` | 声明工具供 Agent 调用 | | `skills/` | Markdown 知识注入 | | `commands/*.js` | 用户命令 | | `agents/*.json` | Agent 模板(JSON 声明) | | `ctx.config` | 读写自己的配置 | | `ctx.dataDir` | 自己的数据目录 | | `bus.emit / subscribe / request` | 发布事件、订阅事件、调用别人的能力 | | `contributes.configuration` | JSON Schema 配置声明 | **不能做的事:** `bus.handle`、routes、extensions、providers、`registerTool`、lifecycle(onload/onunload)。 restricted 插件的 tool/command 代码在主进程运行,有完整的 Node.js API 访问能力。权限模型管的是"系统给你什么扩展接口",不是代码级沙盒。 ### Full-access 在 manifest 中声明 `"trust": "full-access"`: ```json { "id": "my-advanced-plugin", "trust": "full-access", "minAppVersion": "0.82.0" } ``` `minAppVersion`(可选)声明插件运行所需的最低 HanaAgent 版本。如果当前 app 版本低于该值,插件不会加载,状态标记为 `incompatible`。建议所有插件都声明此字段,避免用户在旧版本上遇到不兼容问题。 用户需要在设置 → 插件页面开启"允许全权插件"开关。**开关关着时,full-access 插件完全不会加载**(不会部分加载),直到用户主动打开开关。 在 restricted 基础上额外获得: | 能力 | 说明 | |------|------| | `bus.handle` | 注册能力供其他 plugin 调用 | | `routes/*.js` | HTTP 端点 | | `extensions/*.js` | Pi SDK 事件拦截(tool 调用、provider 请求等) | | `providers/*.js` | Provider 声明:聊天/媒体能力 | | `ctx.registerTool` | 运行时动态注册工具 | | `onload` / `onunload` | 生命周期钩子 | **没有声明 `trust` 或声明为其他值的插件,一律按 restricted 处理。** ## 贡献类型详解 ### Tools(工具) `tools/*.js` 每个文件 export: ```js export const name = "search"; // 必须 export const description = "..."; // 必须 export const parameters = { ... }; // JSON Schema,可选 export async function execute(input, toolCtx) { // 必须 // input: 用户传入的参数 // toolCtx: { pluginId, pluginDir, dataDir, sessionId, sessionRef, sessionPath, bus, network, config, log, registerSessionFile, stageFile } return "result"; } ``` - 自动加命名空间前缀:`pluginId_name`(如 `my-plugin_search`) - restricted 插件的 `toolCtx.bus` 只有 `emit/subscribe/request`,没有 `handle` - 新插件可以使用 `@hana/plugin-runtime` 的 `defineTool()` 获得类型和默认参数;当前静态 `tools/*.js` loader 仍读取命名导出。 - Agent 可调用工具应声明 `sessionPermission`。纯读取工具用 `readOnly: true`;只写 `ctx.dataDir` 并通过 `stageFile()` 返回 `SessionFile` 的工具用 `kind: "plugin_output"`;会访问外部 provider、网络、平台账号或真实世界副作用的工具用 `kind: "external_side_effect"`,Auto 模式会交给 reviewer。修改用户工作区文件的工具默认保持 reviewer-bound,除非能用 `describeSideEffect(input)` 明确描述更窄的副作用。 - 定时自动化的 `plugin_action` v0 复用工具入口:`pluginId/actionId` 会映射到 `pluginId_actionId` 工具。cron 只保存 `pluginId`、`actionId` 和 JSON 参数;插件作者写的静态 `tools/*.js` 与动态 `ctx.registerTool()` 工具都会收到 SDK 风格的 `(input, ctx)` 调用;插件缺失、工具缺失或插件被禁用时,任务执行失败并记录运行历史,不会自动降级成 Agent 会话。 ```js import { defineTool } from '@hana/plugin-runtime'; const tool = defineTool({ name: "search", description: "Search project data", parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, sessionPermission: { readOnly: true }, async execute(input, ctx) { ctx.log.info("search", input.query); return `results for ${input.query}`; } }); export const { name, description, parameters, execute } = tool; ``` #### 用户资源访问 插件需要读取或修改用户资源时,使用 `ctx.resources`,资源可以是本地文件、挂载文件、`SessionFile`、Resource 记录或 URL。manifest 里按需声明能力: ```json { "capabilities": ["resource.read", "resource.search", "resource.write"] } ``` ```js export async function execute(input, ctx) { const ref = { kind: "mount", mountId: input.mountId, path: input.path }; const file = await ctx.resources.read(ref); await ctx.resources.write(ref, file.content.toString("utf-8") + "\nupdated\n"); return "updated"; } ``` `resource.read` 覆盖 `stat`、`read`、`list`;`resource.search` 覆盖搜索,包括 provider 选项里的文件名搜索;`resource.write` 覆盖 `write`、`writeExpectedVersion`、`edit`、`mkdir`、`delete`、`copy`、`rename`、`move`、`trash`;`resource.materialize` 用于把资源实体化成本机路径;`resource.watch` 覆盖 `ctx.resources.watch()` / `ctx.resources.subscribe()` 后端监听订阅。URL resource 保持只读。插件自己生成的文件仍然可以写到 `ctx.dataDir`,再通过 `stageFile()` 返回;用户资源读写不要直接用本地路径和 `fs.writeFileSync`。 `ctx.resources.watch(ref)` 用于单个资源,`ctx.resources.subscribe([refA, refB])` 用于一组资源,返回 `{ subscriptionId, resourceKeys, unsubscribe, close }`。生命周期插件应把 `unsubscribe` 交给 `register()`,短任务应在 `finally` 中释放。资源变化仍通过插件 bus 的 `resource.changed` / `resource.deleted` / `resource.renamed` 事件到达,消费侧按 `resourceKeys` 过滤后再刷新或重新读取。 ResourceIO 是用户资源的唯一权限入口。`local-file`、`mount`、`session-file`、`resource`、`url` 都是资源身份,不等于插件能拿到宿主本机路径。`stageFile()` 只用于插件生成物进入 `SessionFile` 交付链路,不用于修改用户源文件。`ctx.dataDir` 和插件包内 `assets/` 是插件自有存储,可以使用 raw `fs`;工作区、挂载、URL、SessionFile 输入不能套用这个例外。第三方库必须吃本机路径时,用 `ctx.resources.materialize(ref)`,写回仍然要显式走 ResourceIO,而不是把 materialized 文件当源文件直接改。 #### 媒体交付 工具需要交付文件时,使用 `toolCtx.stageFile()` 把本地文件登记成当前 session 的 `SessionFile`,并直接复用它返回的 `mediaItem`: ```js import { createMediaDetails } from "@hana/plugin-runtime"; const staged = toolCtx.stageFile({ sessionId: toolCtx.sessionId, sessionRef: toolCtx.sessionRef, filePath: "/path/to/image.png", label: "image.png", }); return { content: [{ type: "text", text: "已生成图片" }], details: createMediaDetails([staged]), }; ``` 框架会自动提取 `details.media` 并根据上下文投递:桌面端渲染文件卡片,Bridge 按平台能力发送给对方,Mobile PWA / 远程前端通过 `SessionFile` / Resource 身份读取。新协议优先消费 `details.media.items` 里的结构化 `session_file`;`mediaUrls` 只保留为兼容旧工具和远程 URL 的字段,不建议新插件使用。本地文件不得通过 `MEDIA:/path`、`file://` 或 `mediaUrls` 绕过 `stageFile()` / `stage_files`,必须先登记成 `session_file`。内置 `stage_files` 会自动登记 SessionFile 并返回结构化媒体项,插件交付用户可见文件时应复用这条语义,不要让插件自己判断运行平台,也不要自己创建私有文件卡片来替代 `SessionFile`。 插件直接产出本地文件时,调用 `toolCtx.stageFile({ sessionId, sessionRef, filePath, label })` 绑定到当前 session,并得到可直接放入 `details.media.items` 的 `mediaItem`。`registerSessionFile` 仍保留为低层兼容 API,新插件应优先使用 `stageFile`,这样文件归属和媒体交付不会被拆散。`sessionId` / `sessionRef` 是新协议,`sessionPath` 仅作为旧插件兼容字段;`filePath` 必须是绝对路径。框架会把这类文件记为 `storageKind: "plugin_data"`,它们属于插件数据或生成结果,不会被 session 临时缓存清理器删除。插件不应把任意本地路径标成临时缓存,缓存生命周期由框架拥有。 几条边界: - 插件生成的文件:`origin: "plugin_output"`,走 `storageKind: "plugin_data"` - 插件异步生成的文件:后台任务完成时仍要登记 `SessionFile`;如果工具先返回 card,card 只负责展示任务状态和结果引用 - 用户上传、Bridge 入站、浏览器截图、旧 `create_artifact` 兼容工具输出等临时产物由框架登记为 `managed_cache` - 安装来源(`.skill`、plugin 目录或 zip):由安装 route 登记为 `install_source` - Card 负责呈现交互界面,文件仍然是资源;卡片需要引用文件时,应引用 `SessionFile`,不要把文件内容塞进 card payload #### 外部数据访问 插件需要实时比分、天气、行情、外部搜索结果或第三方平台数据时,新代码应通过宿主提供的 `ctx.network.fetch()` 访问外部 HTTP API。iframe 页面只调用本插件自己的 route,例如 `hana.api.fetch("api/live-scores")`;route handler 再调用 `ctx.network.fetch("https://...")`。这样 iframe 认证、外部域名声明、超时、缓存和响应大小限制都在宿主边界内统一处理。 `ctx.network.fetch()` 需要 manifest 显式声明 `network.fetch`,并列出允许访问的主机: ```json { "trust": "full-access", "capabilities": ["network.fetch"], "network": { "allowedHosts": ["site.api.espn.com"], "methods": ["GET"], "defaultTimeoutMs": 8000, "maxResponseBytes": 1048576 } } ``` ```js // routes/api.js route.get("/live-scores", async (c) => { const ctx = c.get("pluginCtx"); const res = await ctx.network.fetch( "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard", { cacheTtlMs: 30_000 }, ); return c.json(await res.json()); }); ``` 边界规则: - `allowedHosts` 必须写主机名,可用 `*.example.com` 匹配子域;空列表拒绝所有外部主机 - 默认只允许 `GET`;需要 `POST`、`PUT` 等方法时在 `network.methods` 中显式声明 - 默认只允许 HTTPS;`http://127.0.0.1`、`localhost`、私网地址必须额外声明 `"allowLocalhost": true` - `timeoutMs`、`cacheTtlMs`、`maxResponseBytes` 可在单次调用覆盖 manifest 默认值 - API key、token、cookie 不得写进 `assets/` 或 iframe JS;用 configuration schema 保存,由 route 在服务端读取 - 旧插件已经直接在 Node route 中使用 `fetch()` 的路径继续兼容;新插件、模板和 Agent 生成代码应使用 `ctx.network.fetch()`,这样诊断能指出缺少能力声明、域名、方法或大小限制 #### 可视化卡片 工具可以在聊天中自动渲染可视化卡片,在返回值的 `details` 中声明 `card`。当前有两条稳定形态: - `type: "iframe"` / `type: "webview"`:用于插件自己的 Web UI、远程网站、单独 HTML 或复杂浏览器 UI。旧 `iframe` 卡继续兼容;新文档把它定位成正式 WebView escape hatch。 - `type: "chat.surface"`:用于把插件自己创建的 `plugin_private` / `private` session 作为原生聊天 transcript 嵌进当前聊天流。它只接受 `sessionId/sessionRef`,宿主会校验该 session 属于当前 plugin 且不是公开 session。 命名边界:`workbench` 是旧代码 namespace,不作为新插件作者需要学习的公开概念。当前公开能力以 `chat.surface` 的原生 transcript 和 WebView/iframe UI 为准。 WebView 卡片示例: ```js return { content: [{ type: "text", text: "数据摘要..." }], details: { card: { type: "webview", route: "/card/chart?symbol=sh600519&period=daily", title: "贵州茅台 日K", description: "贵州茅台 现价1450.00 涨跌+2.11%", }, }, }; ``` - `route`:插件路由路径,WebView / iframe 自行从该路径拉数据渲染 - `title`:卡片标题(可选) - `description`:纯文本摘要,用于 IM 平台降级显示和插件卸载后的 fallback - `pluginId` 由框架自动注入,工具无需填写 - 卡片在工具完成时立即渲染,不依赖 LLM 行为 - 卡片数据随 toolResult 存入 JSONL,会话重载时自动恢复 - 插件 route / Session Bus 发送的自定义消息如果携带同样的 `details.card`,也会被提取成 `plugin_card`,历史回放时保持一致 - 卡片本身可以随 Bridge 或移动端做不同呈现;卡片关联的文件仍通过 `SessionFile` 生命周期恢复 原生聊天 surface 示例: ```js import { createChatSurfaceCard, createSession } from "@hana/plugin-runtime"; const child = await createSession(ctx, { kind: "tavern-run", visibility: "plugin_private", cwd: ctx.dataDir, }); return { content: [{ type: "text", text: "已创建插件私有会话。" }], details: { card: createChatSurfaceCard(ctx, child.sessionRef ?? child, { title: "Tavern run", description: "插件私有会话 transcript", }), }, }; ``` `chat.surface` 在 main 当前版本只提供原生 transcript 展示;复杂 composer、可组合 native cards 和组件生态尚未进入公开 SDK 契约。 ### Skills(知识注入) `skills/*/SKILL.md`,标准 frontmatter 格式: ```markdown --- name: my-skill description: 这个 skill 做什么 --- # 正文内容 Agent 在需要时会自动加载这段知识。 ``` 零代码,和 Claude Code 的 skill 模式一致。 ### Commands(用户命令) `commands/*.js` 每个文件 export: ```js export const name = "focus"; export const description = "Start focus mode"; export async function execute(args, cmdCtx) { // args: 用户输入的参数文本 // cmdCtx: { sessionId, sessionRef, sessionPath, agentId, bus, config, log } } ``` ### Agents(Agent 模板) `agents/*.json`: ```json { "name": "Translator", "systemPrompt": "You are a translator.", "defaultModel": "gpt-4o", "defaultTools": ["web-search"] } ``` ### Routes(HTTP 路由)⚡ full-access `routes/*.js` 支持三种写法,自动挂载到 `/api/plugins/{pluginId}/...`: **写法 A:工厂函数**(推荐,ctx 作为参数直接可用) ```js // routes/chat.js export default function (app, ctx) { app.post("/send", async (c) => { const { text } = await c.req.json(); const result = await ctx.bus.request("session:send", { text, sessionId: ctx.sessionId, sessionRef: ctx.sessionRef, }); return c.json(result); }); } ``` **写法 B:静态 Hono app**(通过中间件取 ctx) ```js // routes/webhook.js import { Hono } from "hono"; const route = new Hono(); route.get("/webhook", (c) => { const ctx = c.get("pluginCtx"); return c.json({ ok: true, plugin: ctx.pluginId }); }); export default route; ``` **写法 C:register 导出** ```js // routes/status.js export function register(app, ctx) { app.get("/status", (c) => c.json({ pluginId: ctx.pluginId })); } ``` 三种写法向后兼容:不使用 ctx 的老插件无需改动。`ctx.bus` 可直接调用内置 session 操作:`session:create`、`session:get`、`session:update`、`session:send`、`session:abort`、`session:history`、`session:list`、`agent:list`、`agent:profile`、`agent:create`、`agent:update`。所有针对已有 session 的操作必须携带 `sessionId` 或 `sessionRef`;`sessionPath` 仅作为旧插件兼容输入。详见下方 Route Context 和 Session Bus Handlers 章节。 #### 请求级上下文(pluginRequestContext) 每个进入插件 route 的 HTTP 请求都会得到一份独立的请求级上下文。新 route 建议通过 `@hana/plugin-runtime` 的 `getPluginRequestContext(c)` 读取;老写法 `c.get("pluginRequestContext")` 仍兼容。 ```js import { getPluginRequestContext } from "@hana/plugin-runtime"; app.post("/create-session", async (c) => { const reqCtx = getPluginRequestContext(c); // reqCtx.principal 本次请求的来源身份(owner 设备 / 本插件 iframe surface…),测试直连时为 null // reqCtx.agentId 本次请求属于哪个 agent;为 null 表示这个表面此刻 // 不属于任何 agent(例如预览),不要当成"某个默认 agent" // reqCtx.capabilityGrant { accessLevel, declaredPermissions, legacyDeclaration } if (!reqCtx.agentId) { return c.json({ error: "这个表面还没有关联 agent" }, 400); } const result = await reqCtx.bus.request("session:create", { agentId: reqCtx.agentId }); return c.json(result); }); ``` `reqCtx.bus` 与 `ctx.bus` 的区别:通过它调用系统敏感能力(capability 目录里 `owner: "system"` 且带 `permission` 的条目,如 `session:create` → `session.write`)时按「manifest 声明 + 用户授权」校验。manifest `capabilities` / `sensitiveCapabilities` 未声明对应 permission(支持命名空间,`session` 即覆盖 `session.write`)返回 403 `PLUGIN_CAPABILITY_NOT_DECLARED`;插件未获 full-access 返回 403 `PLUGIN_CAPABILITY_NOT_GRANTED`。错误响应携带 `capability` / `permission` / `pluginId` / `declared` / `granted` 字段,可直接定位缺什么。完全没写能力声明的老 manifest(两个字段都缺失)视为 legacy(等同声明全部),行为不回退;一旦显式写出任一列表即按声明严格校验——显式空数组(`"capabilities": []`)不是 legacy,会拒绝所有系统敏感能力。处理来自 iframe 页面的请求时优先使用 `reqCtx.bus`。 ### Extensions(Pi SDK 事件拦截)⚡ full-access `extensions/` 目录下的每个 `.js` 文件导出一个工厂函数,接收 Pi SDK 的 `ExtensionAPI`,可以订阅 LLM 调用链上的事件: ```js // extensions/strip-empty-tools.js export default function(pi) { pi.on("before_provider_request", (event) => { const p = event.payload; if (p && Array.isArray(p.tools) && p.tools.length === 0) { delete p.tools; } return p; }); } ``` 常用事件: | 事件 | 时机 | 能做什么 | |------|------|----------| | `tool_call` | 工具调用前 | 修改参数、block 调用 | | `tool_result` | 工具返回后 | 修改返回结果 | | `before_provider_request` | HTTP 请求发出前 | 改写 payload | | `context` | 每次 LLM 调用前 | 过滤/注入消息 | | `before_agent_start` | 用户输入后 | 注入 system prompt | | `input` | 用户输入到达时 | 拦截/变换输入 | 工厂函数在 session 创建时被 Pi SDK 调用,handler 在对应事件触发时执行。安装、启用或 reload full-access 插件后,Hana 会让当前空闲 session 重新绑定 extension runner;正在 streaming、compacting 或切换中的 session 会跳过重绑,下一次安全重建时生效。完整事件列表参见 Pi SDK extension 文档。 `extensions/` 仍是 full-access 边界。restricted 插件即使包含 `extensions/` 目录也不会加载这些工厂;如果插件需要拦截 provider 请求、tool 调用或上下文构建,必须在 manifest 中声明 `"trust": "full-access"`,并由用户开启全权插件开关。 ### Providers(Provider Contribution)⚡ full-access `providers/*.js` export ProviderPlugin 数据对象: ```js export const id = "my-llm"; export const displayName = "My LLM Service"; export const authType = "api-key"; export const defaultBaseUrl = "https://api.my-llm.com/v1"; export const defaultApi = "openai-completions"; ``` Provider 可以声明多种 capability。聊天侧只消费 `capabilities.chat`,生图/生视频/生语音消费 `capabilities.media.*`。如果 provider 只提供媒体能力,把 `chat.projection` 设为 `"none"`,它就不会进入聊天模型列表: ```js export const id = "my-image-cli"; export const displayName = "My Image CLI"; export const authType = "none"; export const runtime = { kind: "local-cli", protocolId: "local-cli-media", command: { executable: "my-image-cli", args: [ { literal: "generate" }, { option: "--prompt", from: "prompt" }, { option: "--model", from: "modelId" }, { option: "--output", from: "outputDir" }, ], timeoutMs: 120000, output: { kind: "file_glob", directory: "outputDir", pattern: "*.png" }, }, }; export const capabilities = { chat: { projection: "none" }, media: { imageGeneration: { models: [ { id: "my-image-model", displayName: "My Image Model", protocolId: "local-cli-media", inputs: ["text"], outputs: ["image"], }, ], }, }, }; ``` CLI provider 必须使用结构化参数绑定。不要拼 shell 字符串;Hana 会通过 `execFile` / `spawn` 的非 shell 模式运行命令,并把输出收束进媒体任务目录。 #### Provider 与 Adapter 的边界 `providers/*.js` 是长期支持的 Provider Contribution 入口。它声明供应商、模型、能力、`protocolId`,进入 ProviderRegistry 后由聊天、图片、视频、语音等选择器统一发现。旧插件如果已经通过 `providers/*.js` 声明图片 provider,只需要补齐 `capabilities.media.imageGeneration` 和每个模型的 `protocolId`,不需要换入口。 媒体 Adapter 负责执行某个 `protocolId` 的 `submit` / `query` / 下载流程。只注册 Adapter 不等于注册供应商;没有 Provider capability 的模型不会自然出现在供应商管理、默认媒体模型选择、媒体 helper 发现结果里。 旧 `media-gen:*` 事件接口仍然保留给历史图片生成插件兼容,例如 `media-gen:register-adapter`、`media-gen:submit-image`、`media-gen:list-adapters`。新插件和 Agent 生成的模板不要调用这些旧命名空间;新建插件必须先通过 `providers/*.js` 声明 Provider capability,再使用稳定媒体 helper 或正式 Adapter Plugin API。迁移方向是: ```text ProviderPlugin capabilities.media.* │ ▼ ProviderRegistry 发现供应商和模型 │ ▼ MediaAdapterRegistry 按 protocolId 选择 Adapter │ ▼ UniversalMediaManager 统一任务、占位、轮询、SessionFile 回填 ``` 如果插件需要全新媒体协议,先声明 Provider capability,并为该 `protocolId` 提供 Adapter。正式 Adapter Plugin API 尚未稳定前,内置或受信插件可以继续使用旧 `media-gen:register-adapter` 作为过渡,但要把它视为兼容层,不能把它写进新插件脚手架或新 Agent 指南。 ### Configuration(配置 schema) 在 `manifest.json` 的 `contributes.configuration` 中声明配置 schema。Hana 会规范化字段、写入默认值、校验类型,并在设置 API 中自动隐藏敏感字段: ```json { "contributes": { "configuration": { "properties": { "interval": { "type": "number", "default": 25, "title": "工作间隔(分钟)", "scope": "global", "ui": { "control": "number" } }, "sound": { "type": "boolean", "default": true, "title": "结束提示音" }, "apiKey": { "type": "string", "title": "API Key", "sensitive": true, "ui": { "control": "password" } } } } } } ``` 配置通过 `ctx.config.get(key)` / `ctx.config.set(key, value)` 读写,持久化在 `plugin-data/{pluginId}/config.json`。旧插件没有 schema 时仍可自由读写平铺 key;声明了 schema 的插件会按字段类型、`enum` 和 `scope` 校验。 字段支持: - `type`: `string` / `number` / `integer` / `boolean` / `object` / `array` - `default` - `title` / `description` - `enum` - `scope`: `global` / `per-agent` / `per-session` - `sensitive`: 设置 API 返回时显示为 `********` - `ui`: 自动设置页的控件提示 - `reloadRequired` per-agent 和 per-session 配置要显式传归属: ```js await ctx.config.set("agentMode", "strict", { scope: "per-agent", agentId: "agent-123" }); const value = await ctx.config.get("agentMode", { scope: "per-agent", agentId: "agent-123" }); ``` ### Page(插件页面)⚡ full-access 插件可以在顶部 tab 栏注册一个全页面视图,跟「聊天/频道」同级。切换到该 tab 后,插件的 iframe 占据整个窗口空间。 在 `manifest.json` 的 `contributes` 中声明: ```json { "contributes": { "page": { "title": { "zh": "金融", "en": "Finance" }, "icon": "", "route": "/dashboard" } } } ``` - `title`:显示名,支持字符串或 `{ zh, en, ... }` 国际化对象 - `icon`:强烈建议提供内联 SVG(stroke 风格,`currentColor`)。缺省时取 title 首字 - `route`:插件 route 的相对路径,实际 URL 为 `/api/plugins/{pluginId}{route}` - 一个插件可以同时声明 `page` 和 `widget`,互不冲突 - 悬停 tab 时显示插件全名(tooltip) - Tab 超过 5 个时自动折叠到 overflow 下拉菜单,用户可拖拽排序 插件页面通过 WebView/iframe 渲染。旧 iframe 是兼容名称,新的插件设计可以把它理解成 WebView:适合展示已有 Web 应用、远程网站或单独 HTML。Hana 原生聊天 surface 不依赖 WebView/iframe。新插件建议使用 `@hana/plugin-sdk` 发送握手和宿主请求: ```js import { hana } from '@hana/plugin-sdk'; hana.ready(); hana.ui.resize({ height: 320 }); await hana.toast.show({ message: '已刷新', type: 'success' }); await hana.external.open('https://example.com'); await hana.clipboard.writeText('复制内容'); await hana.resources.open({ resource: { kind: 'session-file', fileId: 'sf_1' }, mode: 'preview' }); ``` 底层仍保留 `hana.host.request(type, payload)`,用于未来 capability 或实验能力;稳定能力优先使用 typed helper。 为兼容旧插件,宿主仍接受原始握手消息: ```js window.parent.postMessage({ type: 'ready' }, '*'); ``` 宿主只接受来自当前 iframe window 且 origin 匹配的消息。SDK 请求会经过 capability registry;当前内置能力包括 `toast.show`(无需授权)、`external.open`(需要授权)、`clipboard.writeText`(需要授权)以及资源请求类 `resource.open`、`resource.pick`、`resource.requestAccess`(均需要授权)。 需要授权的 iframe 宿主能力必须在 manifest 中声明: ```json { "manifestVersion": 1, "ui": { "hostCapabilities": ["external.open", "clipboard.writeText", "resource.open"] } } ``` 未声明的敏感能力会返回 `CAPABILITY_DENIED`。未知能力名会在加载时被忽略;`toast.show` 不需要声明。 `hana.resources.*` 只是在 iframe 中向宿主发请求:可以请求打开资源、选择资源、申请访问权限,但不能直接读取或写入文件内容。真正的用户资源读写仍然放在插件服务端 route、tool 或 lifecycle 里,通过 `ctx.resources` 进入 ResourceIO。 宿主会在 iframe URL 上附加 `hana-theme` 和 `hana-css` 参数,插件可选择引用主题 CSS 以保持视觉一致: ```html ``` 静态前端资源放在插件目录的 `assets/` 下,由 Hana 宿主通过 `/api/plugins/{pluginId}/assets/...` 统一服务。这个模型参考 VS Code Webview 的资源边界:入口 route 通过本地 token 或 `pluginIframeTicket` 打开,成功返回页面后,宿主下发一个只作用于 `/api/plugins/{pluginId}/assets/` 的 HttpOnly 短会话 cookie。Vite split chunks、`React.lazy()`、CSS、字体、图片、JSON、wasm、MP4/WebM/MOV 等浏览器可播放视频请求不需要也不应该携带 `?token` 或 `pluginIframeTicket`。视频资源支持 HTTP Range,`