diff --git a/docs/examples/provider-example/README.md b/docs/examples/provider-example/README.md new file mode 100644 index 00000000..bfa4e6b6 --- /dev/null +++ b/docs/examples/provider-example/README.md @@ -0,0 +1,36 @@ +# Provider 示例插件 + +这是一个最小可参考的 provider 插件骨架,演示如何通过 `providers` 声明 + `ztools.registerProvider` 接入「翻译」与「OCR」能力。 + +> 此目录为文档示例,handler 使用 mock 实现,不可作为正式插件直接安装运行。真实接入请参考 `docs/provider-development-guide.md` 替换 handler 逻辑。 + +## 文件说明 + +- `plugin.json` —— 声明了 `providers.translation` 与 `providers.ocr`(此处 key 恰好等于 type,作为最简兼容示例;多声明见下方说明)。 +- `preload.js` —— 调用 `ztools.registerProvider` 注册两个 provider 的 mock 实现。 + +## 同一 type 多条声明 + +当一个插件要提供多个同类渠道(如百度、谷歌两个翻译),用不同 key 声明同一 type 即可: + +```json +"providers": { + "baidu": { "type": "translation", "label": "百度翻译" }, + "google": { "type": "translation", "label": "谷歌翻译" } +} +``` + +```js +ztools.registerProvider('baidu', async (input) => { /* ... */ }) +ztools.registerProvider('google', async (input) => { /* ... */ }) +``` + +详见主程序 `docs/provider-development-guide.md`。 + +## 接入后会怎样 + +安装声明了 `providers` 的插件后: + +1. 「设置 → 提供商」的「翻译」「OCR」tab 会自动列出该 provider。 +2. 用户可启用 / 设为默认。 +3. 消费方(如超级面板选中翻译)调用 `providerManager.invoke(type, input)` 时会路由到默认 provider。 diff --git a/docs/examples/provider-example/plugin.json b/docs/examples/provider-example/plugin.json new file mode 100644 index 00000000..9c9c1551 --- /dev/null +++ b/docs/examples/provider-example/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "provider-example", + "title": "Provider 示例", + "description": "演示如何通过 providers + registerProvider 提供翻译与 OCR 能力(仅示例,handler 为 mock 实现)", + "version": "1.0.0", + "main": "index.html", + "preload": "preload.js", + "author": "ZTools", + "logo": "logo.png", + "features": [ + { + "code": "demo", + "explain": "Provider 示例(无实际功能,仅用于展示 providers 声明)", + "cmds": ["provider 示例"] + } + ], + "providers": { + "translation": { + "type": "translation", + "label": "示例翻译", + "description": "Mock 翻译 provider,仅用于演示接入流程" + }, + "ocr": { + "type": "ocr", + "label": "示例 OCR", + "description": "Mock OCR provider,仅用于演示接入流程" + } + } +} diff --git a/docs/examples/provider-example/preload.js b/docs/examples/provider-example/preload.js new file mode 100644 index 00000000..de581783 --- /dev/null +++ b/docs/examples/provider-example/preload.js @@ -0,0 +1,28 @@ +/** + * Provider 示例插件 preload + * + * 演示如何按契约注册 translation / ocr 两个 provider。 + * 这里用 mock 实现演示接入流程;真实插件请替换为对自身服务的调用。 + */ + +// 翻译 provider:入参 { text, from?, to? },返回 { text, detectedFrom? } +ztools.registerProvider('translation', async (input) => { + const { text } = input + // === mock:真实场景替换为你的翻译 API 调用 === + return { + text: `[示例翻译] ${text}`, + detectedFrom: 'auto' + } +}) + +// OCR provider:入参 { image, lang? },返回 { text, blocks?, confidence? } +ztools.registerProvider('ocr', async (input) => { + const { image } = input + // === mock:真实场景替换为你的 OCR API 调用(image 可为路径/dataURI/URL) === + console.log('[provider-example] ocr called with image:', image) + return { + text: '[示例 OCR 结果] 此处为识别到的文本', + blocks: ['[示例 OCR 结果] 此处为识别到的文本'], + confidence: 0.99 + } +}) diff --git a/docs/provider-development-guide.md b/docs/provider-development-guide.md new file mode 100644 index 00000000..d1508d36 --- /dev/null +++ b/docs/provider-development-guide.md @@ -0,0 +1,229 @@ +# Provider(提供商)开发指南 + +ZTools 把「翻译」「OCR」等能力抽象为 **Provider(提供商)**。主程序不再硬编码这些能力的实现,而是由插件按统一契约提供,主程序负责聚合、展示与调用。 + +> AI 模型不纳入 provider 抽象,仍走独立的 AI 模型配置。 + +--- + +## 支持的 Provider 类型 + +| type | 说明 | 入参 | 返回 | +| ------------- | ------------ | ---------------------- | -------------------------------- | +| `translation` | 文本翻译 | `{ text, from?, to? }` | `{ text, detectedFrom? }` | +| `ocr` | 图片文字识别 | `{ image, lang? }` | `{ text, blocks?, confidence? }` | + +- `translation.image` / `ocr.image` 可为:本地路径 / `data:` URI / `http(s)` URL(具体支持取决于实现)。 +- 完整契约定义见主程序源码 `src/shared/providerShared.ts`。 + +--- + +## 第一步:在 plugin.json 声明 providers + +在插件的 `plugin.json` 中新增 `providers` 字段,声明本插件提供哪些 provider。`providers` 的 **key 为插件内唯一标识**(任意字符串,不必等于 type),value 才是 `{ type, label?, description? }`。 + +```json +{ + "name": "my-cloud-ocr", + "title": "云 OCR", + "providers": { + "ocr": { + "type": "ocr", + "label": "云 OCR", + "description": "基于云端 API 的图片文字识别" + } + } +} +``` + +- `providers` 的 key 即「声明 key」,后续 `ztools.registerProvider(key, handler)` 的首参必须与之一致。 +- 一个插件可同时声明多个 type(如同时提供 `translation` 和 `ocr`)。 +- **同一 type 可声明多条**,只要 key 不同即可(见下方「同一 type 多条」示例)。 +- `label` / `description` 会展示在「设置 → 提供商」对应 tab 中。 + +声明后,安装该插件即在「设置 → 提供商 → OCR/翻译」tab 列出该 provider,用户可启用并设为默认。 + +### 同一 type 多条 + +当一个插件想提供多个同类渠道(如百度、谷歌两个翻译),可用不同 key 声明同一 type: + +```json +{ + "providers": { + "baidu": { "type": "translation", "label": "百度翻译" }, + "google": { "type": "translation", "label": "谷歌翻译" } + } +} +``` + +```js +// preload.js +ztools.registerProvider('baidu', async (input) => { /* 调用百度 */ }) +ztools.registerProvider('google', async (input) => { /* 调用谷歌 */ }) +``` + +两条都会出现在「设置 → 提供商 → 翻译」,用户可分别启用并选其中一个为默认。 + +--- + +## 第二步:在 preload 注册 handler + +在插件 preload 脚本中调用 `ztools.registerProvider(key, handler)`,**首参为声明 key**(与 plugin.json 的 providers key 一致),handler 签名必须匹配该声明的 type 对应契约: + +```js +// 插件 preload +ztools.registerProvider('ocr', async (input) => { + const { image, lang } = input + // 调用你的 OCR 服务 + const res = await fetch('https://your-ocr-api/recognize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image, lang }) + }) + const data = await res.json() + return { + text: data.text, + blocks: data.blocks, + confidence: data.confidence + } +}) +``` + +注意事项: + +- `registerProvider` 首参必须是 plugin.json `providers` 中已声明的 key,否则注册会被拒绝。 +- handler 是 `async` 函数,返回值需符合该 key 声明 type 的契约;入参缺失字段请自行兜底。 +- 一个插件对同一 key 只能注册一次;不同 key 各自对应独立 handler。 + +--- + +## 翻译 provider 示例 + +```js +ztools.registerProvider('translation', async (input) => { + const { text, from, to } = input + const res = await fetch('https://your-translate-api/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, from: from || 'auto', to: to || 'zh' }) + }) + const data = await res.json() + return { + text: data.translated, + detectedFrom: data.detectedSource + } +}) +``` + +--- + +## 内置 provider + +主程序内置了 **Bergamot 离线翻译引擎**(type `translation`,id `builtin-bergamot`),仅支持英译中。它与插件 provider 并列展示、可设为默认,用户可随时切换。 + +OCR 暂无内置实现,完全由插件提供。 + +--- + +## 作为消费方调用 provider 能力 + +任何插件(不仅是 provider 的提供方)都可以**主动发起一次翻译 / OCR**,复用用户已启用的 provider。这是 Provider 抽象的核心价值之一:能力可被跨插件复用。 + +### 通用入口:`ztools.providers` + +```js +// 查询某个 type 下的全部渠道,每个渠道带 isDefault 标记 +const list = await ztools.providers.getProviders('translation') +// → [{ id, type, label, description, source, isDefault }, ...] + +// 单独查询默认渠道(无可用时返回 null) +const def = await ztools.providers.getDefaultProvider('translation') +// → { id, type, label, ..., isDefault: true } | null + +// 统一调用入口:providerId 可选,缺省走该 type 的默认渠道 +const out = await ztools.providers.invokeProvider('translation', { text: 'hello', to: 'zh' }) +// → { text: '你好', detectedFrom?: 'en' } +``` + +`invokeProvider` 失败会抛错(如没有可用 provider、provider 加载超时等),调用方需 `try/catch`。 + +### 便捷封装:`ztools.translate` / `ztools.ocr` + +为最常用的两种调用提供语法糖,签名更贴近直觉: + +```js +// 翻译:options: { from?, to?, providerId? } +const result = await ztools.translate('hello', { from: 'en', to: 'zh' }) +console.log(result.text) // 翻译结果 +console.log(result.detectedFrom) // 实际识别到的源语言(可选) + +// OCR:options: { lang?, providerId? },image 为本地路径 / data URI / URL +const ocrResult = await ztools.ocr('/path/to/image.png', { lang: 'eng' }) +console.log(ocrResult.text) +``` + +- `providerId` 缺省时使用用户在该 type 下设置的默认渠道;显式传入则调用指定渠道。 +- `image` / `text` 的具体能力边界取决于所选 provider 的实现。 + +### 默认渠道选择规则 + +调用时若未显式指定 `providerId`,主进程按以下优先级选择: + +1. 用户在「设置 → 提供商」设为默认的 provider; +2. 该 type 下第一个启用的 provider; +3. 该 type 下第一个可用 provider(兜底)。 + +均无可用项时抛出「没有可用的 xxx 提供商」错误。 + +--- + +## 调用链路 + +1. 用户在「设置 → 提供商」启用 / 设为默认某个 provider。 +2. 消费方调用 `providerManager.invoke(type, input)`: + - 主程序内(如超级面板选中翻译)直接调用主进程方法; + - 插件通过 `ztools.providers.invokeProvider` / `ztools.translate` / `ztools.ocr` 发起,经统一分发器转发到同一个 `providerManager.invoke`。 +3. 主进程按默认 / 启用项选择 provider: + - 内置 provider:直接在主进程调用本地实现。 + - 插件 provider:按需预加载插件,等待 `registerProvider` 完成后回调 handler。 +4. handler 返回结果按契约透传给消费方。 + +--- + +## 调试 + +- provider 注册失败会在插件控制台抛错(如未声明该 type)。 +- 「设置 → 提供商」tab 可确认你的 provider 是否被识别、是否启用 / 默认。 +- 翻译可在超级面板选中文本时触发验证;插件内可直接 `await ztools.translate('hello')` / `await ztools.ocr(imagePath)` 验证。 + +## 完整 plugin.json 示例 + +```json +{ + "name": "cloud-providers", + "title": "云翻译与 OCR", + "description": "提供云端翻译与 OCR 能力", + "version": "1.0.0", + "main": "index.html", + "preload": "preload.js", + "features": [ + { + "code": "translate", + "explain": "翻译", + "cmds": ["翻译"] + } + ], + "providers": { + "translation": { + "type": "translation", + "label": "云翻译", + "description": "多语言云翻译" + }, + "ocr": { + "type": "ocr", + "label": "云 OCR", + "description": "高精度图片文字识别" + } + } +} +``` diff --git a/internal-plugins/setting/src/components/common/PluginDetail/PluginDetailHeader.vue b/internal-plugins/setting/src/components/common/PluginDetail/PluginDetailHeader.vue index 91773ded..d8b05257 100644 --- a/internal-plugins/setting/src/components/common/PluginDetail/PluginDetailHeader.vue +++ b/internal-plugins/setting/src/components/common/PluginDetail/PluginDetailHeader.vue @@ -1,4 +1,5 @@ + + + + + + + + {{ tab.label }} + + + + + + + + + + + + + + diff --git a/internal-plugins/setting/src/views/ProvidersSetting/components/OcrProviders.vue b/internal-plugins/setting/src/views/ProvidersSetting/components/OcrProviders.vue new file mode 100644 index 00000000..2a601abf --- /dev/null +++ b/internal-plugins/setting/src/views/ProvidersSetting/components/OcrProviders.vue @@ -0,0 +1,292 @@ + + + + + + + + + + + + {{ p.label }} + 插件 + 来自:{{ p.pluginName }} + + {{ p.description }} + + + + {{ isDefault(p) ? '默认' : '设为默认' }} + + + + + + + + + + + + + 暂无 OCR 提供商 + 安装支持 OCR 的插件即可接入,安装后会在此列出 + + + + + + diff --git a/internal-plugins/setting/src/views/ProvidersSetting/components/TranslationProviders.vue b/internal-plugins/setting/src/views/ProvidersSetting/components/TranslationProviders.vue new file mode 100644 index 00000000..86766426 --- /dev/null +++ b/internal-plugins/setting/src/views/ProvidersSetting/components/TranslationProviders.vue @@ -0,0 +1,440 @@ + + + + + + + + + + 离线翻译引擎(内置) + 内置 + + + 选中文字触发超级面板时,自动翻译为中文显示(使用 Bergamot 离线翻译引擎,首次启用需下载约 + 55MB 模型) + + + {{ engineStatusText }} + + + {{ engineError }} + + + + + + + + + + + + + 插件翻译提供商 + + + + + + + {{ p.label }} + 插件 + 来自:{{ p.pluginName }} + + {{ p.description }} + + + + {{ isDefault(p) ? '默认' : '设为默认' }} + + + + + + + + + + + + + 暂无翻译提供商 + 启用上方离线引擎,或安装支持翻译的插件即可接入 + + + + + + diff --git a/resources/preload.js b/resources/preload.js index 1400d0a8..a915c941 100644 --- a/resources/preload.js +++ b/resources/preload.js @@ -70,6 +70,8 @@ let logEntriesCallback = null let foundInPageCallback = null // 插件侧注册的 MCP 工具处理器,实际执行时由主进程回调到这里。 const registeredTools = new Map() +// 插件侧注册的 provider 处理器,按 type 存放,由主进程聚合后调用。 +const registeredProviders = new Map() /** * 创建懒注册的 IPC 事件监听器。 @@ -713,6 +715,73 @@ window.ztools = { return await handler(input ?? {}) }, + // 注册 provider(翻译、OCR 等)处理器。 + // 首参 key 需与 plugin.json 的 providers 字段 key 一致;type 由声明决定。 + // 一个插件可对同一 type 声明多条(如 baidu / google 都为 translation),只要 key 不同。 + registerProvider: (key, handler) => { + const providerKey = typeof key === 'string' ? key.trim() : '' + if (!providerKey) { + throw new Error('provider key 不能为空') + } + if (typeof handler !== 'function') { + throw new Error(`provider "${providerKey}" 的处理器必须是函数`) + } + + registeredProviders.set(providerKey, handler) + const result = electron.ipcRenderer.sendSync('plugin:provider-register', providerKey) + if (!result?.success) { + registeredProviders.delete(providerKey) + throw new Error(result?.error || `provider "${providerKey}" 注册失败`) + } + }, + + // 由主进程回调执行已注册的 provider 处理器,不对插件开发者直接暴露。 + __invokeRegisteredProvider: async (key, input) => { + const providerKey = typeof key === 'string' ? key.trim() : '' + const handler = registeredProviders.get(providerKey) + if (!handler) { + throw new Error(`provider "${providerKey}" 未注册`) + } + return await handler(input ?? {}) + }, + + // ==================== 作为消费方调用其它 provider 的能力 ==================== + // 查询/调用入口统一经 plugin.api 分发器走主进程 providerManager,不在 preload 侧持有状态。 + providers: { + // 查询某 type 下全部渠道,每个渠道带 isDefault 标记。type 缺省时返回所有 type 的渠道。 + getProviders: async (type) => { + return await ipcInvoke('providersGetProviders', { type }) + }, + // 单独查询某 type 的默认渠道(无可用时返回 null)。 + getDefaultProvider: async (type) => { + return await ipcInvoke('providersGetDefault', { type }) + }, + // 统一调用入口:providerId 可选,缺省走该 type 的默认渠道。 + invokeProvider: async (type, input, providerId) => { + return await ipcInvoke('providersInvoke', { type, input, providerId }) + } + }, + + // 翻译便捷封装:ztools.translate(text, { from?, to?, providerId? }) → { text, detectedFrom? } + translate: async (text, options = {}) => { + const { from, to, providerId } = options || {} + return await ipcInvoke('providersInvoke', { + type: 'translation', + input: { text, from, to }, + providerId + }) + }, + + // OCR 便捷封装:ztools.ocr(image, { lang?, providerId? }) → { text, blocks?, confidence? } + ocr: async (image, options = {}) => { + const { lang, providerId } = options || {} + return await ipcInvoke('providersInvoke', { + type: 'ocr', + input: { image, lang }, + providerId + }) + }, + // AI 调用 API ai: (option, streamCallback) => { const requestId = Math.random().toString(36).substr(2, 9) @@ -1101,6 +1170,37 @@ window.ztools = { await electron.ipcRenderer.invoke('internal:ai-models-delete', modelId) }, + // ==================== Provider(翻译 / OCR 等)管理 API ==================== + providers: { + getAll: async (type) => await electron.ipcRenderer.invoke('internal:providers-get-all', type), + getSettings: async () => await electron.ipcRenderer.invoke('internal:providers-get-settings'), + setEnabled: async (providerId, enabled) => + await electron.ipcRenderer.invoke('internal:providers-set-enabled', providerId, enabled), + setDefault: async (type, providerId) => + await electron.ipcRenderer.invoke('internal:providers-set-default', type, providerId), + getParams: async (providerId) => + await electron.ipcRenderer.invoke('internal:providers-get-params', providerId), + setParams: async (providerId, params) => + await electron.ipcRenderer.invoke('internal:providers-set-params', providerId, params), + // 翻译引擎(内置 Bergamot)状态与总开关 + getTranslationStatus: async () => + await electron.ipcRenderer.invoke('internal:providers-translation-status'), + setTranslationEnabled: async (enabled) => + await electron.ipcRenderer.invoke('internal:providers-translation-set-enabled', enabled) + }, + + // ==================== 网页快开 API ==================== + webSearch: { + getAll: async () => await electron.ipcRenderer.invoke('internal:web-search-get-all'), + add: async (engine) => await electron.ipcRenderer.invoke('internal:web-search-add', engine), + update: async (engine) => + await electron.ipcRenderer.invoke('internal:web-search-update', engine), + delete: async (engineId) => + await electron.ipcRenderer.invoke('internal:web-search-delete', engineId), + fetchFavicon: async (url) => + await electron.ipcRenderer.invoke('internal:web-search-fetch-favicon', url) + }, + // ==================== 悬浮球 API ==================== setFloatingBallEnabled: async (enabled) => await electron.ipcRenderer.invoke('floating-ball:set-enabled', enabled), diff --git a/src/main/api/index.ts b/src/main/api/index.ts index 92872819..51631e48 100644 --- a/src/main/api/index.ts +++ b/src/main/api/index.ts @@ -31,6 +31,7 @@ import pluginInputAPI from './plugin/input' import internalPluginAPI from './plugin/internal' import pluginLifecycleAPI from './plugin/lifecycle' import { initPluginApiDispatcher } from './plugin/pluginApiDispatcher' +import pluginProvidersAPI from './plugin/providers' import pluginRedirectAPI from './plugin/redirect' import pluginScreenAPI from './plugin/screen' import pluginShellAPI from './plugin/shell' @@ -44,6 +45,7 @@ import pluginFFmpegAPI from './plugin/ffmpeg' import httpServer from '../core/httpServer' import mcpServer from '../core/mcpServer' +import providerManager from '../core/provider/providerManager' import { runStartupDataMigrations } from '../core/startupDataMigrations' import superPanelManager from '../core/superPanelManager' import translationManager from '../core/translationManager' @@ -109,6 +111,10 @@ class APIManager { // 初始化插件API pluginToolsAPI.init(pluginManager) + // Provider 管理器(翻译、OCR 等),需在插件 API 之后、消费方之前初始化 + providerManager.init(pluginManager) + // 暴露给所有插件的 provider 消费入口(查询默认渠道 / 调用),依赖 providerManager 已就绪 + pluginProvidersAPI.init() pluginAiAPI.init(mainWindow, pluginManager) pluginLifecycleAPI.init(mainWindow, pluginManager) pluginUIAPI.init(mainWindow, pluginManager) @@ -155,6 +161,21 @@ class APIManager { // 初始化翻译管理器 translationManager.init() + // 将内置 Bergamot 翻译引擎注册为 translation 类型 provider + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线翻译引擎(Bergamot)', + description: '英译中离线引擎,首次启用需下载约 55MB 模型', + isReady: () => translationManager.getStatus().status === 'ready', + invoke: async (input) => { + const { text, from } = input as { text: string; from?: string; to?: string } + // 当前内置引擎仅支持 en→zh,忽略 from/to 参数 + const result = await translationManager.translate(text) + return { text: result || '', detectedFrom: from } + } + }) + // 设置一些特殊的IPC处理器 this.setupSpecialHandlers() diff --git a/src/main/api/plugin/internal.ts b/src/main/api/plugin/internal.ts index 10e7a38b..1973dcb5 100644 --- a/src/main/api/plugin/internal.ts +++ b/src/main/api/plugin/internal.ts @@ -9,6 +9,7 @@ import httpServer from '../../core/httpServer.js' import mcpServer from '../../core/mcpServer.js' import superPanelManager from '../../core/superPanelManager.js' import translationManager from '../../core/translationManager.js' +import providerManager from '../../core/provider/providerManager.js' import aiModelsAPI from '../renderer/aiModels.js' import commandsAPI from '../renderer/commands.js' import pluginsAPI from '../renderer/plugins.js' @@ -566,6 +567,123 @@ export class InternalPluginAPI { return await aiModelsAPI.deleteModel(modelId) }) + // ==================== Provider(翻译 / OCR 等)管理 API ==================== + ipcMain.handle('internal:providers-get-all', async (event, type?: string) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-get-all') + } + try { + const data = providerManager.getAllProviders(type as never) + return { success: true, data } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + }) + + ipcMain.handle('internal:providers-get-settings', async (event) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-get-settings') + } + try { + return { success: true, data: providerManager.getSettings() } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + }) + + ipcMain.handle( + 'internal:providers-set-enabled', + async (event, providerId: string, enabled: boolean) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-set-enabled') + } + try { + const data = providerManager.setEnabled(providerId, enabled) + return { success: true, data } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + } + ) + + ipcMain.handle( + 'internal:providers-set-default', + async (event, type: string, providerId: string) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-set-default') + } + try { + const data = providerManager.setDefault(type as never, providerId) + return { success: true, data } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + } + ) + + ipcMain.handle('internal:providers-get-params', async (event, providerId: string) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-get-params') + } + try { + return { success: true, data: providerManager.getParams(providerId) } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + }) + + ipcMain.handle( + 'internal:providers-set-params', + async (event, providerId: string, params: Record) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-set-params') + } + try { + const data = providerManager.setParams(providerId, params) + return { success: true, data } + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : '未知错误' + } + } + } + ) + + // 超级面板翻译状态(供翻译 tab 展示内置 Bergamot 引擎状态) + ipcMain.handle('internal:providers-translation-status', async (event) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-translation-status') + } + return translationManager.getStatus() + }) + + ipcMain.handle( + 'internal:providers-translation-set-enabled', + async (event, enabled: boolean) => { + if (!requireInternalPlugin(this.pluginManager, event)) { + throw new PermissionDeniedError('internal:providers-translation-set-enabled') + } + translationManager.updateEnabled(enabled) + return { success: true } + } + ) + // ==================== 全局快捷键 API ==================== ipcMain.handle( 'internal:register-global-shortcut', diff --git a/src/main/api/plugin/providers.ts b/src/main/api/plugin/providers.ts new file mode 100644 index 00000000..cb992137 --- /dev/null +++ b/src/main/api/plugin/providers.ts @@ -0,0 +1,65 @@ +import type { ProviderType } from '@shared/providerShared' +import providerManager from '../../core/provider/providerManager' +import { registerPluginApiServices } from './pluginApiDispatcher' + +/** + * 消费方入口:让任意插件能够主动查询、调用其它 provider(翻译 / OCR)。 + * + * 与 `ztools.registerProvider`(提供方)和 `ztools.internal.providers.*`(管理方)互补: + * 这里注册的 handler 面向所有插件,走统一 `plugin.api` 分发器,转发到 providerManager。 + */ +class PluginProvidersAPI { + public init(): void { + registerPluginApiServices({ + /** + * 查询某 type 下的全部 provider,并标注哪个是默认。 + * 返回结构直接给插件使用,不包裹 { success, data }(与 ai() 风格一致)。 + */ + providersGetProviders: async (_event, payload: { type?: ProviderType }) => { + const type = payload?.type + const all = providerManager.getAllProviders(type as never) + const defaultId = type ? providerManager.getDefaultProviderId(type) : undefined + return all.map((p) => ({ ...p, isDefault: p.id === defaultId })) + }, + + /** + * 单独查询某 type 的默认 provider(defaultId 缺失时回退到首个启用/可用项)。 + * 无可用 provider 时返回 null。 + */ + providersGetDefault: async (_event, payload: { type?: ProviderType }) => { + const type = payload?.type + if (!type) return null + const id = providerManager.getDefaultProviderId(type) + if (!id) return null + const entry = providerManager.getAllProviders(type).find((p) => p.id === id) + return entry ? { ...entry, isDefault: true } : null + }, + + /** + * 统一调用入口:type 必填,input 必填,providerId 可选(缺省走默认)。 + * 成功直接返回 provider 的输出;失败抛 Error(分发器已处理前缀去除)。 + */ + providersInvoke: async ( + _event, + payload: { + type: ProviderType + input: Record + providerId?: string + } + ) => { + const { type, input, providerId } = payload || {} + if (!type) throw new Error('provider type 不能为空') + if (!input || typeof input !== 'object') { + throw new Error('provider 调用入参必须为对象') + } + return await providerManager.invoke( + type, + input as never, + providerId ? { providerId } : undefined + ) + } + }) + } +} + +export default new PluginProvidersAPI() diff --git a/src/main/api/plugin/redirect.ts b/src/main/api/plugin/redirect.ts index 2ebea854..2a161679 100644 --- a/src/main/api/plugin/redirect.ts +++ b/src/main/api/plugin/redirect.ts @@ -27,7 +27,8 @@ export class PluginRedirectAPI { }) ipcMain.on('ztools-redirect-ai-models-setting', (event) => { - event.returnValue = this.redirectToSettingPage('AiModels', 'AI 模型') + // 兼容旧跳转:AI 模型已并入「提供商」容器页的 AI tab + event.returnValue = this.redirectToSettingPage('Providers', '提供商') }) } diff --git a/src/main/api/renderer/pluginDevProjects.ts b/src/main/api/renderer/pluginDevProjects.ts index 97812965..bed252b0 100644 --- a/src/main/api/renderer/pluginDevProjects.ts +++ b/src/main/api/renderer/pluginDevProjects.ts @@ -3,6 +3,7 @@ import { dialog, shell } from 'electron' import { promises as fs } from 'fs' import path from 'path' import { isBundledInternalPlugin } from '../../core/internalPlugins' +import providerManager from '../../core/provider/providerManager' import { toDevPluginName } from '../../../shared/pluginRuntimeNamespace' import { packZpx } from '../../utils/zpxArchive.js' import databaseAPI from '../shared/database' @@ -513,6 +514,12 @@ export class PluginDevProjectsAPI { const { [projectName]: _, ...remainingProjects } = registry.projects this.writeRegistry({ ...registry, projects: remainingProjects }) this.removePluginUsageData(devEffectiveName) + // 同步清理该开发插件可能注册过的 provider 配置 + try { + providerManager.cleanupForPlugin(devEffectiveName) + } catch (error) { + console.error('[DevProjects] 清理 provider 配置失败:', error) + } this.deps.notifyPluginsChanged() console.log('[DevProjects] 项目已移除:', projectName) return { success: true, pluginName: projectName } @@ -596,6 +603,12 @@ export class PluginDevProjectsAPI { plugins.filter((p) => !(p?.isDevelopment && p?.name === devEffectiveName)) ) this.removePluginUsageData(toDevPluginName(projectName)) + // 卸载开发模式插件时一并清理其 provider 配置 + try { + providerManager.cleanupForPlugin(devEffectiveName) + } catch (error) { + console.error('[DevProjects] 清理 provider 配置失败:', error) + } this.deps.notifyPluginsChanged() return { success: true, pluginName: projectName } } catch (error: unknown) { diff --git a/src/main/api/renderer/plugins.ts b/src/main/api/renderer/plugins.ts index f73dddf7..e741cb28 100644 --- a/src/main/api/renderer/plugins.ts +++ b/src/main/api/renderer/plugins.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from 'url' import { normalizeIconPath } from '../../common/iconUtils' import { isBundledInternalPlugin } from '../../core/internalPlugins' import lmdbInstance from '../../core/lmdb/lmdbInstance' +import providerManager from '../../core/provider/providerManager' import windowManager from '../../managers/windowManager' import { httpGet } from '../../utils/httpRequest.js' import { pluginFeatureAPI } from '../plugin/feature' @@ -476,6 +477,14 @@ export class PluginsAPI { this.devProjects.removePluginUsageData(pluginInfo.name) + // 清理该插件的 provider 配置(启用 / 默认 / 自定义参数) + // 与插件数据无关,卸载即应移除,避免残留指向已卸载插件的 provider 引用。 + try { + providerManager.cleanupForPlugin(pluginInfo.name) + } catch (error) { + console.error('[Plugins] 清理 provider 配置失败:', error) + } + if (options.deleteData !== false) { await databaseAPI.clearPluginData(pluginInfo.name) this.removePluginNameConfigs(PLUGIN_NAME_SETTING_KEYS, pluginInfo.name) diff --git a/src/main/core/provider/providerManager.ts b/src/main/core/provider/providerManager.ts new file mode 100644 index 00000000..0c856b48 --- /dev/null +++ b/src/main/core/provider/providerManager.ts @@ -0,0 +1,441 @@ +import { ipcMain, type WebContents } from 'electron' +import fsSync from 'fs' +import path from 'path' +import type { PluginManager } from '../../managers/pluginManager' +import databaseAPI from '../../api/shared/database' +import { + ALL_PROVIDER_TYPES, + BUILTIN_PROVIDER_PREFIX, + buildBuiltinProviderId, + buildPluginProviderId, + normalizeProviderSettings, + PROVIDER_SETTINGS_KEY, + type PluginProvidersField, + type ProviderContractMap, + type ProviderDeclaration, + type ProviderEntry, + type ProviderSettings, + type ProviderType +} from '@shared/providerShared' + +/** 内置 provider 的本地实现(主进程内直接提供,不经插件)。 */ +interface BuiltinProviderDefinition { + /** 内置 provider 名称,用于拼装 id(builtin-) */ + name: string + type: ProviderType + label: string + description: string + /** 实际调用实现;为空表示该 provider 仅有声明、暂不可调用(如尚未就绪的引擎) */ + invoke?: (input: never) => Promise + /** 是否就绪(影响 UI 提示与是否可被设为默认) */ + isReady?: () => boolean +} + +/** 等待插件 provider 注册完成的超时时间 */ +const PROVIDER_REGISTER_TIMEOUT_MS = 5000 + +/** + * Provider 管理器。 + * + * 聚合内置 provider 与插件 provider(来自 plugin.json 的 providers 字段), + * 负责声明扫描、运行时注册跟踪、按需预加载、统一调用,以及用户配置持久化。 + * 设计上对齐 PluginToolsAPI(tools.ts)。 + */ +class ProviderManager { + private pluginManager: PluginManager | null = null + /** 已注册的内置 provider 定义(id -> definition) */ + private builtinProviders = new Map() + /** webContents.id => 已通过 ztools.registerProvider 注册的 key 集合 */ + private registeredProviders = new Map>() + /** webContents.id:key => 等待注册完成的回调 */ + private waiters = new Map void>>() + + public init(pluginManager: PluginManager): void { + this.pluginManager = pluginManager + this.setupIPC() + } + + // ==================== IPC ==================== + + private setupIPC(): void { + // 插件 preload 通过 ztools.registerProvider 注册 + // payload 为插件内声明 key(plugin.json providers 字段的 key),不再限制为 type。 + ipcMain.on('plugin:provider-register', (event, key: string) => { + try { + this.registerProvider(event.sender, key) + event.returnValue = { success: true } + } catch (error: unknown) { + event.returnValue = { + success: false, + error: error instanceof Error ? error.message : 'provider 注册失败' + } + } + }) + } + + // ==================== 内置 provider 注册 ==================== + + /** + * 注册一个内置 provider(主进程本地实现)。 + * 内置 provider 不可删除,仅供 invoke / 展示。 + */ + public registerBuiltinProvider(def: BuiltinProviderDefinition): void { + if (!def?.name || !def.type) { + throw new Error('内置 provider 缺少 name 或 type') + } + const id = buildBuiltinProviderId(def.name) + if (!id.startsWith(BUILTIN_PROVIDER_PREFIX)) { + throw new Error(`内置 provider id 必须以 ${BUILTIN_PROVIDER_PREFIX} 开头`) + } + this.builtinProviders.set(id, def) + } + + // ==================== 聚合查询 ==================== + + /** + * 读取某个插件的 providers 声明。 + */ + public getDeclaredProvidersByPath(pluginPath: string): PluginProvidersField { + try { + const pluginJsonPath = path.join(pluginPath, 'plugin.json') + const pluginConfig = JSON.parse(fsSync.readFileSync(pluginJsonPath, 'utf-8')) as { + providers?: PluginProvidersField + } + const providers = pluginConfig.providers + if (!providers || typeof providers !== 'object' || Array.isArray(providers)) { + return {} + } + return this.sanitizeDeclaredProviders(providers) + } catch { + return {} + } + } + + /** + * 仅保留合法 type 且结构正确的声明,丢弃其余。 + * + * 返回的 key 集合与插件 plugin.json providers 字段的 key 一致; + * 同一 type 可出现多条(key 不同),由调用方各自生成一条 entry。 + */ + private sanitizeDeclaredProviders(raw: PluginProvidersField): PluginProvidersField { + const result: PluginProvidersField = {} + if (!raw || typeof raw !== 'object') return result + for (const [key, decl] of Object.entries(raw)) { + if (!decl || typeof decl !== 'object') continue + const type = (decl as ProviderDeclaration).type + if (!type || !ALL_PROVIDER_TYPES.includes(type)) continue + result[key] = { + type, + label: typeof decl.label === 'string' ? decl.label : undefined, + description: typeof decl.description === 'string' ? decl.description : undefined + } + } + return result + } + + /** + * 汇总所有 provider(内置 + 插件),可按 type 过滤。 + */ + public getAllProviders(filterType?: ProviderType): ProviderEntry[] { + const entries: ProviderEntry[] = [] + + // 内置 + for (const [id, def] of this.builtinProviders) { + if (filterType && def.type !== filterType) continue + entries.push({ + id, + type: def.type, + label: def.label, + description: def.description, + source: 'builtin' + }) + } + + // 插件 + const plugins = databaseAPI.dbGet('plugins') + if (!Array.isArray(plugins)) return entries + + for (const plugin of plugins as Array>) { + const pluginPath = plugin?.path + const pluginName = plugin?.name + if (typeof pluginPath !== 'string' || typeof pluginName !== 'string') continue + + const declared = this.getDeclaredProvidersByPath(pluginPath) + // 声明 key 为插件内唯一标识,同一 type 可有多条(key 不同) + for (const [key, decl] of Object.entries(declared)) { + if (filterType && decl.type !== filterType) continue + entries.push({ + id: buildPluginProviderId(pluginName, key), + type: decl.type, + key, + label: decl.label || String(plugin.title || pluginName), + description: decl.description || '', + source: 'plugin', + pluginName, + pluginPath, + pluginLogo: typeof plugin.logo === 'string' ? plugin.logo : undefined + }) + } + } + return entries + } + + // ==================== 调用 ==================== + + /** + * 解析某 type 下最终使用的 provider id: + * 优先显式传入 → 其次默认 → 最后回退到第一个启用项(或第一个可用项)。 + * 仅做选择,不做加载/校验,供 invoke / 查询默认项等场景复用。 + */ + public resolveProviderId(type: ProviderType, providerId?: string): string | undefined { + const settings = this.getSettings() + return providerId || settings.defaultId[type] || this.firstEnabledId(type, settings) + } + + /** + * 取某 type 的默认 provider id(显式默认 → 首个启用 → 首个可用)。 + * 无可用项时返回 undefined。 + */ + public getDefaultProviderId(type: ProviderType): string | undefined { + return this.resolveProviderId(type) + } + + /** + * 按 type 调用默认 provider(或显式指定 providerId)。 + */ + public async invoke( + type: T, + input: ProviderContractMap[T]['input'], + options?: { providerId?: string } + ): Promise { + const targetId = this.resolveProviderId(type, options?.providerId) + + if (!targetId) { + throw new Error(`没有可用的 ${type} 提供商,请在设置中安装或启用对应插件`) + } + + // 内置 provider + if (targetId.startsWith(BUILTIN_PROVIDER_PREFIX)) { + const def = this.builtinProviders.get(targetId) + if (!def) throw new Error(`未找到内置 provider: ${targetId}`) + if (typeof def.invoke !== 'function') { + throw new Error(`内置 provider "${def.label}" 暂不可用`) + } + return (await def.invoke(input as never)) as ProviderContractMap[T]['output'] + } + + // 插件 provider + const entry = this.getAllProviders(type).find((p) => p.id === targetId) + if (!entry || entry.source !== 'plugin' || !entry.pluginPath || !entry.key) { + throw new Error(`未找到 provider: ${targetId}`) + } + const webContents = await this.ensurePluginProviderReady(entry.pluginPath, entry.key) + if (!webContents) { + throw new Error(`provider 所在插件无法加载: ${entry.pluginName}`) + } + + const result = await webContents.executeJavaScript(` + (async () => { + if (!window.ztools || typeof window.ztools.__invokeRegisteredProvider !== 'function') { + throw new Error('插件运行时缺少 provider 调用入口') + } + return await window.ztools.__invokeRegisteredProvider( + ${JSON.stringify(entry.key)}, + ${JSON.stringify(input ?? {})} + ) + })() + `) + return result as ProviderContractMap[T]['output'] + } + + /** + * 取某 type 下第一个启用的 provider id(兜底逻辑)。 + */ + private firstEnabledId(type: ProviderType, settings: ProviderSettings): string | undefined { + const enabled = settings.enabled[type] + if (enabled && enabled.length > 0) return enabled[0] + // 没有显式启用项时,回退到该类型的第一个可用 provider + const all = this.getAllProviders(type) + return all[0]?.id + } + + /** + * 确保目标插件已加载且对应 key 的 provider 已注册。 + */ + public async ensurePluginProviderReady( + pluginPath: string, + key: string + ): Promise { + let webContents = this.pluginManager?.getPluginWebContentsByPath(pluginPath) ?? null + if (!webContents) { + await this.pluginManager?.preloadPlugin(pluginPath) + webContents = this.pluginManager?.getPluginWebContentsByPath(pluginPath) ?? null + } + if (!webContents) return null + + if (this.isProviderRegistered(webContents, key)) { + return webContents + } + await this.waitForProviderRegistration(webContents, key) + return this.isProviderRegistered(webContents, key) ? webContents : null + } + + // ==================== 运行时注册跟踪 ==================== + + private registerProvider(webContents: WebContents, key: string): void { + if (!key || typeof key !== 'string') { + throw new Error('provider key 不能为空') + } + const pluginInfo = this.pluginManager?.getPluginInfoByWebContents(webContents) + if (!pluginInfo) { + throw new Error('无法获取插件信息') + } + // 必须在 plugin.json 声明了对应 key 才允许注册 + const declared = this.getDeclaredProvidersByPath(pluginInfo.path) + if (!declared[key]) { + throw new Error(`插件未在 plugin.json 声明 "${key}" provider`) + } + + let set = this.registeredProviders.get(webContents.id) + if (!set) { + set = new Set() + this.registeredProviders.set(webContents.id, set) + webContents.once('destroyed', () => { + this.registeredProviders.delete(webContents.id) + }) + } + set.add(key) + this.resolveWaiters(webContents.id, key) + } + + private isProviderRegistered(webContents: WebContents, key: string): boolean { + return this.registeredProviders.get(webContents.id)?.has(key) ?? false + } + + private async waitForProviderRegistration( + webContents: WebContents, + key: string + ): Promise { + if (this.isProviderRegistered(webContents, key)) return + const waiterKey = `${webContents.id}:${key}` + await new Promise((resolve, reject) => { + let wrappedResolve: (() => void) | null = null + const timeout = setTimeout(() => { + if (wrappedResolve) this.removeWaiter(waiterKey, wrappedResolve) + reject(new Error(`等待 ${key} provider 注册超时`)) + }, PROVIDER_REGISTER_TIMEOUT_MS) + + wrappedResolve = (): void => { + clearTimeout(timeout) + resolve() + } + const list = this.waiters.get(waiterKey) || [] + list.push(wrappedResolve) + this.waiters.set(waiterKey, list) + }).catch(() => undefined) + } + + private resolveWaiters(webContentsId: number, key: string): void { + const waiterKey = `${webContentsId}:${key}` + const list = this.waiters.get(waiterKey) + if (!list?.length) return + this.waiters.delete(waiterKey) + for (const resolve of list) resolve() + } + + private removeWaiter(waiterKey: string, target: () => void): void { + const list = this.waiters.get(waiterKey) + if (!list?.length) return + const next = list.filter((w) => w !== target) + if (next.length > 0) this.waiters.set(waiterKey, next) + else this.waiters.delete(waiterKey) + } + + // ==================== 配置持久化 ==================== + + public getSettings(): ProviderSettings { + return normalizeProviderSettings(databaseAPI.dbGet(PROVIDER_SETTINGS_KEY)) + } + + private saveSettings(settings: ProviderSettings): void { + databaseAPI.dbPut(PROVIDER_SETTINGS_KEY, settings) + } + + /** 设置某个 provider 的启用状态 */ + public setEnabled(providerId: string, enabled: boolean): ProviderSettings { + const entry = this.getAllProviders().find((p) => p.id === providerId) + if (!entry) throw new Error(`未找到 provider: ${providerId}`) + const settings = this.getSettings() + const list = new Set(settings.enabled[entry.type] || []) + if (enabled) list.add(providerId) + else list.delete(providerId) + settings.enabled[entry.type] = Array.from(list) + + // 关闭默认 provider 时需要重新选一个默认 + if (!enabled && settings.defaultId[entry.type] === providerId) { + settings.defaultId[entry.type] = list.size > 0 ? Array.from(list)[0] : undefined + } + this.saveSettings(settings) + return settings + } + + /** 设置某个 type 的默认 provider */ + public setDefault(type: ProviderType, providerId: string): ProviderSettings { + const entry = this.getAllProviders(type).find((p) => p.id === providerId) + if (!entry) throw new Error(`未找到 ${type} provider: ${providerId}`) + const settings = this.getSettings() + // 设为默认时自动加入启用列表 + const list = new Set(settings.enabled[type] || []) + list.add(providerId) + settings.enabled[type] = Array.from(list) + settings.defaultId[type] = providerId + this.saveSettings(settings) + return settings + } + + /** 读取某 provider 的自定义参数 */ + public getParams(providerId: string): Record { + return this.getSettings().params[providerId] || {} + } + + /** 保存某 provider 的自定义参数 */ + public setParams(providerId: string, params: Record): ProviderSettings { + const settings = this.getSettings() + settings.params[providerId] = params + this.saveSettings(settings) + return settings + } + + /** + * 当插件被卸载时清理其相关配置(启用/默认/参数)。 + */ + public cleanupForPlugin(pluginName: string): void { + const targetPrefix = `plugin:${pluginName}:` + const settings = this.getSettings() + let changed = false + for (const type of ALL_PROVIDER_TYPES) { + const enabled = settings.enabled[type] + if (Array.isArray(enabled)) { + const next = enabled.filter((id) => !id.startsWith(targetPrefix)) + if (next.length !== enabled.length) { + settings.enabled[type] = next + changed = true + } + } + const def = settings.defaultId[type] + if (typeof def === 'string' && def.startsWith(targetPrefix)) { + delete settings.defaultId[type] + changed = true + } + } + for (const id of Object.keys(settings.params)) { + if (id.startsWith(targetPrefix)) { + delete settings.params[id] + changed = true + } + } + if (changed) this.saveSettings(settings) + } +} + +export default new ProviderManager() diff --git a/src/main/core/superPanelManager.ts b/src/main/core/superPanelManager.ts index bbac9b3d..d6c992c7 100644 --- a/src/main/core/superPanelManager.ts +++ b/src/main/core/superPanelManager.ts @@ -14,7 +14,7 @@ import pluginsAPI from '../api/renderer/plugins.js' import windowManager from '../managers/windowManager.js' import clipboardManager, { type LastCopiedContent } from '../managers/clipboardManager.js' import { applyWindowMaterial, getDefaultWindowMaterial } from '../utils/windowUtils.js' -import translationManager from './translationManager.js' +import providerManager from './provider/providerManager.js' import { filterSuperPanelPinnedCommands } from './superPanelPinnedCommands.js' import { decodeFileUrlToPath } from '../utils/common' @@ -524,17 +524,19 @@ class SuperPanelManager { /** * 请求翻译选中的文本 + * 经 providerManager 按用户默认翻译提供商分发(内置 Bergamot 或插件翻译 provider)。 */ private async requestTranslation(text: string): Promise { try { - const translation = await translationManager.translate(text) - if (translation) { + const result = await providerManager.invoke('translation', { text }) + if (result?.text) { this.sendToSuperPanel('super-panel-translation', { - text: translation, + text: result.text, sourceText: text }) } } catch (error) { + // 没有可用的翻译提供商属于正常情形(用户未启用),静默处理 console.error('[SuperPanel] 翻译请求失败:', error) } } diff --git a/src/main/core/translationManager.ts b/src/main/core/translationManager.ts index 5593856d..e57b8a2c 100644 --- a/src/main/core/translationManager.ts +++ b/src/main/core/translationManager.ts @@ -98,10 +98,18 @@ class TranslationManager { } /** - * 更新翻译功能开关 + * 更新翻译功能开关,并持久化到 settings-general.superPanelTranslateEnabled。 + * 翻译 tab 的开关独立于此前的通用设置页写入,因此这里自行合并保存。 */ updateEnabled(enabled: boolean): void { this.enabled = enabled + // 合并写入,避免覆盖 settings-general 中的其他字段 + try { + const data = databaseAPI.dbGet('settings-general') || {} + databaseAPI.dbPut('settings-general', { ...data, superPanelTranslateEnabled: enabled }) + } catch (error) { + console.error('[Translation] 持久化翻译开关失败:', error) + } if (enabled) { this.initializeTranslator() } else { diff --git a/src/renderer/src/stores/commandDataStore.ts b/src/renderer/src/stores/commandDataStore.ts index a1e747c4..5e2e5949 100644 --- a/src/renderer/src/stores/commandDataStore.ts +++ b/src/renderer/src/stores/commandDataStore.ts @@ -114,6 +114,55 @@ interface SearchResultScoreMeta { scoreMatches: MatchInfo[] } +/** + * 安全生成 { pinyin, pinyinAbbr } 字段。 + * + * 插件数据中指令名(cmdName / app.name 等)可能不是字符串(例如对象型 cmd 缺 type + * 时 cmdName 会是整个对象),直接传入 pinyin() 会返回非字符串并让后续 .replace 崩溃, + * 进而让整个 loadCommands 失败、搜索栏与历史全部空白。这里统一兜底: + * 非字符串输入返回空串;pinyin/replace 抛错时也回退为空串,绝不向上抛。 + */ +export function toPinyinFields(name: unknown): { pinyin: string; pinyinAbbr: string } { + if (typeof name !== 'string' || name === '') { + return { pinyin: '', pinyinAbbr: '' } + } + try { + const full = pinyin(name, { toneType: 'none', type: 'string' }) + .replace(/\s+/g, '') + .toLowerCase() + const abbr = pinyin(name, { pattern: 'first', toneType: 'none', type: 'string' }) + .replace(/\s+/g, '') + .toLowerCase() + return { pinyin: full, pinyinAbbr: abbr } + } catch { + return { pinyin: '', pinyinAbbr: '' } + } +} + +/** + * 匹配型指令的白名单 type。命中则按 match cmd 处理(取 cmd.label 作名字), + * 否则视为文本型指令(cmd 本身即为名字)。 + */ +const MATCH_CMD_TYPES = ['regex', 'over', 'img', 'files', 'window'] + +/** + * 从一条 cmd 规整出 { isMatchCmd, cmdName }。 + * + * 关键防御:当 cmd 是对象但 type 不在白名单(或缺 type)时,旧逻辑会让 cmdName = + * 整个对象,再传给 pinyin() 会抛 "is not assignable to type string"。这里强制把 + * cmdName 收敛为字符串:对象型取 label(取不到回退空串),字符串型直接用。 + */ +export function normalizeCmd(cmd: unknown): { isMatchCmd: boolean; cmdName: string } { + if (typeof cmd === 'object' && cmd !== null) { + const type = (cmd as { type?: unknown }).type + const isMatchCmd = + typeof type === 'string' && MATCH_CMD_TYPES.includes(type) + const label = (cmd as { label?: unknown }).label + return { isMatchCmd, cmdName: typeof label === 'string' ? label : '' } + } + return { isMatchCmd: false, cmdName: typeof cmd === 'string' ? cmd : '' } +} + // MainPush 功能信息 export interface MainPushFeature { /** 提供该 mainPush 功能的插件路径。 */ @@ -759,6 +808,7 @@ export const useCommandDataStore = defineStore('commandData', () => { } } + const pluginNamePy = toPinyinFields(plugin.name) pluginItems.push({ name: plugin.title ?? plugin.name, path: plugin.path, @@ -768,16 +818,8 @@ export const useCommandDataStore = defineStore('commandData', () => { pluginName: plugin.name, pluginTitle: plugin.title, pluginExplain: defaultFeatureExplain || plugin.description, - pinyin: pinyin(plugin.name, { toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase(), - pinyinAbbr: pinyin(plugin.name, { - pattern: 'first', - toneType: 'none', - type: 'string' - }) - .replace(/\s+/g, '') - .toLowerCase() + pinyin: pluginNamePy.pinyin, + pinyinAbbr: pluginNamePy.pinyinAbbr }) } @@ -802,66 +844,59 @@ export const useCommandDataStore = defineStore('commandData', () => { } for (const cmd of feature.cmds) { - const isMatchCmd = - typeof cmd === 'object' && - ['regex', 'over', 'img', 'files', 'window'].includes(cmd.type) - const cmdName = isMatchCmd ? cmd.label : cmd - - if (isMatchCmd) { - const matchCommand: Command = { - name: cmdName, - path: plugin.path, - icon: featureIcon, - type: 'plugin', - featureCode: feature.code, - pluginName: plugin.name, - pluginTitle: plugin.title, - pluginExplain: feature.explain, - matchCmd: cmd, - cmdType: cmd.type, - mainPush: isMainPush, - pinyin: pinyin(cmdName, { toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase(), - pinyinAbbr: pinyin(cmdName, { - pattern: 'first', - toneType: 'none', - type: 'string' - }) - .replace(/\s+/g, '') - .toLowerCase() - } + // 单 cmd 隔离:任意一条指令解析异常(例如对象型 cmd 缺 type 导致 cmdName 是对象) + // 不应让整个插件的其余指令、乃至全局搜索栏跟着崩溃。 + try { + const { isMatchCmd, cmdName } = normalizeCmd(cmd) + const py = toPinyinFields(cmdName) + + if (isMatchCmd) { + const matchCommand: Command = { + name: cmdName, + path: plugin.path, + icon: featureIcon, + type: 'plugin', + featureCode: feature.code, + pluginName: plugin.name, + pluginTitle: plugin.title, + pluginExplain: feature.explain, + matchCmd: cmd, + cmdType: cmd.type, + mainPush: isMainPush, + pinyin: py.pinyin, + pinyinAbbr: py.pinyinAbbr + } + + regexItems.push(matchCommand) - regexItems.push(matchCommand) + if (cmd.type === 'window') { + pluginItems.push(...getLaunchableAliasEntries(matchCommand, commandAliases)) + } + } else { + const textCommand: Command = { + name: cmdName, + path: plugin.path, + icon: featureIcon, + type: 'plugin', + featureCode: feature.code, + pluginName: plugin.name, + pluginTitle: plugin.title, + pluginExplain: feature.explain, + cmdType: 'text', + mainPush: isMainPush, + pinyin: py.pinyin, + pinyinAbbr: py.pinyinAbbr + } - if (cmd.type === 'window') { - pluginItems.push(...getLaunchableAliasEntries(matchCommand, commandAliases)) + pluginItems.push(textCommand, ...getLaunchableAliasEntries(textCommand, commandAliases)) } - } else { - const textCommand: Command = { - name: cmdName, - path: plugin.path, - icon: featureIcon, - type: 'plugin', - featureCode: feature.code, + } catch (error) { + console.error('[CommandData] 构建插件指令失败,已跳过该指令:', { pluginName: plugin.name, - pluginTitle: plugin.title, - pluginExplain: feature.explain, - cmdType: 'text', - mainPush: isMainPush, - pinyin: pinyin(cmdName, { toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase(), - pinyinAbbr: pinyin(cmdName, { - pattern: 'first', - toneType: 'none', - type: 'string' - }) - .replace(/\s+/g, '') - .toLowerCase() - } - - pluginItems.push(textCommand, ...getLaunchableAliasEntries(textCommand, commandAliases)) + featureCode: feature.code, + cmd, + error + }) } } } @@ -873,32 +908,26 @@ export const useCommandDataStore = defineStore('commandData', () => { function buildAppCommandItems(rawApps: any[], commandAliases: CommandAliasStore): Command[] { return rawApps.flatMap((app) => { const extendedApp = app as any + const basePy = toPinyinFields(app.name) const baseApp: Command = { ...app, type: extendedApp.type || ('direct' as const), subType: extendedApp.subType || ('app' as const), cmdType: 'text', originalName: app.name, - pinyin: pinyin(app.name, { toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase(), - pinyinAbbr: pinyin(app.name, { pattern: 'first', toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase() + pinyin: basePy.pinyin, + pinyinAbbr: basePy.pinyinAbbr } const result: Command[] = [baseApp] if (extendedApp.aliases && Array.isArray(extendedApp.aliases)) { for (const alias of extendedApp.aliases) { if (alias && alias !== extendedApp.name) { + const aliasPy = toPinyinFields(alias) result.push({ ...baseApp, name: alias, - pinyin: pinyin(alias, { toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase(), - pinyinAbbr: pinyin(alias, { pattern: 'first', toneType: 'none', type: 'string' }) - .replace(/\s+/g, '') - .toLowerCase() + pinyin: aliasPy.pinyin, + pinyinAbbr: aliasPy.pinyinAbbr }) } } diff --git a/src/shared/providerShared.ts b/src/shared/providerShared.ts new file mode 100644 index 00000000..81fbe79e --- /dev/null +++ b/src/shared/providerShared.ts @@ -0,0 +1,196 @@ +/** + * Provider(提供商)抽象的统一契约。 + * + * 翻译、OCR 等能力不再由主程序硬编码实现,而是统一抽象为 Provider: + * - 插件在 plugin.json 的 providers 字段声明它提供哪些 type; + * - 运行时通过 ztools.registerProvider(key, handler) 注册实现; + * - 主程序经 providerManager 聚合后供设置页展示与调用。 + * + * 注意:AI 不纳入本抽象,AI 模型继续走 aiModels 独立链路。 + */ + +/** + * 提供商类型。预留扩展位,未来可在此追加新类型。 + */ +export type ProviderType = 'translation' | 'ocr' + +/** + * 插件在 plugin.json 中声明的单个 provider。 + * + * providers 字段的 key 为插件内唯一标识(不必等于 type), + * 因此同一插件可对同一 type 声明多条(如 baidu / google 都为 translation)。 + * `registerProvider` 的首参即该 key,运行时按 key 派发。 + */ +export interface ProviderDeclaration { + /** provider 类型 */ + type: ProviderType + /** 展示名称(可空,缺省回退到插件名) */ + label?: string + /** 描述文案 */ + description?: string +} + +/** + * plugin.json 中 providers 字段的合法结构: + * key 为插件内唯一标识(任意字符串),value 为声明详情。 + * 同一插件可对同一 type 声明多条,只要 key 不同即可。 + */ +export type PluginProvidersField = Record + +// ==================== 翻译契约 ==================== + +export interface TranslationInput { + /** 待翻译文本 */ + text: string + /** 源语言(可空表示自动检测) */ + from?: string + /** 目标语言(可空表示使用默认目标,如中文) */ + to?: string +} + +export interface TranslationOutput { + /** 翻译结果文本 */ + text: string + /** 实际识别到的源语言(可选) */ + detectedFrom?: string +} + +// ==================== OCR 契约 ==================== + +export interface OcrInput { + /** 图片:本地路径 / data URI / http(s) URL */ + image: string + /** 识别语言(可空) */ + lang?: string +} + +export interface OcrOutput { + /** 识别到的完整文本 */ + text: string + /** 按行或块的文本(可选) */ + blocks?: string[] + /** 置信度 0~1(可选) */ + confidence?: number +} + +/** + * 每种 type 对应的输入/输出类型映射,便于在 providerManager 中做类型收口。 + */ +export interface ProviderContractMap { + translation: { input: TranslationInput; output: TranslationOutput } + ocr: { input: OcrInput; output: OcrOutput } +} + +/** + * 运行时 handler 的函数签名(由插件通过 registerProvider 注册)。 + * + * 注意:handler 按「声明 key」存取,而非按 type。一个 key 对应一个 handler, + * 同一 type 下可有多个 key(如 baidu / google),由 providerManager 按 id 路由。 + */ +export type ProviderHandler = ( + input: ProviderContractMap[T]['input'] +) => Promise + +/** + * 内置 provider 标识(不可删除、不可由插件占用)。 + */ +export const BUILTIN_PROVIDER_PREFIX = 'builtin-' + +/** + * 提供商在数据库中的存储 key(ZTOOLS/ 命名空间,由 databaseAPI 自动加前缀)。 + */ +export const PROVIDER_SETTINGS_KEY = 'provider-settings' + +/** + * 提供商来源枚举:内置 或 插件。 + */ +export type ProviderSource = 'builtin' | 'plugin' + +/** + * 供设置页与主进程消费的扁平化 provider 描述。 + */ +export interface ProviderEntry { + /** 全局唯一 id。内置为 `builtin-xxx`,插件为 `plugin::` */ + id: string + /** provider 类型 */ + type: ProviderType + /** 展示名称 */ + label: string + /** 描述文案 */ + description: string + /** 来源 */ + source: ProviderSource + /** 来源插件名(仅插件 provider) */ + pluginName?: string + /** 来源插件路径(仅插件 provider) */ + pluginPath?: string + /** 插件 logo(仅插件 provider) */ + pluginLogo?: string + /** 插件内声明 key(仅插件 provider,用于运行时按 key 派发到 handler) */ + key?: string +} + +/** + * 用户层 provider 配置,持久化在 PROVIDER_SETTINGS_KEY 下。 + */ +export interface ProviderSettings { + /** 每个 type 启用的 provider id 列表 */ + enabled: Partial> + /** 每个 type 的默认 provider id */ + defaultId: Partial> + /** 各 provider 的自定义参数(providerId -> 参数对象) */ + params: Record> +} + +/** + * 生成插件 provider 的稳定 id。 + * + * 第二参数为插件内声明 key(plugin.json providers 字段的 key), + * 旧插件 key===type,故此处兼容历史用法:传 type 仍得到 `plugin::`。 + */ +export function buildPluginProviderId(pluginName: string, key: string): string { + return `plugin:${pluginName}:${key}` +} + +/** + * 生成内置 provider 的稳定 id。 + */ +export function buildBuiltinProviderId(name: string): string { + return `${BUILTIN_PROVIDER_PREFIX}${name}` +} + +/** + * 归一化 provider 配置,补齐缺失字段,返回安全可用的副本。 + */ +export function normalizeProviderSettings(raw: unknown): ProviderSettings { + const empty: ProviderSettings = { enabled: {}, defaultId: {}, params: {} } + if (!raw || typeof raw !== 'object') return empty + const data = raw as Partial + // 深拷贝数组层,避免主进程对 enabled 列表的就地修改污染原始数据 + const enabled: ProviderSettings['enabled'] = {} + if (data.enabled && typeof data.enabled === 'object') { + for (const [type, list] of Object.entries(data.enabled)) { + if (Array.isArray(list)) { + enabled[type as ProviderType] = list.filter((id): id is string => typeof id === 'string') + } + } + } + return { + enabled, + defaultId: data.defaultId && typeof data.defaultId === 'object' ? { ...data.defaultId } : {}, + params: + data.params && typeof data.params === 'object' + ? Object.fromEntries( + Object.entries(data.params).map(([k, v]) => [ + k, + v && typeof v === 'object' ? { ...v } : v + ]) + ) + : {} + } +} + +/** + * 全部受支持的 type 列表(用于设置页 tab 渲染顺序)。 + */ +export const ALL_PROVIDER_TYPES: ProviderType[] = ['translation', 'ocr'] diff --git a/tests/main/pluginRemovalCleanup.test.ts b/tests/main/pluginRemovalCleanup.test.ts index c39e0291..69de8e33 100644 --- a/tests/main/pluginRemovalCleanup.test.ts +++ b/tests/main/pluginRemovalCleanup.test.ts @@ -4,6 +4,7 @@ const mockDbGet = vi.hoisted(() => vi.fn()) const mockDbPut = vi.hoisted(() => vi.fn()) const mockClearPluginData = vi.hoisted(() => vi.fn()) const mockFsRm = vi.hoisted(() => vi.fn()) +const mockCleanupForPlugin = vi.hoisted(() => vi.fn()) vi.mock('electron', () => ({ dialog: { @@ -76,6 +77,12 @@ vi.mock('../../src/main/api/renderer/pluginMarket', () => ({ PluginMarketAPI: class {} })) +vi.mock('../../src/main/core/provider/providerManager', () => ({ + default: { + cleanupForPlugin: mockCleanupForPlugin + } +})) + import { DEV_PROJECT_REGISTRY_DB_KEY, type DevProjectRegistry @@ -96,7 +103,6 @@ describe('plugin removal cleanup', () => { mockClearPluginData.mockResolvedValue({ success: true }) mockFsRm.mockResolvedValue(undefined) }) - it('removes all matching development entries when deleting a dev project', async () => { const registry: DevProjectRegistry = { version: 3, @@ -302,4 +308,28 @@ describe('plugin removal cleanup', () => { expect(mockDbPut).toHaveBeenCalledWith(ENABLED_MAIN_PUSH_PLUGINS_KEY, ['other', 'demo']) expect(send).toHaveBeenCalledWith('plugins-changed') }) + + it('cleans provider settings for the uninstalled plugin', async () => { + mockDbGet.mockImplementation((key: string) => { + if (key === 'plugins') { + return [{ name: 'demo', path: 'D:\\plugins\\demo', isDevelopment: false }] + } + return [] + }) + + const api = new PluginsAPI() + const killPlugin = vi.fn(() => false) + const removePluginUsageData = vi.fn() + + ;(api as any).pluginManager = { killPlugin } + ;(api as any).devProjects = { removePluginUsageData } + ;(api as any).mainWindow = { webContents: { send: vi.fn() } } + ;(api as any).disabledPluginPathSet = new Set() + + const result = await api.deletePlugin('D:\\plugins\\demo') + + expect(result).toEqual({ success: true }) + // 卸载时应清理该插件在 provider 配置中的启用/默认/参数 + expect(mockCleanupForPlugin).toHaveBeenCalledWith('demo') + }) }) diff --git a/tests/main/providerConsumption.test.ts b/tests/main/providerConsumption.test.ts new file mode 100644 index 00000000..e8a18538 --- /dev/null +++ b/tests/main/providerConsumption.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRequire } from 'module' + +const require = createRequire(import.meta.url) +const moduleLoader = require('module') as { + _load: (request: string, parent: unknown, isMain: boolean) => unknown +} +const preloadPath = require.resolve('../../resources/preload.js') +const originalLoad = moduleLoader._load + +// ==================== providerManager 单测:默认项解析回退链 ==================== + +const mockDbGet = vi.hoisted(() => vi.fn()) +const mockDbPut = vi.hoisted(() => vi.fn()) +const mockReadFileSync = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() } })) +vi.mock('fs', () => ({ + default: { readFileSync: mockReadFileSync, existsSync: vi.fn() }, + readFileSync: mockReadFileSync, + existsSync: vi.fn() +})) +vi.mock('path', async () => { + const actual = await vi.importActual('path') + return { default: actual, ...actual } +}) +vi.mock('../../src/main/api/shared/database', () => ({ + default: { dbGet: mockDbGet, dbPut: mockDbPut } +})) + +import providerManager from '../../src/main/core/provider/providerManager' + +describe('providerManager.getDefaultProviderId / resolveProviderId', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDbGet.mockReturnValue([]) + mockReadFileSync.mockImplementation(() => { + throw new Error('no plugin.json') + }) + }) + + it('returns explicit defaultId when set', () => { + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-bergamot'] }, + defaultId: { translation: 'builtin-bergamot' }, + params: {} + }) + expect(providerManager.getDefaultProviderId('translation')).toBe('builtin-bergamot') + }) + + it('falls back to first enabled when defaultId missing', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线', + description: '' + }) + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-bergamot'] }, + defaultId: {}, + params: {} + }) + expect(providerManager.getDefaultProviderId('translation')).toBe('builtin-bergamot') + }) + + it('falls back to first available provider when enabled list empty', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot2', + type: 'translation', + label: '离线', + description: '' + }) + mockDbGet.mockReturnValue({ enabled: {}, defaultId: {}, params: {} }) + // 没有显式启用项,回退到该类型首个可用 provider + expect(providerManager.getDefaultProviderId('translation')).toMatch(/^builtin-/) + }) + + it('returns undefined when no provider available', () => { + // ocr 既无内置也无插件 + mockDbGet.mockReturnValue({ enabled: {}, defaultId: {}, params: {} }) + expect(providerManager.getDefaultProviderId('ocr')).toBeUndefined() + }) + + it('resolveProviderId honors explicit providerId over default', () => { + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-bergamot'] }, + defaultId: { translation: 'builtin-bergamot' }, + params: {} + }) + expect(providerManager.resolveProviderId('translation', 'plugin:custom:translation')).toBe( + 'plugin:custom:translation' + ) + }) +}) + +// ==================== preload 暴露的消费者入口 ==================== + +describe('plugin preload provider consumption surface', () => { + const ipcInvoke = vi.fn() + const ipcSendSync = vi.fn() + const ipcSend = vi.fn() + const ipcOn = vi.fn() + const ipcRemoveListener = vi.fn() + const ipcEmit = vi.fn() + + beforeEach(() => { + delete require.cache[preloadPath] + ipcInvoke.mockReset().mockResolvedValue({ text: '你好' }) + ipcSendSync.mockReset() + ipcSend.mockReset() + ipcOn.mockReset() + ipcRemoveListener.mockReset() + ipcEmit.mockReset() + ;(globalThis as any).window = { addEventListener: vi.fn() } + + moduleLoader._load = ((request: string) => { + if (request === 'electron') { + return { + ipcRenderer: { + invoke: ipcInvoke, + on: ipcOn, + send: ipcSend, + sendSync: ipcSendSync, + removeListener: ipcRemoveListener, + emit: ipcEmit + } + } + } + return originalLoad.call(moduleLoader, request, undefined, false) + }) as typeof originalLoad + }) + + afterEach(() => { + delete require.cache[preloadPath] + moduleLoader._load = originalLoad + delete (globalThis as any).window + }) + + it('exposes providers namespace with getProviders/getDefaultProvider/invokeProvider', async () => { + require(preloadPath) + const ztools = (globalThis as any).window.ztools + + expect(typeof ztools.providers.getProviders).toBe('function') + expect(typeof ztools.providers.getDefaultProvider).toBe('function') + expect(typeof ztools.providers.invokeProvider).toBe('function') + + await ztools.providers.getProviders('translation') + await ztools.providers.getDefaultProvider('translation') + await ztools.providers.invokeProvider('translation', { text: 'hi' }, 'p1') + + // preload 的 ipcInvoke 内部走 'plugin.api' 通道,参数顺序为 (channel, apiName, args) + expect(ipcInvoke).toHaveBeenNthCalledWith(1, 'plugin.api', 'providersGetProviders', { + type: 'translation' + }) + expect(ipcInvoke).toHaveBeenNthCalledWith(2, 'plugin.api', 'providersGetDefault', { + type: 'translation' + }) + expect(ipcInvoke).toHaveBeenNthCalledWith(3, 'plugin.api', 'providersInvoke', { + type: 'translation', + input: { text: 'hi' }, + providerId: 'p1' + }) + }) + + it('translate sugar invokes translation provider with optional from/to/providerId', async () => { + require(preloadPath) + const ztools = (globalThis as any).window.ztools + + await ztools.translate('hello', { from: 'en', to: 'zh', providerId: 'p1' }) + + expect(ipcInvoke).toHaveBeenCalledWith('plugin.api', 'providersInvoke', { + type: 'translation', + input: { text: 'hello', from: 'en', to: 'zh' }, + providerId: 'p1' + }) + }) + + it('ocr sugar invokes ocr provider with optional lang/providerId', async () => { + require(preloadPath) + const ztools = (globalThis as any).window.ztools + + await ztools.ocr('data:image/png;base64,xxx', { lang: 'eng', providerId: 'p2' }) + + expect(ipcInvoke).toHaveBeenCalledWith('plugin.api', 'providersInvoke', { + type: 'ocr', + input: { image: 'data:image/png;base64,xxx', lang: 'eng' }, + providerId: 'p2' + }) + }) + + it('translate/ocr work without options (providerId undefined)', async () => { + require(preloadPath) + const ztools = (globalThis as any).window.ztools + + await ztools.translate('hello') + await ztools.ocr('img.png') + + expect(ipcInvoke).toHaveBeenNthCalledWith(1, 'plugin.api', 'providersInvoke', { + type: 'translation', + input: { text: 'hello', from: undefined, to: undefined }, + providerId: undefined + }) + expect(ipcInvoke).toHaveBeenNthCalledWith(2, 'plugin.api', 'providersInvoke', { + type: 'ocr', + input: { image: 'img.png', lang: undefined }, + providerId: undefined + }) + }) +}) diff --git a/tests/main/providerManager.test.ts b/tests/main/providerManager.test.ts new file mode 100644 index 00000000..95b671ee --- /dev/null +++ b/tests/main/providerManager.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockDbGet = vi.hoisted(() => vi.fn()) +const mockDbPut = vi.hoisted(() => vi.fn()) +const mockReadFileSync = vi.hoisted(() => vi.fn()) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + on: vi.fn() + } +})) + +vi.mock('fs', () => ({ + default: { + readFileSync: mockReadFileSync, + existsSync: vi.fn() + }, + readFileSync: mockReadFileSync, + existsSync: vi.fn() +})) + +vi.mock('path', async () => { + const actual = await vi.importActual('path') + return { default: actual, ...actual } +}) + +vi.mock('../../src/main/api/shared/database', () => ({ + default: { + dbGet: mockDbGet, + dbPut: mockDbPut + } +})) + +import providerManager from '../../src/main/core/provider/providerManager' + +describe('providerManager', () => { + beforeEach(() => { + vi.clearAllMocks() + // 默认无已安装插件、无配置 + mockDbGet.mockReturnValue([]) + mockReadFileSync.mockImplementation(() => { + throw new Error('no plugin.json') + }) + }) + + describe('registerBuiltinProvider', () => { + it('registers a builtin provider and exposes it via getAllProviders', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线翻译', + description: 'd' + }) + const all = providerManager.getAllProviders() + const builtin = all.find((p) => p.id === 'builtin-bergamot') + expect(builtin).toBeDefined() + expect(builtin?.source).toBe('builtin') + expect(builtin?.type).toBe('translation') + }) + + it('throws when name or type is missing', () => { + expect(() => + providerManager.registerBuiltinProvider({ + name: '', + type: 'ocr', + label: 'x', + description: '' + }) + ).toThrow() + }) + }) + + describe('getDeclaredProvidersByPath', () => { + it('parses valid providers from plugin.json', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ + providers: { + translation: { type: 'translation', label: '云翻译', description: 'd' }, + ocr: { type: 'ocr', label: '云OCR' } + } + }) + ) + const declared = providerManager.getDeclaredProvidersByPath('/fake/plugin') + expect(declared.translation?.label).toBe('云翻译') + expect(declared.ocr?.type).toBe('ocr') + }) + + it('drops unknown provider types and malformed declarations', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ + providers: { + // 不支持的 type + ai: { type: 'ai', label: 'should be dropped' }, + // 结构错误 + translation: 'not-an-object' + } + }) + ) + const declared = providerManager.getDeclaredProvidersByPath('/fake/plugin') + expect(declared.translation).toBeUndefined() + expect(declared.ocr).toBeUndefined() + }) + + it('preserves multiple declarations of the same type (different keys)', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ + providers: { + baidu: { type: 'translation', label: '百度' }, + google: { type: 'translation', label: '谷歌' }, + ocr: { type: 'ocr', label: '云 OCR' } + } + }) + ) + const declared = providerManager.getDeclaredProvidersByPath('/fake/plugin') + expect(Object.keys(declared)).toEqual(['baidu', 'google', 'ocr']) + expect(declared.baidu?.type).toBe('translation') + expect(declared.google?.type).toBe('translation') + expect(declared.ocr?.type).toBe('ocr') + }) + + it('returns empty when plugin.json is unreadable', () => { + mockReadFileSync.mockImplementation(() => { + throw new Error('ENOENT') + }) + expect(providerManager.getDeclaredProvidersByPath('/missing')).toEqual({}) + }) + }) + + describe('getAllProviders aggregates plugin providers', () => { + it('lists plugin providers from installed plugins', () => { + mockDbGet.mockReturnValue([ + { name: 'cloud-trans', path: '/p/cloud-trans', title: '云翻译', logo: 'l.png' } + ]) + mockReadFileSync.mockReturnValue( + JSON.stringify({ + providers: { translation: { type: 'translation', label: '云翻译', description: 'd' } } + }) + ) + const list = providerManager.getAllProviders('translation') + const pluginProvider = list.find((p) => p.source === 'plugin') + expect(pluginProvider).toBeDefined() + expect(pluginProvider?.id).toBe('plugin:cloud-trans:translation') + expect(pluginProvider?.pluginName).toBe('cloud-trans') + expect(pluginProvider?.pluginLogo).toBe('l.png') + }) + + it('lists multiple providers of the same type from one plugin (multi-declaration)', () => { + mockDbGet.mockReturnValue([{ name: 'multi-trans', path: '/p/multi-trans', title: '多云翻译' }]) + mockReadFileSync.mockReturnValue( + JSON.stringify({ + providers: { + baidu: { type: 'translation', label: '百度翻译', description: 'b' }, + google: { type: 'translation', label: '谷歌翻译', description: 'g' }, + ocr: { type: 'ocr', label: '云 OCR', description: 'o' } + } + }) + ) + const translations = providerManager.getAllProviders('translation') + const pluginProviders = translations.filter((p) => p.source === 'plugin') + expect(pluginProviders).toHaveLength(2) + const byId = Object.fromEntries(pluginProviders.map((p) => [p.id, p])) + expect(byId['plugin:multi-trans:baidu']).toBeDefined() + expect(byId['plugin:multi-trans:baidu'].label).toBe('百度翻译') + expect(byId['plugin:multi-trans:baidu'].key).toBe('baidu') + expect(byId['plugin:multi-trans:google']).toBeDefined() + expect(byId['plugin:multi-trans:google'].label).toBe('谷歌翻译') + expect(byId['plugin:multi-trans:google'].key).toBe('google') + + // ocr 声明不会混入 translation 列表 + expect(pluginProviders.every((p) => p.type === 'translation')).toBe(true) + + // 按 ocr 过滤应只剩一条 + const ocrList = providerManager.getAllProviders('ocr') + expect(ocrList.filter((p) => p.source === 'plugin')).toHaveLength(1) + expect(ocrList.find((p) => p.source === 'plugin')?.id).toBe('plugin:multi-trans:ocr') + }) + }) + + describe('settings persistence', () => { + it('setEnabled adds the provider to the enabled list and persists', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线', + description: '' + }) + mockDbGet.mockReturnValue({ enabled: {}, defaultId: {}, params: {} }) + providerManager.setEnabled('builtin-bergamot', true) + expect(mockDbPut).toHaveBeenCalledWith( + 'provider-settings', + expect.objectContaining({ + enabled: expect.objectContaining({ translation: ['builtin-bergamot'] }) + }) + ) + }) + + it('setDefault sets default and auto-enables the provider', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线', + description: '' + }) + mockDbGet.mockReturnValue({ enabled: {}, defaultId: {}, params: {} }) + providerManager.setDefault('translation', 'builtin-bergamot') + const calls = mockDbPut.mock.calls + const last = calls[calls.length - 1][1] as { + enabled: Record + defaultId: Record + } + expect(last.defaultId.translation).toBe('builtin-bergamot') + expect(last.enabled.translation).toContain('builtin-bergamot') + }) + + it('disabling the default provider clears the default', () => { + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线', + description: '' + }) + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-bergamot'] }, + defaultId: { translation: 'builtin-bergamot' }, + params: {} + }) + providerManager.setEnabled('builtin-bergamot', false) + const last = mockDbPut.mock.calls[mockDbPut.mock.calls.length - 1][1] as { + defaultId: Record + enabled: Record + } + expect(last.defaultId.translation).toBeUndefined() + expect(last.enabled.translation).not.toContain('builtin-bergamot') + }) + }) + + describe('cleanupForPlugin', () => { + it('removes plugin-scoped enabled/default/params entries', () => { + mockDbGet.mockReturnValue({ + enabled: { + translation: ['plugin:demo:translation', 'plugin:other:translation'], + ocr: ['plugin:demo:ocr'] + }, + defaultId: { translation: 'plugin:demo:translation' }, + params: { 'plugin:demo:translation': { k: 'v' }, 'plugin:other:translation': { k2: 'v2' } } + }) + providerManager.cleanupForPlugin('demo') + const last = mockDbPut.mock.calls[mockDbPut.mock.calls.length - 1][1] as { + enabled: Record + defaultId: Record + params: Record + } + expect(last.enabled.translation).toEqual(['plugin:other:translation']) + expect(last.enabled.ocr).toEqual([]) + expect(last.defaultId.translation).toBeUndefined() + expect(last.params['plugin:demo:translation']).toBeUndefined() + expect(last.params['plugin:other:translation']).toEqual({ k2: 'v2' }) + }) + }) + + describe('invoke', () => { + it('calls the builtin provider invoke when set as default', async () => { + const invokeFn = vi.fn().mockResolvedValue({ text: '你好' }) + providerManager.registerBuiltinProvider({ + name: 'bergamot', + type: 'translation', + label: '离线', + description: '', + invoke: invokeFn as never + }) + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-bergamot'] }, + defaultId: { translation: 'builtin-bergamot' }, + params: {} + }) + const result = await providerManager.invoke('translation', { text: 'hello' }) + expect(invokeFn).toHaveBeenCalledWith(expect.objectContaining({ text: 'hello' })) + expect(result).toEqual({ text: '你好' }) + }) + + it('throws when no provider is available', async () => { + mockDbGet.mockReturnValue({ enabled: {}, defaultId: {}, params: {} }) + // ocr 没有任何内置/插件 provider + await expect(providerManager.invoke('ocr', { image: 'x' })).rejects.toThrow( + /没有可用的 ocr 提供商/ + ) + }) + + it('throws when builtin provider has no invoke implementation', async () => { + providerManager.registerBuiltinProvider({ + name: 'noimpl', + type: 'translation', + label: '空', + description: '' + }) + mockDbGet.mockReturnValue({ + enabled: { translation: ['builtin-noimpl'] }, + defaultId: { translation: 'builtin-noimpl' }, + params: {} + }) + await expect(providerManager.invoke('translation', { text: 'x' })).rejects.toThrow(/暂不可用/) + }) + }) +}) diff --git a/tests/main/providerShared.test.ts b/tests/main/providerShared.test.ts new file mode 100644 index 00000000..dac17512 --- /dev/null +++ b/tests/main/providerShared.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_PROVIDER_TYPES, + BUILTIN_PROVIDER_PREFIX, + buildBuiltinProviderId, + buildPluginProviderId, + normalizeProviderSettings +} from '@shared/providerShared' + +describe('providerShared', () => { + describe('buildPluginProviderId', () => { + it('builds a stable id from plugin name and declaration key', () => { + // 旧用法(key===type)保持兼容 + expect(buildPluginProviderId('my-plugin', 'translation')).toBe('plugin:my-plugin:translation') + expect(buildPluginProviderId('ocr-x', 'ocr')).toBe('plugin:ocr-x:ocr') + // 新用法:key 可为任意字符串,同一 type 下多条用不同 key + expect(buildPluginProviderId('multi', 'baidu')).toBe('plugin:multi:baidu') + expect(buildPluginProviderId('multi', 'google')).toBe('plugin:multi:google') + }) + }) + + describe('buildBuiltinProviderId', () => { + it('prefixes the name with the builtin marker', () => { + const id = buildBuiltinProviderId('bergamot') + expect(id).toBe(`${BUILTIN_PROVIDER_PREFIX}bergamot`) + expect(id.startsWith(BUILTIN_PROVIDER_PREFIX)).toBe(true) + }) + }) + + describe('normalizeProviderSettings', () => { + it('returns an empty structure for non-object input', () => { + const result = normalizeProviderSettings(null) + expect(result).toEqual({ enabled: {}, defaultId: {}, params: {} }) + expect(normalizeProviderSettings(undefined)).toEqual({ + enabled: {}, + defaultId: {}, + params: {} + }) + expect(normalizeProviderSettings('string')).toEqual({ + enabled: {}, + defaultId: {}, + params: {} + }) + }) + + it('preserves provided enabled / defaultId / params', () => { + const input = { + enabled: { translation: ['plugin:a:translation'] }, + defaultId: { ocr: 'plugin:b:ocr' }, + params: { 'plugin:a:translation': { key: 'secret' } } + } + const result = normalizeProviderSettings(input) + expect(result.enabled.translation).toEqual(['plugin:a:translation']) + expect(result.defaultId.ocr).toBe('plugin:b:ocr') + expect(result.params['plugin:a:translation']).toEqual({ key: 'secret' }) + }) + + it('does not mutate the original input object', () => { + const input = { enabled: { translation: ['x'] } } + const result = normalizeProviderSettings(input) + result.enabled.translation!.push('y') + // 原始输入不应被影响(深拷贝语义) + expect(input.enabled.translation).toEqual(['x']) + }) + }) + + describe('ALL_PROVIDER_TYPES', () => { + it('includes translation and ocr', () => { + expect(ALL_PROVIDER_TYPES).toContain('translation') + expect(ALL_PROVIDER_TYPES).toContain('ocr') + expect(ALL_PROVIDER_TYPES).not.toContain('ai') + }) + }) +})
{{ p.description }}
+ 选中文字触发超级面板时,自动翻译为中文显示(使用 Bergamot 离线翻译引擎,首次启用需下载约 + 55MB 模型) +