From 3febf6074f17edcec64ede674bdcf1c7da7e44ea Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 26 Mar 2026 00:09:18 +0800 Subject: [PATCH 1/5] feat(deeplink): add provider import --- docs/specs/provider-deeplink-import/plan.md | 180 ++++ docs/specs/provider-deeplink-import/spec.md | 136 +++ docs/specs/provider-deeplink-import/tasks.md | 59 ++ src/main/events.ts | 3 +- src/main/presenter/deeplinkPresenter/index.ts | 203 +++- src/renderer/settings/App.vue | 28 + .../components/ModelProviderSettings.vue | 84 ++ .../ProviderDeeplinkImportDialog.vue | 108 +++ src/renderer/src/events.ts | 3 +- src/renderer/src/i18n/da-DK/settings.json | 9 + src/renderer/src/i18n/en-US/settings.json | 9 + src/renderer/src/i18n/fa-IR/settings.json | 9 + src/renderer/src/i18n/fr-FR/settings.json | 9 + src/renderer/src/i18n/he-IL/settings.json | 9 + src/renderer/src/i18n/ja-JP/settings.json | 9 + src/renderer/src/i18n/ko-KR/settings.json | 9 + src/renderer/src/i18n/pt-BR/settings.json | 9 + src/renderer/src/i18n/ru-RU/settings.json | 9 + src/renderer/src/i18n/zh-CN/settings.json | 9 + src/renderer/src/i18n/zh-HK/settings.json | 9 + src/renderer/src/i18n/zh-TW/settings.json | 9 + .../src/stores/providerDeeplinkImport.ts | 21 + src/shared/providerDeeplink.ts | 95 ++ src/shared/types/index.d.ts | 7 + test/README.md | 28 + test/main/presenter/deeplinkPresenter.test.ts | 145 +++ test/manual/deeplink-playground.html | 865 ++++++++++++++++++ .../components/ModelProviderSettings.test.ts | 141 ++- test/renderer/components/SettingsApp.test.ts | 227 +++++ 29 files changed, 2415 insertions(+), 26 deletions(-) create mode 100644 docs/specs/provider-deeplink-import/plan.md create mode 100644 docs/specs/provider-deeplink-import/spec.md create mode 100644 docs/specs/provider-deeplink-import/tasks.md create mode 100644 src/renderer/settings/components/ProviderDeeplinkImportDialog.vue create mode 100644 src/renderer/src/stores/providerDeeplinkImport.ts create mode 100644 src/shared/providerDeeplink.ts create mode 100644 test/main/presenter/deeplinkPresenter.test.ts create mode 100644 test/manual/deeplink-playground.html diff --git a/docs/specs/provider-deeplink-import/plan.md b/docs/specs/provider-deeplink-import/plan.md new file mode 100644 index 000000000..170d4a237 --- /dev/null +++ b/docs/specs/provider-deeplink-import/plan.md @@ -0,0 +1,180 @@ +# Provider Deeplink Import 实施计划 + +## 1. 当前实现基线 + +### 1.1 Deeplink 现状 + +1. `src/main/presenter/deeplinkPresenter/index.ts` 已支持 `deepchat://start` 和 `deepchat://mcp/install`。 +2. 设置窗口已经支持通过 `SETTINGS_EVENTS.NAVIGATE` 进行页面跳转。 +3. 设置 App 已有 MCP deeplink 的初始化处理,可复用设置窗口 ready 后接收事件的模式。 + +### 1.2 Provider 设置页现状 + +1. Provider 列表与配置由 `providerStore` 驱动。 +2. Provider 详情页可基于路由参数 `providerId` 切换目标 provider。 +3. 自定义 provider 已有手动新增流程,可复用新增后的选中逻辑。 + +## 2. 设计决策 + +### 2.1 Payload 与共享类型 + +新增共享模块 `src/shared/providerDeeplink.ts`: + +1. 常量: + - `PROVIDER_INSTALL_ROUTE` + - `PROVIDER_INSTALL_VERSION` +2. 类型: + - `ProviderInstallDeeplinkPayload` + - `ProviderInstallPreview` +3. 工具函数: + - `maskApiKey` + - custom type 校验 + +### 2.2 主进程事件流 + +入口:`deepchat://provider/install?v=1&data=...` + +处理顺序: + +1. `DeeplinkPresenter.handleDeepLink` 识别 `provider/install` +2. Base64 解码 + JSON 解析 + 字段校验 +3. built-in: + - 校验 `id` + - 拒绝 `acp` +4. custom: + - 校验 `name/type` + - 校验 `type` 在允许列表中 + - 拒绝 `acp` +5. 创建/聚焦设置窗口 +6. 发送: + - `SETTINGS_EVENTS.NAVIGATE -> settings-provider` + - `SETTINGS_EVENTS.PROVIDER_INSTALL -> preview` + +错误策略: + +1. 解析失败或 payload 不合法时,发 `NOTIFICATION_EVENTS.SHOW_ERROR` +2. 失败时不写任何 provider 配置 + +### 2.3 渲染进程事件流 + +`src/renderer/settings/App.vue`: + +1. 监听 `SETTINGS_EVENTS.PROVIDER_INSTALL` +2. 确保 provider store 已初始化 +3. built-in 导入时切到 `settings-provider/:providerId` +4. custom 导入时切到 `settings-provider` +5. 把 preview 放入新的 pending import store + +`src/renderer/src/stores/providerDeeplinkImport.ts`: + +1. 只维护当前 pending preview +2. 对话框开关由 preview 是否存在推导 + +### 2.4 对话框与落库行为 + +`ProviderDeeplinkImportDialog` 只负责展示解析结果,不自行写配置。 + +展示规则: + +1. built-in:`icon + id` +2. custom:`icon + name`,并额外显示 `type` +3. 两类都展示 `baseUrl` +4. 两类都展示脱敏 `apiKey` +5. built-in 额外显示覆盖 warning + +确认逻辑放在 `ModelProviderSettings.vue`: + +1. built-in: + - 更新 `baseUrl/apiKey` + - 若未启用则自动启用 + - 刷新该 provider 模型 + - 切换到对应 provider 页面 +2. custom: + - 生成新 `id` + - 创建 `custom: true` provider + - 默认 `enable: true` + - 刷新新 provider 模型 + - 切换到新 provider 页面 +3. cancel: + - 清空 pending preview + - 不写配置 + +### 2.5 Provider 兼容策略 + +1. built-in provider 以 `id` 作为唯一匹配键,因此导入是覆盖语义。 +2. custom provider 以 `type/apiType` 校验,但确认后总是新增实例,因此是追加语义。 +3. `vertex`、`aws-bedrock`、`github-copilot` 等允许部分导入,即使后续仍需补专属字段,也不阻塞 `baseUrl/apiKey` 导入。 +4. `acp` 独立于本流程,不进入 `settings-provider` 导入链路。 + +## 3. Manual Playground + +新增: + +- `test/manual/deeplink-playground.html` + +页面结构: + +1. `start` +2. `mcp/install` +3. `provider/install` +4. `provider/install builder` + +规则: + +1. built-in 列出当前所有默认 provider `id`,排除 `acp` +2. custom 列出当前所有允许导入的 `apiType`,排除 `acp` +3. 每项展示: + - label + - raw JSON + - deeplink + - `Open` + - `Copy` +4. 示例数据全部使用假地址和假 key + +## 4. 测试策略 + +### 4.1 Main + +1. built-in payload 成功时: + - 打开设置窗 + - 发送 `NAVIGATE` + - 发送 `PROVIDER_INSTALL` +2. custom payload 成功时: + - 发送 custom preview +3. 非法 payload: + - 不发送导入事件 + - 发送错误通知 + +### 4.2 Renderer + +1. `App.vue` 收到 `PROVIDER_INSTALL` 后正确导航并写入 preview store +2. `ModelProviderSettings.vue`: + - built-in confirm 覆盖并启用 provider + - custom confirm 新增并选中新 provider + - cancel 不写配置 +3. `ProviderDeeplinkImportDialog.vue` 正确展示 built-in/custom 解析结果 + +### 4.3 Manual + +1. playground 中三类 deeplink 都能生成合法协议链接 +2. built-in/custom 列表覆盖范围正确 +3. builder 输出格式与应用解析格式一致 + +## 5. 风险与缓解 + +1. 风险:设置窗口创建后事件发送早于页面监听注册。 +缓解:复用现有 settings 事件通道,并在 App 侧做独立导航兜底。 + +2. 风险:部分 provider 启用后仍缺专属字段,模型刷新可能失败。 +缓解:允许部分导入;模型刷新失败只记录日志,不回滚导入。 + +3. 风险:手工验证页 provider 列表与真实支持集合漂移。 +缓解:built-in 与 custom 列表以当前代码中的 provider 集合为准,变更时同步更新此页。 + +## 6. 质量门槛 + +1. `pnpm run format` +2. `pnpm run i18n` +3. `pnpm run lint` +4. `pnpm run typecheck` +5. 关键 main/renderer 测试通过 diff --git a/docs/specs/provider-deeplink-import/spec.md b/docs/specs/provider-deeplink-import/spec.md new file mode 100644 index 000000000..4f383bb5d --- /dev/null +++ b/docs/specs/provider-deeplink-import/spec.md @@ -0,0 +1,136 @@ +# Provider Deeplink Import 规格 + +## 概述 + +新增 provider 导入 deeplink: + +- `deepchat://provider/install?v=1&data=` + +其中 `data` 只接受两种结构,且 `id` 与 `type` 必须二选一: + +1. `{ id, baseUrl, apiKey }` +2. `{ name, type, baseUrl, apiKey }` + +导入后统一进入 Provider Settings,先展示确认对话框;用户确认后才写入配置,取消则直接丢弃。 + +## 背景与动机 + +1. 用户经常需要在多个 built-in provider 与 custom provider 之间切换配置。 +2. 当前 provider 配置主要依赖手动录入,分享和一键导入成本高。 +3. DeepLink 已经用于 `start` 和 `mcp/install`,provider 导入应沿用同一套唤起能力。 +4. 需要一个独立的手工验证页,降低联调和回归验证成本。 + +## 用户故事 + +### US-1:一键导入内置 Provider + +作为用户,我希望点击一个 deeplink 后直接进入对应 provider 设置,并在确认后覆盖它的 `baseUrl` 与 `apiKey`。 + +### US-2:一键新增 Custom Provider + +作为用户,我希望通过 deeplink 快速新增一个 custom provider,而不是手动新建并逐项填写。 + +### US-3:导入前确认 + +作为用户,我希望在真正写入前看到解析结果,避免误覆盖现有配置。 + +### US-4:手工验证入口 + +作为开发者或测试者,我希望仓库里有一个静态网页,能集中打开所有支持的 deeplink。 + +## 功能需求 + +### A. Provider Deeplink 协议 + +- [ ] 新增 `deepchat://provider/install?v=1&data=` +- [ ] `v=1` 是当前唯一支持版本 +- [ ] `data` Base64 解码后必须是 JSON object +- [ ] payload 只允许两种结构: + - [ ] `{ id, baseUrl, apiKey }` + - [ ] `{ name, type, baseUrl, apiKey }` +- [ ] `id` 与 `type` 同时存在或同时缺失时,必须拒绝 + +### B. 内置 Provider 导入 + +- [ ] 当 payload 包含 `id` 时,按内置 provider id 匹配 +- [ ] `id='acp'` 必须拒绝 +- [ ] unknown `id` 必须拒绝 +- [ ] 确认后覆盖目标 provider 的 `baseUrl` 与 `apiKey` +- [ ] 若目标 provider 当前未启用,确认后自动启用 +- [ ] 完成后停留在对应 provider 设置页 +- [ ] 若是 `vertex`、`aws-bedrock`、`github-copilot` 等仍需额外字段的 provider,允许部分导入,不阻塞确认 + +### C. Custom Provider 导入 + +- [ ] 当 payload 包含 `type` 时,按 provider `apiType` 匹配 +- [ ] `type='acp'` 必须拒绝 +- [ ] unknown `type` 必须拒绝 +- [ ] custom payload 必须包含 `name` +- [ ] 确认后总是新增一条 custom provider,不复用旧条目 +- [ ] 新 provider 默认 `enable=true` +- [ ] 完成后停留在新 provider 设置页 + +### D. 设置页行为 + +- [ ] deeplink 唤起后自动进入 `settings-provider` +- [ ] 在真正写入前弹出 `Import Provider` 对话框 +- [ ] built-in 对话框只展示: + - [ ] `id + icon` + - [ ] `baseUrl` + - [ ] 脱敏 `apiKey` +- [ ] custom 对话框只展示: + - [ ] `name + icon` + - [ ] `type` + - [ ] `baseUrl` + - [ ] 脱敏 `apiKey` +- [ ] built-in 导入需要展示“将覆盖当前配置”的提示 +- [ ] 取消后不写入任何 provider 配置 + +### E. 错误处理 + +- [ ] 非法 Base64、非法 JSON、非法版本、缺字段、unknown `id/type` 均必须拒绝 +- [ ] 非法 deeplink 需要有可见错误提示 +- [ ] 拒绝场景不得写入 provider 配置 + +### F. Manual Playground + +- [ ] 新增 `test/manual/deeplink-playground.html` +- [ ] 页面覆盖三类 deeplink: + - [ ] `start` + - [ ] `mcp/install` + - [ ] `provider/install` +- [ ] `provider/install` 区块必须列出: + - [ ] 所有 built-in provider `id`,排除 `acp` + - [ ] 所有允许的 custom `apiType`,排除 `acp` +- [ ] 每项都提供 `Open` 和 `Copy` +- [ ] 每项都展示原始 JSON 与最终 deeplink +- [ ] 页面提供一个可编辑 builder,用于临时生成 deeplink +- [ ] 所有示例数据必须为 fake data + +## 验收标准 + +- [ ] 打开 built-in provider deeplink 时,设置窗进入对应 provider,并弹出确认对话框 +- [ ] 确认 built-in provider 导入后,`baseUrl/apiKey` 被覆盖,provider 被自动启用 +- [ ] 打开 custom provider deeplink 时,设置窗进入 provider 设置页,并弹出确认对话框 +- [ ] 确认 custom provider 导入后,会新增一条启用中的 custom provider +- [ ] 取消导入时,不产生任何配置写入 +- [ ] 非法 payload 只显示错误,不进入确认流程 +- [ ] 手工验证页可直接生成并打开三类 deeplink + +## 非目标 + +1. 不扩展 provider deeplink 的版本协商机制,本次仅支持 `v=1`。 +2. 不新增 provider 专属迁移脚本或持久化 schema。 +3. 不为 `acp` provider 引入导入能力。 +4. 不修改现有 `start` 与 `mcp/install` 的协议格式。 + +## 约束 + +1. 保持现有 Presenter + EventBus 架构。 +2. 所有用户可见文案必须走 i18n。 +3. 不破坏现有 provider 配置存储结构。 +4. Manual playground 不打包进应用,仅作为仓库内测试辅助页。 + +## 开放问题 + +无。 diff --git a/docs/specs/provider-deeplink-import/tasks.md b/docs/specs/provider-deeplink-import/tasks.md new file mode 100644 index 000000000..ca53fb43e --- /dev/null +++ b/docs/specs/provider-deeplink-import/tasks.md @@ -0,0 +1,59 @@ +# Provider Deeplink Import Tasks + +## T0 规格与设计 + +- [x] 完成 `spec.md` +- [x] 完成 `plan.md` +- [x] 完成 `tasks.md` + +## T1 共享协议与事件 + +- [x] 新增 provider deeplink 共享类型与协议常量 +- [x] 新增 `SETTINGS_EVENTS.PROVIDER_INSTALL` +- [x] 补共享类型导出 + +## T2 主进程解析与分发 + +- [x] 在 `deeplinkPresenter` 新增 `provider/install` 入口 +- [x] 校验 `v=1` +- [x] 校验 Base64 / JSON / 字段结构 +- [x] built-in 导入按 `id` 匹配 +- [x] custom 导入按 `type` 校验 +- [x] 拒绝 `acp` +- [x] 发送设置页导航与 preview 事件 +- [x] 非法 payload 显示错误通知 + +## T3 设置页预览与确认 + +- [x] 新增 pending import store +- [x] `App.vue` 监听 `PROVIDER_INSTALL` +- [x] built-in 导航到目标 provider +- [x] 新增 `ProviderDeeplinkImportDialog` +- [x] built-in confirm 覆盖 `baseUrl/apiKey` 并自动启用 +- [x] custom confirm 新增启用中的 custom provider +- [x] cancel 只清空 pending preview + +## T4 i18n 与测试 + +- [x] 补齐 provider import 对话框文案 +- [x] 新增 main deeplink 测试 +- [x] 新增 settings app 事件处理测试 +- [x] 新增 provider settings confirm 测试 + +## T5 Manual Playground + +- [x] 新增 `test/manual/deeplink-playground.html` +- [x] 覆盖 `start` +- [x] 覆盖 `mcp/install` +- [x] 覆盖 built-in provider import,排除 `acp` +- [x] 覆盖 custom provider import,排除 `acp` +- [x] 提供 builder、Open、Copy、raw JSON、deeplink 展示 +- [x] 更新 `test/README.md` + +## T6 质量检查 + +- [ ] `pnpm run format` +- [ ] `pnpm run i18n` +- [ ] `pnpm run lint` +- [ ] `pnpm run typecheck` +- [ ] 运行相关测试并记录结果 diff --git a/src/main/events.ts b/src/main/events.ts index 1d959c698..8e12fe8b4 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -123,7 +123,8 @@ export const WINDOW_EVENTS = { export const SETTINGS_EVENTS = { READY: 'settings:ready', NAVIGATE: 'settings:navigate', - CHECK_FOR_UPDATES: 'settings:check-for-updates' + CHECK_FOR_UPDATES: 'settings:check-for-updates', + PROVIDER_INSTALL: 'settings:provider-install' } // ollama 相关事件 diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 40fe04541..505f83e36 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -2,8 +2,21 @@ import { app, BrowserWindow } from 'electron' import { presenter } from '@/presenter' import { IDeeplinkPresenter, MCPServerConfig } from '@shared/presenter' import path from 'path' -import { DEEPLINK_EVENTS, MCP_EVENTS, WINDOW_EVENTS } from '@/events' +import { + NOTIFICATION_EVENTS, + SETTINGS_EVENTS, + DEEPLINK_EVENTS, + MCP_EVENTS, + WINDOW_EVENTS +} from '@/events' import { eventBus, SendTarget } from '@/eventbus' +import { + PROVIDER_INSTALL_VERSION, + isProviderInstallCustomType, + maskApiKey, + type ProviderInstallDeeplinkPayload, + type ProviderInstallPreview +} from '@shared/providerDeeplink' interface MCPInstallConfig { mcpServers: Record< @@ -204,6 +217,13 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } else { console.warn('Unknown MCP subcommand:', subCommand) } + } else if (command === 'provider') { + const subCommand = urlObj.pathname.slice(1) + if (subCommand === 'install') { + await this.handleProviderInstall(urlObj.searchParams) + } else { + console.warn('Unknown provider subcommand:', subCommand) + } } else { console.warn('Unknown DeepLink command:', command) } @@ -462,6 +482,35 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } } + async handleProviderInstall(params: URLSearchParams): Promise { + console.log( + 'Processing provider/install command, parameters:', + Object.fromEntries(params.entries()) + ) + + try { + const preview = this.parseProviderInstallParams(params) + const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() + if (!settingsWindowId) { + this.notifyProviderImportError('Failed to open settings window for provider deeplink.') + return + } + + presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, { + routeName: 'settings-provider' + }) + presenter.windowPresenter.sendToWindow( + settingsWindowId, + SETTINGS_EVENTS.PROVIDER_INSTALL, + preview + ) + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid provider deeplink.' + console.error('Error parsing provider install deeplink:', error) + this.notifyProviderImportError(message) + } + } + /** * Store MCP config in the first available app window localStorage. * @param mcpConfig MCP 配置对象 @@ -524,6 +573,158 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { }) } + private parseProviderInstallParams(params: URLSearchParams): ProviderInstallPreview { + const version = params.get('v') + if (version !== PROVIDER_INSTALL_VERSION) { + throw new Error(`Unsupported provider deeplink version: ${version || 'missing'}`) + } + + const rawData = params.get('data') + if (!rawData) { + throw new Error("Missing 'data' parameter") + } + + const payload = this.parseProviderInstallPayload(rawData) + + if ('id' in payload) { + const id = this.sanitizeStringParameter(payload.id) + const baseUrl = this.sanitizeProviderInstallField(payload.baseUrl, 'baseUrl') + const apiKey = this.sanitizeProviderInstallField(payload.apiKey, 'apiKey') + if (!id) { + throw new Error('Provider id is required.') + } + if (id === 'acp') { + throw new Error('ACP provider deeplinks are not supported.') + } + + const provider = presenter.configPresenter.getProviderById(id) + if (!provider) { + throw new Error(`Unknown provider id: ${id}`) + } + + return { + kind: 'builtin', + id, + baseUrl, + apiKey, + maskedApiKey: maskApiKey(apiKey), + iconModelId: id, + willOverwrite: true + } + } + + const type = this.sanitizeStringParameter(payload.type) + const name = this.sanitizeStringParameter(payload.name) + const baseUrl = this.sanitizeProviderInstallField(payload.baseUrl, 'baseUrl') + const apiKey = this.sanitizeProviderInstallField(payload.apiKey, 'apiKey') + if (!name) { + throw new Error('Provider name is required for custom provider imports.') + } + if (!type) { + throw new Error('Provider type is required for custom provider imports.') + } + if (type === 'acp') { + throw new Error('ACP provider deeplinks are not supported.') + } + if (!isProviderInstallCustomType(type)) { + throw new Error(`Unsupported provider type: ${type}`) + } + + return { + kind: 'custom', + name, + type, + baseUrl, + apiKey, + maskedApiKey: maskApiKey(apiKey), + iconModelId: type + } + } + + private parseProviderInstallPayload(rawData: string): ProviderInstallDeeplinkPayload { + const sanitizedBase64 = rawData.replace(/\s+/g, '') + if (!sanitizedBase64) { + throw new Error('Provider deeplink data is empty.') + } + + let jsonString = '' + try { + const buffer = Buffer.from(sanitizedBase64, 'base64') + const normalizedInput = sanitizedBase64.replace(/=+$/, '') + const normalizedOutput = buffer.toString('base64').replace(/=+$/, '') + if (normalizedInput !== normalizedOutput) { + throw new Error('Invalid base64 payload.') + } + jsonString = buffer.toString('utf8') + } catch (error) { + throw new Error( + error instanceof Error ? error.message : 'Failed to decode provider deeplink payload.' + ) + } + + let parsed: unknown + try { + parsed = JSON.parse(jsonString) + } catch { + throw new Error('Provider deeplink payload is not valid JSON.') + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Provider deeplink payload must be an object.') + } + + const payload = parsed as Partial & Record + const hasId = typeof payload.id === 'string' + const hasType = typeof payload.type === 'string' + + if (hasId === hasType) { + throw new Error("Provider deeplink payload must include either 'id' or 'type'.") + } + + if (typeof payload.baseUrl !== 'string') { + throw new Error("Provider deeplink payload must include a string 'baseUrl'.") + } + if (typeof payload.apiKey !== 'string') { + throw new Error("Provider deeplink payload must include a string 'apiKey'.") + } + + if (hasId) { + return { + id: payload.id as string, + baseUrl: payload.baseUrl, + apiKey: payload.apiKey + } + } + + if (typeof payload.name !== 'string') { + throw new Error("Custom provider deeplink payload must include a string 'name'.") + } + + return { + name: payload.name, + type: payload.type as string, + baseUrl: payload.baseUrl, + apiKey: payload.apiKey + } + } + + private sanitizeProviderInstallField(value: string, field: string): string { + const sanitized = this.sanitizeStringParameter(value) + if (value.trim().length > 0 && sanitized.length === 0) { + throw new Error(`Provider deeplink field '${field}' is invalid.`) + } + return sanitized + } + + private notifyProviderImportError(message: string): void { + eventBus.sendToRenderer(NOTIFICATION_EVENTS.SHOW_ERROR, SendTarget.ALL_WINDOWS, { + id: `provider-deeplink-${Date.now()}`, + title: 'Provider Deeplink', + message, + type: 'error' + }) + } + /** * 净化消息内容,防止恶意输入 * @param content 原始消息内容 diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 4ccc31704..125830f1a 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -72,8 +72,10 @@ import { useProviderStore } from '@/stores/providerStore' import { useModelStore } from '@/stores/modelStore' import { useOllamaStore } from '@/stores/ollamaStore' import { useMcpStore } from '@/stores/mcp' +import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' import { useMcpInstallDeeplinkHandler } from '../src/lib/storeInitializer' import { useFontManager } from '../src/composables/useFontManager' +import type { ProviderInstallPreview } from '@shared/presenter' const devicePresenter = usePresenter('devicePresenter') const windowPresenter = usePresenter('windowPresenter') @@ -92,6 +94,7 @@ const providerStore = useProviderStore() const modelStore = useModelStore() const ollamaStore = useOllamaStore() const mcpStore = useMcpStore() +const providerDeeplinkImportStore = useProviderDeeplinkImportStore() const { setup: setupMcpDeeplink, cleanup: cleanupMcpDeeplink } = useMcpInstallDeeplinkHandler() // Register MCP deeplink listener immediately to avoid race with incoming IPC setupMcpDeeplink() @@ -121,8 +124,29 @@ const handleSettingsNavigate = async ( } } +const handleProviderInstall = async (_event: unknown, payload?: ProviderInstallPreview) => { + if (!payload) return + + await providerStore.initialize() + await router.isReady() + + if (payload.kind === 'builtin') { + await router.push({ + name: 'settings-provider', + params: { + providerId: payload.id + } + }) + } else if (router.currentRoute.value.name !== 'settings-provider') { + await router.push({ name: 'settings-provider' }) + } + + providerDeeplinkImportStore.openPreview(payload) +} + if (window?.electron?.ipcRenderer) { window.electron.ipcRenderer.on(SETTINGS_EVENTS.NAVIGATE, handleSettingsNavigate) + window.electron.ipcRenderer.on(SETTINGS_EVENTS.PROVIDER_INSTALL, handleProviderInstall) } const notifySettingsReady = () => { @@ -347,6 +371,10 @@ onBeforeUnmount(() => { window.electron.ipcRenderer.removeAllListeners(NOTIFICATION_EVENTS.SHOW_ERROR) window.electron.ipcRenderer.removeListener(SETTINGS_EVENTS.NAVIGATE, handleSettingsNavigate) + window.electron.ipcRenderer.removeListener( + SETTINGS_EVENTS.PROVIDER_INSTALL, + handleProviderInstall + ) cleanupMcpDeeplink() }) diff --git a/src/renderer/settings/components/ModelProviderSettings.vue b/src/renderer/settings/components/ModelProviderSettings.vue index 166eae061..36f5b8730 100644 --- a/src/renderer/settings/components/ModelProviderSettings.vue +++ b/src/renderer/settings/components/ModelProviderSettings.vue @@ -186,6 +186,14 @@ v-model:open="isAddProviderDialogOpen" @provider-added="handleProviderAdded" /> + @@ -201,6 +209,7 @@ import BedrockProviderSettingsDetail from './BedrockProviderSettingsDetail.vue' import ModelIcon from '@/components/icons/ModelIcon.vue' import { Icon } from '@iconify/vue' import AddCustomProviderDialog from './AddCustomProviderDialog.vue' +import ProviderDeeplinkImportDialog from './ProviderDeeplinkImportDialog.vue' import { useI18n } from 'vue-i18n' import type { AWS_BEDROCK_PROVIDER, LLM_PROVIDER } from '@shared/presenter' import { Switch } from '@shadcn/components/ui/switch' @@ -211,6 +220,8 @@ import { ScrollArea } from '@shadcn/components/ui/scroll-area' import { useThemeStore } from '@/stores/theme' import { useLanguageStore } from '@/stores/language' import { onMounted } from 'vue' +import { nanoid } from 'nanoid' +import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' const route = useRoute() const router = useRouter() @@ -219,10 +230,12 @@ const languageStore = useLanguageStore() const providerStore = useProviderStore() const modelStore = useModelStore() const themeStore = useThemeStore() +const providerDeeplinkImportStore = useProviderDeeplinkImportStore() const isAddProviderDialogOpen = ref(false) const searchQueryBase = ref('') const searchQuery = refDebounced(searchQueryBase, 150) const showClearButton = computed(() => searchQueryBase.value.trim().length > 0) +const isImportingProvider = ref(false) const editingProviderId = ref(null) const editingName = ref('') @@ -378,6 +391,77 @@ const handleProviderAdded = (provider: LLM_PROVIDER) => { setActiveProvider(provider.id) } +const pendingImportPreview = computed(() => providerDeeplinkImportStore.preview) + +const providerImportConfirmDisabled = computed(() => { + const preview = pendingImportPreview.value + if (!preview) { + return true + } + + if (preview.kind === 'builtin') { + return !providerStore.providers.some((provider) => provider.id === preview.id) + } + + return false +}) + +const handleProviderImportDialogOpenChange = (open: boolean) => { + if (!open) { + providerDeeplinkImportStore.clearPreview() + } +} + +const confirmProviderImport = async () => { + const preview = pendingImportPreview.value + if (!preview || isImportingProvider.value) { + return + } + + isImportingProvider.value = true + try { + if (preview.kind === 'builtin') { + const targetProvider = providerStore.providers.find((provider) => provider.id === preview.id) + if (!targetProvider) { + return + } + + await providerStore.updateProviderApi(preview.id, preview.apiKey, preview.baseUrl) + if (!targetProvider.enable) { + await providerStore.updateProviderStatus(preview.id, true) + } + + await modelStore.refreshProviderModels(preview.id) + setActiveProvider(preview.id) + await nextTick() + scrollToProvider(preview.id) + } else { + const providerId = nanoid() + const newProvider: LLM_PROVIDER = { + id: providerId, + name: preview.name, + apiType: preview.type, + apiKey: preview.apiKey, + baseUrl: preview.baseUrl, + enable: true, + custom: true + } + + await providerStore.addCustomProvider(newProvider) + await modelStore.refreshProviderModels(providerId) + setActiveProvider(providerId) + await nextTick() + scrollToProvider(providerId) + } + + providerDeeplinkImportStore.clearPreview() + } catch (error) { + console.error('Failed to import provider from deeplink:', error) + } finally { + isImportingProvider.value = false + } +} + onMounted(async () => { if (!providerStore.providers.length) { await providerStore.refreshProviders() diff --git a/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue b/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue new file mode 100644 index 000000000..988d4575d --- /dev/null +++ b/src/renderer/settings/components/ProviderDeeplinkImportDialog.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/renderer/src/events.ts b/src/renderer/src/events.ts index 7732f08b8..b59e5fbe4 100644 --- a/src/renderer/src/events.ts +++ b/src/renderer/src/events.ts @@ -78,7 +78,8 @@ export const WINDOW_EVENTS = { export const SETTINGS_EVENTS = { READY: 'settings:ready', NAVIGATE: 'settings:navigate', - CHECK_FOR_UPDATES: 'settings:check-for-updates' + CHECK_FOR_UPDATES: 'settings:check-for-updates', + PROVIDER_INSTALL: 'settings:provider-install' } // ollama 相关事件 diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index e15c33a2b..f7f32bd7e 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -492,6 +492,15 @@ "baseUrlPlaceholder": "Indtast basis-URL'en for API'et", "enable": "Aktivér udbyder" }, + "providerDeeplinkImport": { + "title": "Importer udbyder", + "description": "Gennemgå den fortolkede udbyderkonfiguration, før du anvender den.", + "type": "Type", + "url": "URL", + "key": "Nøgle", + "overwriteWarning": "Denne import vil overskrive den nuværende udbyderkonfiguration.", + "confirming": "Importerer..." + }, "deleteProvider": { "title": "Bekræft sletning af udbyder", "content": "Er du sikker på, at du vil slette udbyderen \"{name}\"? Handlingen kan ikke fortrydes.", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 881aea336..9f7d361e5 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -577,6 +577,15 @@ "baseUrlPlaceholder": "Please enter the API base URL", "enable": "Enable Provider" }, + "providerDeeplinkImport": { + "title": "Import Provider", + "description": "Review the parsed provider configuration before applying it.", + "type": "Type", + "url": "URL", + "key": "Key", + "overwriteWarning": "This import will overwrite the current provider configuration.", + "confirming": "Importing..." + }, "deleteProvider": { "title": "Confirm Delete Provider", "content": "Are you sure you want to delete provider \"{name}\"? This action cannot be undone.", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 8c80502fa..7cda8bece 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "لطفاً نشانی پایه API را وارد کنید", "enable": "روشن کردن فراهم‌کننده" }, + "providerDeeplinkImport": { + "title": "درون‌ریزی فراهم‌کننده", + "description": "پیش از اعمال، پیکربندی تحلیل‌شدهٔ فراهم‌کننده را بررسی کنید.", + "type": "نوع", + "url": "نشانی", + "key": "کلید", + "overwriteWarning": "این درون‌ریزی پیکربندی فعلی فراهم‌کننده را بازنویسی می‌کند.", + "confirming": "در حال درون‌ریزی..." + }, "deleteProvider": { "title": "پذیرش پاک کردن فراهم‌کننده", "content": "آیا مطمئن هستید که می‌خواهید فراهم‌کننده \"{name}\" را پاک کنید؟ این کنش بازگشت‌پذیر نیست.", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 046ce7255..6c88aaf29 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "Veuillez entrer l'URL de base de l'API", "enable": "Activer le fournisseur" }, + "providerDeeplinkImport": { + "title": "Importer un fournisseur", + "description": "Vérifiez la configuration du fournisseur analysée avant de l'appliquer.", + "type": "Type", + "url": "URL", + "key": "Clé", + "overwriteWarning": "Cette importation écrasera la configuration actuelle du fournisseur.", + "confirming": "Importation..." + }, "deleteProvider": { "title": "Confirmer la suppression du fournisseur", "content": "Êtes-vous sûr de vouloir supprimer le fournisseur \"{name}\" ? Cette action ne peut pas être annulée.", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 82d3b9886..98fb75a09 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "אנא הזן את כתובת הבסיס ל-API", "enable": "הפעל ספק" }, + "providerDeeplinkImport": { + "title": "ייבוא ספק", + "description": "בדוק את תצורת הספק שפוענחה לפני החלתה.", + "type": "סוג", + "url": "כתובת URL", + "key": "מפתח", + "overwriteWarning": "ייבוא זה ידרוס את תצורת הספק הנוכחית.", + "confirming": "מייבא..." + }, "deleteProvider": { "title": "אשר מחיקת ספק", "content": "האם אתה בטוח שברצונך למחוק את הספק \"{name}\"? לא ניתן לבטל פעולה זו.", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 5487b0dde..14bc41a5b 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "API URLを入力してください", "enable": "プロバイダーを有効にする" }, + "providerDeeplinkImport": { + "title": "プロバイダーをインポート", + "description": "適用する前に、解析されたプロバイダー設定を確認してください。", + "type": "タイプ", + "url": "URL", + "key": "キー", + "overwriteWarning": "このインポートにより、現在のプロバイダー設定が上書きされます。", + "confirming": "インポート中..." + }, "deleteProvider": { "title": "プロバイダーの削除確認", "content": "プロバイダー \"{name}\" を削除してもよろしいですか?この操作は元に戻せません。", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 54fe00083..de4ffac21 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -555,6 +555,15 @@ "baseUrlPlaceholder": "기본 URL을 입력하세요", "enable": "제공자 활성화" }, + "providerDeeplinkImport": { + "title": "제공자 가져오기", + "description": "적용하기 전에 파싱된 제공자 설정을 확인하세요.", + "type": "유형", + "url": "URL", + "key": "키", + "overwriteWarning": "이 가져오기는 현재 제공자 설정을 덮어씁니다.", + "confirming": "가져오는 중..." + }, "deleteProvider": { "title": "제공자 삭제", "content": "제공자 \"{name}\"을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 523bbbd50..f57c81d73 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "Por favor, insira a URL base da API", "enable": "Habilitar Provedor" }, + "providerDeeplinkImport": { + "title": "Importar provedor", + "description": "Revise a configuração de provedor analisada antes de aplicá-la.", + "type": "Tipo", + "url": "URL", + "key": "Chave", + "overwriteWarning": "Esta importação substituirá a configuração atual do provedor.", + "confirming": "Importando..." + }, "deleteProvider": { "title": "Confirmar Exclusão do Provedor", "content": "Tem certeza de que deseja excluir o provedor \"{name}\"? Esta ação não pode ser desfeita.", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 296d027dd..b5745de24 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -555,6 +555,15 @@ "baseUrlPlaceholder": "Введите API URL", "enable": "Включить провайдер" }, + "providerDeeplinkImport": { + "title": "Импорт провайдера", + "description": "Проверьте разобранную конфигурацию провайдера перед применением.", + "type": "Тип", + "url": "URL", + "key": "Ключ", + "overwriteWarning": "Этот импорт перезапишет текущую конфигурацию провайдера.", + "confirming": "Импорт..." + }, "deleteProvider": { "title": "Подтверждение удаления провайдера", "content": "Вы уверены, что хотите удалить провайдера \"{name}\"? Это действие нельзя отменить.", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index d6795205e..8f7d95a8f 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -676,6 +676,15 @@ "baseUrlPlaceholder": "请输入API基础地址", "enable": "启用服务商" }, + "providerDeeplinkImport": { + "title": "导入服务商", + "description": "请确认解析出的服务商配置后再导入。", + "type": "类型", + "url": "地址", + "key": "密钥", + "overwriteWarning": "本次导入会覆盖当前服务商配置。", + "confirming": "导入中..." + }, "deleteProvider": { "title": "确认删除服务商", "content": "是否确认删除服务商 \"{name}\"?此操作不可恢复。", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index a0ca15047..f49e9a966 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -553,6 +553,15 @@ "baseUrlPlaceholder": "請輸入API基礎地址", "enable": "啟用服務商" }, + "providerDeeplinkImport": { + "title": "導入服務商", + "description": "請先確認解析出的服務商配置,再決定是否導入。", + "type": "類型", + "url": "地址", + "key": "密鑰", + "overwriteWarning": "本次導入會覆蓋目前的服務商設定。", + "confirming": "導入中..." + }, "deleteProvider": { "title": "確認刪除服務商", "content": "是否確認刪除服務商 \"{name}\"?此操作不可恢復。", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 2d62ca4d6..efdaf07b0 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -558,6 +558,15 @@ "baseUrlPlaceholder": "請輸入 API 基礎網址", "enable": "啟用服務提供者" }, + "providerDeeplinkImport": { + "title": "匯入服務商", + "description": "請先確認解析出的服務商設定,再決定是否匯入。", + "type": "類型", + "url": "位址", + "key": "金鑰", + "overwriteWarning": "這次匯入會覆蓋目前的服務商設定。", + "confirming": "匯入中..." + }, "deleteProvider": { "title": "確認刪除服務提供者", "content": "是否確認刪除服務提供者「{name}」?此操作無法復原。", diff --git a/src/renderer/src/stores/providerDeeplinkImport.ts b/src/renderer/src/stores/providerDeeplinkImport.ts new file mode 100644 index 000000000..072a24c7b --- /dev/null +++ b/src/renderer/src/stores/providerDeeplinkImport.ts @@ -0,0 +1,21 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { ProviderInstallPreview } from '@shared/presenter' + +export const useProviderDeeplinkImportStore = defineStore('providerDeeplinkImport', () => { + const preview = ref(null) + + const openPreview = (nextPreview: ProviderInstallPreview) => { + preview.value = nextPreview + } + + const clearPreview = () => { + preview.value = null + } + + return { + preview, + openPreview, + clearPreview + } +}) diff --git a/src/shared/providerDeeplink.ts b/src/shared/providerDeeplink.ts new file mode 100644 index 000000000..37f8b1d2a --- /dev/null +++ b/src/shared/providerDeeplink.ts @@ -0,0 +1,95 @@ +export const PROVIDER_INSTALL_ROUTE = 'provider/install' +export const PROVIDER_INSTALL_VERSION = '1' + +export const SUPPORTED_PROVIDER_INSTALL_CUSTOM_TYPES = [ + 'minimax', + 'deepseek', + 'silicon', + 'siliconcloud', + 'dashscope', + 'ppio', + 'gemini', + 'vertex', + 'zhipu', + 'github', + 'github-copilot', + 'ollama', + 'anthropic', + 'doubao', + 'openai', + 'openai-completions', + 'voiceai', + 'openai-compatible', + 'openai-responses', + 'lmstudio', + 'together', + 'groq', + 'grok', + 'vercel-ai-gateway', + 'poe', + 'aws-bedrock', + 'jiekou', + 'zenmux', + 'o3fan' +] as const + +const SUPPORTED_PROVIDER_INSTALL_CUSTOM_TYPE_SET = new Set( + SUPPORTED_PROVIDER_INSTALL_CUSTOM_TYPES +) + +export type SupportedProviderInstallCustomType = + (typeof SUPPORTED_PROVIDER_INSTALL_CUSTOM_TYPES)[number] + +export type ProviderInstallByIdPayload = { + id: string + baseUrl: string + apiKey: string +} + +export type ProviderInstallByTypePayload = { + name: string + type: string + baseUrl: string + apiKey: string +} + +export type ProviderInstallDeeplinkPayload = + | ProviderInstallByIdPayload + | ProviderInstallByTypePayload + +export type ProviderInstallPreview = + | { + kind: 'builtin' + id: string + baseUrl: string + apiKey: string + maskedApiKey: string + iconModelId: string + willOverwrite: boolean + } + | { + kind: 'custom' + name: string + type: string + baseUrl: string + apiKey: string + maskedApiKey: string + iconModelId: string + } + +export const maskApiKey = (value: string): string => { + if (!value) { + return '' + } + + if (value.length <= 8) { + return `${value.slice(0, 2)}***${value.slice(-2)}` + } + + return `${value.slice(0, 4)}...${value.slice(-4)}` +} + +export const isProviderInstallCustomType = ( + value: string +): value is SupportedProviderInstallCustomType => + SUPPORTED_PROVIDER_INSTALL_CUSTOM_TYPE_SET.has(value) diff --git a/src/shared/types/index.d.ts b/src/shared/types/index.d.ts index 6753f62ae..cd11d329b 100644 --- a/src/shared/types/index.d.ts +++ b/src/shared/types/index.d.ts @@ -5,6 +5,13 @@ export type * from './presenters/agent-provider' export type * from './presenters/workspace' export type * from './presenters/tool.presenter' export type * from '../hooksNotifications' +export type { + ProviderInstallByIdPayload, + ProviderInstallByTypePayload, + ProviderInstallDeeplinkPayload, + ProviderInstallPreview, + SupportedProviderInstallCustomType +} from '../providerDeeplink' export * from './browser' export * from './chatSettings' export * from './skill' diff --git a/test/README.md b/test/README.md index efefe5669..52b1d1eef 100644 --- a/test/README.md +++ b/test/README.md @@ -18,6 +18,34 @@ test/ ## 🚀 快速开始 +## 🔗 手工验证 Deeplink Playground + +仓库内提供了一个静态验证页: + +- `test/manual/deeplink-playground.html` + +用途: + +- 验证 `deepchat://start` +- 验证 `deepchat://mcp/install` +- 验证 `deepchat://provider/install` + +使用方式: + +```bash +# 直接在浏览器中打开 +test/manual/deeplink-playground.html +``` + +说明: + +- 页面内置了示例 payload、Base64 编码结果和最终 deeplink +- `provider/install` 区块覆盖了当前支持的 built-in provider 与 custom `apiType` +- 页面里的 key 全部是 fake data,仅用于本地联调 +- 若浏览器拦截自定义协议,请允许页面打开 `deepchat://` 链接 + +如果要验证应用内行为,建议先启动 DeepChat,再点击页面中的 `Open` 按钮。 + ### 安装测试依赖 首先需要安装Vue组件测试所需的依赖: diff --git a/test/main/presenter/deeplinkPresenter.test.ts b/test/main/presenter/deeplinkPresenter.test.ts new file mode 100644 index 000000000..310713d85 --- /dev/null +++ b/test/main/presenter/deeplinkPresenter.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NOTIFICATION_EVENTS, SETTINGS_EVENTS } from '@/events' + +const presenterMock = vi.hoisted(() => ({ + windowPresenter: { + createSettingsWindow: vi.fn().mockResolvedValue(9), + sendToWindow: vi.fn(), + getAllWindows: vi.fn().mockReturnValue([]), + getFocusedWindow: vi.fn().mockReturnValue(null) + }, + configPresenter: { + getProviderById: vi.fn() + }, + mcpPresenter: { + isReady: vi.fn().mockReturnValue(true) + } +})) + +const eventBusMock = vi.hoisted(() => ({ + once: vi.fn(), + on: vi.fn(), + off: vi.fn(), + sendToRenderer: vi.fn() +})) + +vi.mock('@/presenter', () => ({ + presenter: presenterMock +})) + +vi.mock('@/eventbus', () => ({ + eventBus: eventBusMock, + SendTarget: { + ALL_WINDOWS: 'all_windows' + } +})) + +describe('DeeplinkPresenter provider install', () => { + beforeEach(() => { + vi.clearAllMocks() + presenterMock.windowPresenter.createSettingsWindow.mockResolvedValue(9) + presenterMock.configPresenter.getProviderById.mockImplementation((providerId: string) => { + if (providerId === 'openai') { + return { + id: 'openai', + name: 'OpenAI', + apiType: 'openai', + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + enable: false + } + } + + return undefined + }) + }) + + it('routes built-in provider imports to settings and sends preview payload', async () => { + const { DeeplinkPresenter } = await import('@/presenter/deeplinkPresenter') + const deeplinkPresenter = new DeeplinkPresenter() + const payload = { + id: 'openai', + baseUrl: 'https://proxy.example.com/v1', + apiKey: 'sk-import-1234' + } + const url = `deepchat://provider/install?v=1&data=${Buffer.from(JSON.stringify(payload)).toString('base64')}` + + await deeplinkPresenter.handleDeepLink(url) + + expect(presenterMock.windowPresenter.createSettingsWindow).toHaveBeenCalledTimes(1) + expect(presenterMock.windowPresenter.sendToWindow).toHaveBeenNthCalledWith( + 1, + 9, + SETTINGS_EVENTS.NAVIGATE, + { + routeName: 'settings-provider' + } + ) + expect(presenterMock.windowPresenter.sendToWindow).toHaveBeenNthCalledWith( + 2, + 9, + SETTINGS_EVENTS.PROVIDER_INSTALL, + expect.objectContaining({ + kind: 'builtin', + id: 'openai', + baseUrl: 'https://proxy.example.com/v1', + apiKey: 'sk-import-1234', + iconModelId: 'openai', + willOverwrite: true + }) + ) + }) + + it('routes custom provider imports to settings and sends preview payload', async () => { + const { DeeplinkPresenter } = await import('@/presenter/deeplinkPresenter') + const deeplinkPresenter = new DeeplinkPresenter() + const payload = { + name: 'My Proxy', + type: 'openai-completions', + baseUrl: 'https://custom.example.com/v1', + apiKey: 'sk-custom-5678' + } + const url = `deepchat://provider/install?v=1&data=${Buffer.from(JSON.stringify(payload)).toString('base64')}` + + await deeplinkPresenter.handleDeepLink(url) + + expect(presenterMock.windowPresenter.sendToWindow).toHaveBeenNthCalledWith( + 2, + 9, + SETTINGS_EVENTS.PROVIDER_INSTALL, + expect.objectContaining({ + kind: 'custom', + name: 'My Proxy', + type: 'openai-completions', + baseUrl: 'https://custom.example.com/v1', + apiKey: 'sk-custom-5678', + iconModelId: 'openai-completions' + }) + ) + }) + + it('rejects invalid provider payloads and emits an error notification', async () => { + const { DeeplinkPresenter } = await import('@/presenter/deeplinkPresenter') + const deeplinkPresenter = new DeeplinkPresenter() + const payload = { + id: 'openai', + type: 'openai-completions', + name: 'invalid', + baseUrl: 'https://invalid.example.com/v1', + apiKey: 'sk-invalid' + } + const url = `deepchat://provider/install?v=1&data=${Buffer.from(JSON.stringify(payload)).toString('base64')}` + + await deeplinkPresenter.handleDeepLink(url) + + expect(presenterMock.windowPresenter.createSettingsWindow).not.toHaveBeenCalled() + expect(eventBusMock.sendToRenderer).toHaveBeenCalledWith( + NOTIFICATION_EVENTS.SHOW_ERROR, + 'all_windows', + expect.objectContaining({ + title: 'Provider Deeplink', + type: 'error' + }) + ) + }) +}) diff --git a/test/manual/deeplink-playground.html b/test/manual/deeplink-playground.html new file mode 100644 index 000000000..a73f4a76f --- /dev/null +++ b/test/manual/deeplink-playground.html @@ -0,0 +1,865 @@ + + + + + + DeepChat Deeplink Playground + + + +
+
+
+

DeepChat Deeplink Playground

+

+ 这个页面用于手工验证当前仓库支持的 deeplink。它会展示原始 JSON、Base64 + 编码后的参数,以及最终 `deepchat://...` 链接。 +

+ +
+
+
+
Scope
+
`start` / `mcp/install` / `provider/install`
+
+
+
Data
+
全部使用 fake message、fake baseUrl、fake apiKey
+
+
+
Open
+
浏览器会尝试唤起 `deepchat://` 协议,部分环境会先弹确认
+
+
+
Copy
+
复制的是最终 deeplink,可直接用于外部联调
+
+
+
+ +
+ 验证前建议 +
    +
  • 先启动 DeepChat,再点 `Open`,这样最容易观察设置页和对话框行为。
  • +
  • `provider/install` 的 built-in 导入是覆盖语义,custom 导入是新增语义。
  • +
  • `acp` 不在本页 provider 导入列表中,因为它不属于 `settings-provider` 导入流。
  • +
+
+ +
+
+
+

start

+

用于唤起应用并把预置消息、模型或 mentions 带入新会话。

+
+
0 条示例
+
+
+
+ +
+
+
+

mcp/install

+

用于安装 stdio / sse MCP 配置。当前协议参数名是 `code`。

+
+
0 条示例
+
+
+
+ +
+
+
+

provider/install

+

+ built-in 用 `id` 匹配并覆盖;custom 用 `name + type` 新增。列表已排除 `acp`。 +

+
+
+ built-in 0 条 / custom + 0 条 +
+
+
+
+ Filter + +
+
+
+
+
+ Built-in Providers +
+
+
+
+
+ Custom Provider Types +
+
+
+
+
+ +
+
+
+

provider/install builder

+

+ 这里可以临时改 `id / name / type / baseUrl / apiKey`,实时生成符合应用解析格式的 + deeplink。 +

+
+
+
+
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+
+
+
Raw JSON
+

+            
+
+
Base64
+

+            
+
+
+
DeepLink
+
+ + +
+
+ +
+
+
+
+ + +
+ + + + diff --git a/test/renderer/components/ModelProviderSettings.test.ts b/test/renderer/components/ModelProviderSettings.test.ts index ae2662335..d42883ef4 100644 --- a/test/renderer/components/ModelProviderSettings.test.ts +++ b/test/renderer/components/ModelProviderSettings.test.ts @@ -8,40 +8,46 @@ const passthrough = (name: string) => template: '
' }) -const setup = async () => { +const setup = async (options?: { + preview?: Record | null + providerEnabled?: boolean +}) => { vi.resetModules() + const provider = { + id: 'anthropic', + name: 'Anthropic', + apiType: 'anthropic', + apiKey: 'test-key', + baseUrl: 'https://api.anthropic.com', + enable: options?.providerEnabled ?? true + } const providerStore = reactive({ - providers: [ - { - id: 'anthropic', - name: 'Anthropic', - apiType: 'anthropic', - apiKey: 'test-key', - baseUrl: 'https://api.anthropic.com', - enable: true - } - ], - sortedProviders: [ - { - id: 'anthropic', - name: 'Anthropic', - apiType: 'anthropic', - apiKey: 'test-key', - baseUrl: 'https://api.anthropic.com', - enable: true - } - ], + providers: [provider], + sortedProviders: [provider], refreshProviders: vi.fn().mockResolvedValue(undefined), updateProviderConfig: vi.fn().mockResolvedValue(undefined), + updateProviderApi: vi.fn().mockResolvedValue(undefined), updateProviderStatus: vi.fn().mockResolvedValue(undefined), + addCustomProvider: vi.fn().mockResolvedValue(undefined), updateProvidersOrder: vi.fn(), defaultProviders: [] }) const modelStore = reactive({ allProviderModels: [{ providerId: 'anthropic', models: [{ id: 'claude-sonnet' }] }], - refreshAllModels: vi.fn().mockResolvedValue(undefined) + refreshAllModels: vi.fn().mockResolvedValue(undefined), + refreshProviderModels: vi.fn().mockResolvedValue(undefined) + }) + + const clearPreview = vi.fn() + const providerDeeplinkImportStore = reactive({ + preview: options?.preview ?? null, + clearPreview, + openPreview: vi.fn() + }) + clearPreview.mockImplementation(() => { + providerDeeplinkImportStore.preview = null }) const router = { @@ -55,6 +61,9 @@ const setup = async () => { vi.doMock('@/stores/modelStore', () => ({ useModelStore: () => modelStore })) + vi.doMock('@/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => providerDeeplinkImportStore + })) vi.doMock('@/stores/theme', () => ({ useThemeStore: () => ({ isDark: false }) })) @@ -73,6 +82,9 @@ const setup = async () => { t: (key: string) => key }) })) + vi.doMock('nanoid', () => ({ + nanoid: () => 'custom-provider-id' + })) const ModelProviderSettings = ( await import('../../../src/renderer/settings/components/ModelProviderSettings.vue') @@ -89,6 +101,13 @@ const setup = async () => { ModelIcon: true, draggable: passthrough('draggable'), AddCustomProviderDialog: true, + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: ['open', 'preview'], + emits: ['confirm', 'update:open'], + template: + '
{{ preview?.kind }}
' + }), OllamaProviderSettingsDetail: defineComponent({ name: 'OllamaProviderSettingsDetail', template: '
' @@ -111,7 +130,7 @@ const setup = async () => { await flushPromises() - return { wrapper, router } + return { wrapper, router, providerStore, modelStore, providerDeeplinkImportStore } } describe('ModelProviderSettings', () => { @@ -125,4 +144,80 @@ describe('ModelProviderSettings', () => { expect(wrapper.find('[data-testid="generic-detail"]').exists()).toBe(true) expect(wrapper.find('[data-testid="anthropic-detail"]').exists()).toBe(false) }) + + it('confirms built-in provider imports and enables the provider', async () => { + const preview = { + kind: 'builtin', + id: 'anthropic', + baseUrl: 'https://proxy.example.com/v1', + apiKey: 'sk-import-1234', + maskedApiKey: 'sk-i...1234', + iconModelId: 'anthropic', + willOverwrite: true + } + const { wrapper, providerStore, modelStore, providerDeeplinkImportStore, router } = await setup( + { + preview, + providerEnabled: false + } + ) + + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerStore.updateProviderApi).toHaveBeenCalledWith( + 'anthropic', + 'sk-import-1234', + 'https://proxy.example.com/v1' + ) + expect(providerStore.updateProviderStatus).toHaveBeenCalledWith('anthropic', true) + expect(modelStore.refreshProviderModels).toHaveBeenCalledWith('anthropic') + expect(router.push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'anthropic' + } + }) + expect(providerDeeplinkImportStore.clearPreview).toHaveBeenCalledTimes(1) + }) + + it('confirms custom provider imports and creates a new provider entry', async () => { + const preview = { + kind: 'custom', + name: 'My Proxy', + type: 'openai-completions', + baseUrl: 'https://custom.example.com/v1', + apiKey: 'sk-custom-5678', + maskedApiKey: 'sk-c...5678', + iconModelId: 'openai-completions' + } + const { wrapper, providerStore, modelStore, providerDeeplinkImportStore, router } = await setup( + { + preview + } + ) + + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerStore.addCustomProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'custom-provider-id', + name: 'My Proxy', + apiType: 'openai-completions', + apiKey: 'sk-custom-5678', + baseUrl: 'https://custom.example.com/v1', + enable: true, + custom: true + }) + ) + expect(modelStore.refreshProviderModels).toHaveBeenCalledWith('custom-provider-id') + expect(router.push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'custom-provider-id' + } + }) + expect(providerDeeplinkImportStore.clearPreview).toHaveBeenCalledTimes(1) + }) }) diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts index 27d86a18b..5143e8641 100644 --- a/test/renderer/components/SettingsApp.test.ts +++ b/test/renderer/components/SettingsApp.test.ts @@ -104,6 +104,11 @@ describe('Settings App', () => { initialize: vi.fn().mockResolvedValue(undefined) }) })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + openPreview: vi.fn() + }) + })) vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ useModelStore: () => ({ initialize: vi.fn().mockResolvedValue(undefined) @@ -292,6 +297,11 @@ describe('Settings App', () => { initialize: vi.fn().mockResolvedValue(undefined) }) })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + openPreview: vi.fn() + }) + })) vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ useModelStore: () => ({ initialize: vi.fn().mockResolvedValue(undefined) @@ -381,4 +391,221 @@ describe('Settings App', () => { expect(push).toHaveBeenCalledWith({ name: 'settings-deepchat-agents' }) }) + + it('navigates to provider settings and stores provider deeplink previews', async () => { + vi.resetModules() + + const push = vi.fn().mockResolvedValue(undefined) + const isReady = vi.fn().mockResolvedValue(undefined) + const ipcOn = vi.fn() + const ipcRemoveListener = vi.fn() + const ipcRemoveAllListeners = vi.fn() + const ipcSend = vi.fn() + const providerStore = { + initialize: vi.fn().mockResolvedValue(undefined) + } + const providerDeeplinkImportStore = { + openPreview: vi.fn() + } + + ;(window as any).electron = { + ipcRenderer: { + on: ipcOn, + removeListener: ipcRemoveListener, + removeAllListeners: ipcRemoveAllListeners, + send: ipcSend + } + } + + vi.doMock('vue-router', () => { + const currentRoute = ref({ name: 'settings-common', query: {}, params: {}, path: '/common' }) + const router = { + hasRoute: vi.fn((routeName: string) => routeName === 'settings-provider'), + isReady, + push, + replace: vi.fn().mockResolvedValue(undefined), + getRoutes: vi.fn(() => [ + { + path: '/common', + name: 'settings-common', + meta: { titleKey: 'routes.settings-common', icon: 'lucide:bolt', position: 1 } + }, + { + path: '/provider/:providerId?', + name: 'settings-provider', + meta: { + titleKey: 'routes.settings-provider', + icon: 'lucide:cloud-cog', + position: 3 + } + } + ]), + currentRoute + } + + return { + useRouter: () => router, + useRoute: () => currentRoute.value, + RouterView: { + name: 'RouterView', + template: '
' + } + } + }) + + vi.doMock('../../../src/renderer/src/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'devicePresenter') { + return { + getDeviceInfo: vi.fn().mockResolvedValue({ platform: 'darwin' }) + } + } + if (name === 'windowPresenter') { + return { + closeSettingsWindow: vi.fn() + } + } + if (name === 'configPresenter') { + return { + getLanguage: vi.fn().mockResolvedValue('zh-CN') + } + } + return {} + } + })) + vi.doMock('../../../src/renderer/src/stores/uiSettingsStore', () => ({ + useUiSettingsStore: () => ({ + fontSizeClass: 'text-base', + loadSettings: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/language', () => ({ + useLanguageStore: () => ({ + language: 'zh-CN', + dir: 'ltr' + }) + })) + vi.doMock('../../../src/renderer/src/stores/modelCheck', () => ({ + useModelCheckStore: () => ({ + isDialogOpen: false, + currentProviderId: null, + closeDialog: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/stores/theme', () => ({ + useThemeStore: () => ({ + themeMode: 'light', + isDark: false + }) + })) + vi.doMock('../../../src/renderer/src/stores/providerStore', () => ({ + useProviderStore: () => providerStore + })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => providerDeeplinkImportStore + })) + vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ + useModelStore: () => ({ + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/ollamaStore', () => ({ + useOllamaStore: () => ({ + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/mcp', () => ({ + useMcpStore: () => ({ + mcpEnabled: false, + setMcpEnabled: vi.fn().mockResolvedValue(undefined), + setMcpInstallCache: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/lib/storeInitializer', () => ({ + useMcpInstallDeeplinkHandler: () => ({ + setup: vi.fn(), + cleanup: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/composables/useFontManager', () => ({ + useFontManager: () => ({ + setupFontListener: vi.fn() + }) + })) + vi.doMock('../../../src/renderer/src/composables/useDeviceVersion', () => ({ + useDeviceVersion: () => ({ + isMacOS: ref(false), + isWinMacOS: true + }) + })) + vi.doMock('@vueuse/core', () => ({ + useTitle: () => ref('') + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + locale: ref('zh-CN') + }) + })) + vi.doMock('@iconify/vue', () => ({ + Icon: { + name: 'Icon', + template: '' + } + })) + vi.doMock('@/components/use-toast', () => ({ + useToast: () => ({ + toast: vi.fn(() => ({ dismiss: vi.fn() })) + }) + })) + + const SettingsApp = (await import('../../../src/renderer/settings/App.vue')).default + mount(SettingsApp, { + global: { + stubs: { + Button: true, + RouterView: true, + CloseIcon: true, + ModelCheckDialog: defineComponent({ + name: 'ModelCheckDialog', + props: { + open: { type: Boolean, default: false }, + providerId: { type: null, default: null } + }, + template: '
' + }), + Toaster: true, + Icon: true + } + } + }) + + await Promise.resolve() + await Promise.resolve() + + const installHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === SETTINGS_EVENTS.PROVIDER_INSTALL + )?.[1] + const payload = { + kind: 'builtin', + id: 'openai', + baseUrl: 'https://proxy.example.com/v1', + apiKey: 'sk-import-1234', + maskedApiKey: 'sk-i...1234', + iconModelId: 'openai', + willOverwrite: true + } + + expect(installHandler).toBeTypeOf('function') + + await installHandler?.({}, payload) + + expect(push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'openai' + } + }) + expect(providerDeeplinkImportStore.openPreview).toHaveBeenCalledWith(payload) + }) }) From 8d058f4264e75a8e09a07f9a921910b0ef62ce94 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Thu, 26 Mar 2026 16:00:23 +0800 Subject: [PATCH 2/5] fix(deeplink): harden startup and settings --- src/main/index.ts | 99 ++-- src/main/lib/startupDeepLink.ts | 68 +++ src/main/presenter/deeplinkPresenter/index.ts | 279 +++-------- src/main/presenter/windowPresenter/index.ts | 22 +- src/renderer/settings/App.vue | 204 +++++--- .../components/ModelProviderSettings.vue | 84 ---- src/renderer/src/App.vue | 77 ++- src/renderer/src/pages/NewThreadPage.vue | 78 ++- .../src/stores/providerDeeplinkImport.ts | 5 +- src/renderer/src/stores/ui/draft.ts | 31 +- .../types/presenters/legacy.presenters.d.ts | 6 + test/main/lib/startupDeepLink.test.ts | 46 ++ test/main/presenter/deeplinkPresenter.test.ts | 142 +++++- test/renderer/components/App.startup.test.ts | 89 +++- .../components/ModelProviderSettings.test.ts | 123 +---- .../SettingsApp.providerDeeplink.test.ts | 458 ++++++++++++++++++ test/renderer/components/SettingsApp.test.ts | 281 ++++++++++- test/renderer/pages/NewThreadPage.test.ts | 171 +++++++ .../providerDeeplinkImportStore.test.ts | 32 ++ 19 files changed, 1752 insertions(+), 543 deletions(-) create mode 100644 src/main/lib/startupDeepLink.ts create mode 100644 test/main/lib/startupDeepLink.test.ts create mode 100644 test/renderer/components/SettingsApp.providerDeeplink.test.ts create mode 100644 test/renderer/pages/NewThreadPage.test.ts create mode 100644 test/renderer/stores/providerDeeplinkImportStore.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 48a625422..77c0a4fe3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,12 @@ import log from 'electron-log' import { eventBus, SendTarget } from './eventbus' import { NOTIFICATION_EVENTS } from './events' import { registerWorkspacePreviewSchemes } from './presenter/workspacePresenter/workspacePreviewProtocol' +import { + findDeepLinkArg, + findStartupDeepLink, + isDeepLinkUrl, + storeStartupDeepLink +} from './lib/startupDeepLink' registerWorkspacePreviewSchemes() @@ -56,69 +62,78 @@ if (process.platform === 'darwin') { app.commandLine.appendSwitch('disable-features', 'DesktopCaptureMacV2,IOSurfaceCapturer') } -// Check for startup deeplink before any other initialization -let startupDeepLink: string | null = null +const gotSingleInstanceLock = app.requestSingleInstanceLock() +if (!gotSingleInstanceLock) { + console.log('Another DeepChat instance is already running. Exiting current process.') + app.quit() +} -console.log('Main process starting, checking for deeplink...') +// Initialize presenter after ready +let presenter: Presenter | undefined -// Check command line arguments for deeplink first +console.log('Main process starting, checking for deeplink...') console.log('Full command line arguments:', process.argv) -const deepLinkArg = process.argv.find((arg) => { - return arg.startsWith('deepchat://') || arg.includes('deepchat://') || arg.match(/^deepchat:/) -}) - -if (deepLinkArg) { - console.log('Found startup deeplink in command line:', deepLinkArg) - startupDeepLink = deepLinkArg +const startupDeepLink = findStartupDeepLink(process.argv, process.env) +if (startupDeepLink) { + console.log('Found startup deeplink during initialization:', startupDeepLink) + storeStartupDeepLink(startupDeepLink) } else { - console.log('No startup deeplink found in command line arguments') + console.log('No startup deeplink detected during initialization') } -// Check for deeplink in environment variables (macOS sometimes passes it this way) -const envDeepLink = process.env.DEEPLINK_URL || process.env.deepchat_deeplink -if (envDeepLink) { - console.log('Found deeplink in environment variables:', envDeepLink) - startupDeepLink = envDeepLink +const focusExistingAppWindow = () => { + const targetWindow = presenter?.windowPresenter.getAllWindows()[0] + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + if (targetWindow.isMinimized()) { + targetWindow.restore() + } + targetWindow.show() + targetWindow.focus() +} + +const routeIncomingDeeplink = (url: string, source: string) => { + if (!isDeepLinkUrl(url)) { + return + } + + console.log(`${source}:`, url) + const normalizedUrl = storeStartupDeepLink(url) + if (!normalizedUrl) { + return + } + + if (presenter && app.isReady()) { + void presenter.deeplinkPresenter.handleDeepLink(normalizedUrl) + } } // Listen for open-url events that might occur during startup // This must be set before app.whenReady() because open-url events can fire before that app.on('open-url', (event, url) => { event.preventDefault() - console.log('Received open-url event during startup:', url) - if (url.startsWith('deepchat://')) { - console.log('Setting startup deeplink from open-url event:', url) - startupDeepLink = url - process.env.STARTUP_DEEPLINK = url - } + routeIncomingDeeplink(url, 'Received open-url event') }) // Also listen for second-instance events (Windows/Linux) -app.on('second-instance', (_event, commandLine) => { - console.log('Received second-instance event with command line:', commandLine) - const deepLinkUrl = commandLine.find((arg) => arg.startsWith('deepchat://')) - if (deepLinkUrl) { - console.log('Found deeplink in second-instance command line:', deepLinkUrl) - startupDeepLink = deepLinkUrl - process.env.STARTUP_DEEPLINK = deepLinkUrl - } -}) - -// Store the startup deeplink for later use -if (startupDeepLink) { - console.log('Final startup deeplink detected:', startupDeepLink) - process.env.STARTUP_DEEPLINK = startupDeepLink -} else { - console.log('No startup deeplink detected during initialization') +if (gotSingleInstanceLock) { + app.on('second-instance', (_event, commandLine) => { + console.log('Received second-instance event with command line:', commandLine) + focusExistingAppWindow() + + const deepLinkUrl = findDeepLinkArg(commandLine) + if (deepLinkUrl) { + routeIncomingDeeplink(deepLinkUrl, 'Received second-instance deeplink') + } + }) } // Initialize lifecycle manager and register core hooks const lifecycleManager = new LifecycleManager() registerCoreHooks(lifecycleManager) -// Initialize presenter after ready -let presenter: Presenter - function clearPresenterPermissionCaches(activePresenter?: Presenter): void { if (!activePresenter) return diff --git a/src/main/lib/startupDeepLink.ts b/src/main/lib/startupDeepLink.ts new file mode 100644 index 000000000..333134282 --- /dev/null +++ b/src/main/lib/startupDeepLink.ts @@ -0,0 +1,68 @@ +const STARTUP_DEEPLINK_ENV_KEY = 'STARTUP_DEEPLINK' +const SECONDARY_STARTUP_ENV_KEYS = ['DEEPLINK_URL', 'deepchat_deeplink'] as const + +export const isDeepLinkUrl = (value: string | null | undefined): value is string => { + if (typeof value !== 'string') { + return false + } + + const normalized = value.trim() + return normalized.startsWith('deepchat:') || normalized.includes('deepchat://') +} + +export const normalizeDeepLinkUrl = (value: string): string => value.trim() + +export const findDeepLinkArg = (argv: readonly string[]): string | null => { + const matched = argv.find((arg) => isDeepLinkUrl(arg)) + return matched ? normalizeDeepLinkUrl(matched) : null +} + +export const readStartupDeepLinkFromEnv = (env: NodeJS.ProcessEnv = process.env): string | null => { + const stored = env[STARTUP_DEEPLINK_ENV_KEY] + return isDeepLinkUrl(stored) ? normalizeDeepLinkUrl(stored) : null +} + +export const findStartupDeepLink = ( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): string | null => { + const stored = readStartupDeepLinkFromEnv(env) + if (stored) { + return stored + } + + const deepLinkArg = findDeepLinkArg(argv) + if (deepLinkArg) { + return deepLinkArg + } + + for (const key of SECONDARY_STARTUP_ENV_KEYS) { + const value = env[key] + if (isDeepLinkUrl(value)) { + return normalizeDeepLinkUrl(value) + } + } + + return null +} + +export const storeStartupDeepLink = ( + url: string, + env: NodeJS.ProcessEnv = process.env +): string | null => { + if (!isDeepLinkUrl(url)) { + return null + } + + const normalized = normalizeDeepLinkUrl(url) + env[STARTUP_DEEPLINK_ENV_KEY] = normalized + return normalized +} + +export const consumeStartupDeepLink = (env: NodeJS.ProcessEnv = process.env): string | null => { + const stored = readStartupDeepLinkFromEnv(env) + if (stored) { + delete env[STARTUP_DEEPLINK_ENV_KEY] + } + return stored +} diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 505f83e36..ba8f74c9c 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -10,6 +10,7 @@ import { WINDOW_EVENTS } from '@/events' import { eventBus, SendTarget } from '@/eventbus' +import { consumeStartupDeepLink } from '@/lib/startupDeepLink' import { PROVIDER_INSTALL_VERSION, isProviderInstallCustomType, @@ -48,8 +49,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { private pendingMcpInstallUrl: string | null = null init(): void { - // 检查启动时的命令行参数是否包含deeplink URL(冷启动情况) - const startupDeepLinkUrl = this.checkStartupDeepLink() + const startupDeepLinkUrl = consumeStartupDeepLink() if (startupDeepLinkUrl) { console.log('Found startup deeplink URL:', startupDeepLinkUrl) this.startupUrl = startupDeepLinkUrl @@ -66,25 +66,12 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { app.setAsDefaultProtocolClient('deepchat') } - // 处理 macOS 上协议被调用的情况 - app.on('open-url', (event, url) => { - event.preventDefault() - console.log('open-url event received:', url) - if (!app.isReady()) { - console.log('App not ready yet, saving URL:', url) - this.startupUrl = url - } else { - console.log('App is ready, checking URL:', url) - this.processDeepLink(url) - } - }) - // 监听窗口内容加载完成事件 eventBus.once(WINDOW_EVENTS.FIRST_CONTENT_LOADED, () => { console.log('Window content loaded. Processing DeepLink if exists.') if (this.startupUrl) { console.log('Processing startup URL:', this.startupUrl) - this.processDeepLink(this.startupUrl) + void this.handleDeepLink(this.startupUrl) this.startupUrl = null } }) @@ -94,102 +81,10 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { console.log('MCP initialized. Processing pending MCP install if exists.') if (this.pendingMcpInstallUrl) { console.log('Processing pending MCP install URL:', this.pendingMcpInstallUrl) - this.handleDeepLink(this.pendingMcpInstallUrl) + void this.handleDeepLink(this.pendingMcpInstallUrl) this.pendingMcpInstallUrl = null } }) - - // 处理 Windows 上协议被调用的情况 - const gotTheLock = app.requestSingleInstanceLock() - if (!gotTheLock) { - app.quit() // Exit trigger: Second instance - } else { - app.on('second-instance', (_event, commandLine) => { - // 用户尝试运行第二个实例,我们应该聚焦到我们的窗口 - if (presenter.windowPresenter.mainWindow) { - if (presenter.windowPresenter.mainWindow.isMinimized()) { - presenter.windowPresenter.mainWindow.restore() - } - presenter.windowPresenter.mainWindow.show() - presenter.windowPresenter.mainWindow.focus() - } - if (process.platform === 'win32') { - // 在 Windows 上,命令行参数包含协议 URL - const deepLinkUrl = commandLine.find((arg) => arg.startsWith('deepchat://')) - if (deepLinkUrl) { - if (!app.isReady()) { - console.log('Windows: App not ready yet, saving URL:', deepLinkUrl) - this.startupUrl = deepLinkUrl - } else { - console.log('Windows: App is ready, checking URL:', deepLinkUrl) - this.processDeepLink(deepLinkUrl) - } - } - } - }) - } - } - - // 新增:处理DeepLink的方法,根据URL类型和系统状态决定如何处理 - private processDeepLink(url: string): void { - console.log('processDeepLink called with URL:', url) - try { - const urlObj = new URL(url) - const command = urlObj.hostname - const subCommand = urlObj.pathname.slice(1) - - console.log('Parsed deeplink - command:', command, 'subCommand:', subCommand) - - // 如果是MCP安装命令,需要等待MCP初始化完成 - if (command === 'mcp' && subCommand === 'install') { - console.log('MCP install deeplink detected') - if (!presenter.mcpPresenter.isReady()) { - console.log('MCP not ready yet, saving MCP install URL for later') - this.pendingMcpInstallUrl = url - return - } else { - console.log('MCP is ready, processing MCP install immediately') - } - } - - // 其他类型的DeepLink或MCP已初始化完成,直接处理 - this.handleDeepLink(url) - } catch (error) { - console.error('Error processing DeepLink:', error) - } - } - - /** - * 检查启动时的deeplink URL - * 用于处理冷启动时传递的deeplink - */ - private checkStartupDeepLink(): string | null { - console.log('Checking for startup deeplink...') - - // 首先检查环境变量(在main.ts中设置的) - const envDeepLink = process.env.STARTUP_DEEPLINK - if (envDeepLink) { - console.log('Found deeplink in startup environment variable:', envDeepLink) - // 清理环境变量,避免重复处理 - delete process.env.STARTUP_DEEPLINK - return envDeepLink - } - - // 检查命令行参数 - 尝试多种deeplink格式 - const deepLinkArg = process.argv.find((arg) => { - return arg.startsWith('deepchat://') || arg.includes('deepchat://') || arg.match(/^deepchat:/) - }) - - if (deepLinkArg) { - console.log('Found deeplink in command line arguments:', deepLinkArg) - return deepLinkArg - } - - // 检查所有命令行参数 - console.log('All command line arguments:', process.argv) - - console.log('No startup deeplink found') - return null } async handleDeepLink(url: string): Promise { @@ -205,13 +100,21 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { // 从 hostname 获取命令 const command = urlObj.hostname + const subCommand = urlObj.pathname.slice(1) + + console.log('Parsed deeplink - command:', command, 'subCommand:', subCommand) + + if (command === 'mcp' && subCommand === 'install' && !presenter.mcpPresenter.isReady()) { + console.log('MCP not ready yet, saving MCP install URL for later') + this.pendingMcpInstallUrl = url + return + } // 处理不同的命令 if (command === 'start') { await this.handleStart(urlObj.searchParams) } else if (command === 'mcp') { // 处理 mcp/install 命令 - const subCommand = urlObj.pathname.slice(1) // 移除开头的斜杠 if (subCommand === 'install') { await this.handleMcpInstall(urlObj.searchParams) } else { @@ -277,17 +180,14 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { console.log('systemPrompt:', systemPrompt) console.log('autoSend:', autoSend, '(disabled for security)') - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow) { - focusedWindow.show() - focusedWindow.focus() - } else { - presenter.windowPresenter.show() + const targetWindow = await this.resolveChatWindow() + if (!targetWindow) { + console.error('Failed to resolve chat window for start deeplink') + return } - const windowId = focusedWindow?.id || 1 - await this.ensureChatWindowReady(windowId) - eventBus.sendToRenderer(DEEPLINK_EVENTS.START, SendTarget.DEFAULT_WINDOW, { + await this.ensureChatWindowReady(targetWindow.id) + presenter.windowPresenter.sendToWindow(targetWindow.id, DEEPLINK_EVENTS.START, { msg, modelId, systemPrompt, @@ -296,6 +196,38 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { }) } + private async resolveChatWindow(): Promise { + const appWindows = presenter.windowPresenter.getAllWindows() + const focusedWindow = presenter.windowPresenter.getFocusedWindow() + const focusedChatWindow = + focusedWindow && appWindows.some((window) => window.id === focusedWindow.id) + ? focusedWindow + : null + + let targetWindow: BrowserWindow | null | undefined = focusedChatWindow ?? appWindows[0] + + if (!targetWindow) { + const windowId = await presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' + }) + if (windowId == null) { + return null + } + targetWindow = BrowserWindow.fromId(windowId) ?? null + } + + if (!targetWindow || targetWindow.isDestroyed()) { + return null + } + + if (targetWindow.isMinimized()) { + targetWindow.restore() + } + targetWindow.show() + targetWindow.focus() + return targetWindow + } + /** * Ensure the active chat window is ready to receive the deeplink payload. * @param windowId 窗口ID @@ -342,12 +274,6 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { return } - // 检查应用程序是否已经完全启动(有窗口存在) - const allWindows = presenter.windowPresenter.getAllWindows() - const hasWindows = allWindows.length > 0 - - console.log('Window check - hasWindows:', hasWindows, 'windowCount:', allWindows.length) - // Prepare complete MCP configuration for all servers const completeMcpConfig: { mcpServers: Record } = { mcpServers: {} } @@ -451,31 +377,21 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { completeMcpConfig.mcpServers[serverName] = finalConfig } - if (hasWindows) { - // 应用程序已启动,使用现有逻辑创建 Settings 窗口 - const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() - if (!settingsWindowId) { - console.error('Failed to open Settings window for MCP install deeplink') - return - } + if (Object.keys(completeMcpConfig.mcpServers).length === 0) { + console.error('No valid MCP servers found in deeplink payload') + return + } - // Store the complete MCP configuration in localStorage of the Settings window - const settingsWindow = BrowserWindow.fromId(settingsWindowId) - if (settingsWindow && !settingsWindow.isDestroyed()) { - try { - await settingsWindow.webContents.executeJavaScript(` - localStorage.setItem('pending-mcp-install', '${JSON.stringify(completeMcpConfig).replace(/'/g, "\\'")}'); - `) - console.log('Complete MCP configuration stored in Settings window localStorage') - } catch (error) { - console.error('Failed to store MCP configuration in localStorage:', error) - } - } - } else { - console.log('App not fully started yet, saving MCP config for first app window') - await this.saveMcpConfigToAppWindow(completeMcpConfig) + const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() + if (!settingsWindowId) { + console.error('Failed to open Settings window for MCP install deeplink') + return } + presenter.windowPresenter.sendToWindow(settingsWindowId, DEEPLINK_EVENTS.MCP_INSTALL, { + mcpConfig: JSON.stringify(completeMcpConfig) + }) + console.log('All MCP servers processing completed') } catch (error) { console.error('Error parsing or processing MCP configuration:', error) @@ -496,14 +412,11 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { return } + presenter.windowPresenter.setPendingSettingsProviderInstall(preview) presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, { routeName: 'settings-provider' }) - presenter.windowPresenter.sendToWindow( - settingsWindowId, - SETTINGS_EVENTS.PROVIDER_INSTALL, - preview - ) + presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.PROVIDER_INSTALL) } catch (error) { const message = error instanceof Error ? error.message : 'Invalid provider deeplink.' console.error('Error parsing provider install deeplink:', error) @@ -511,68 +424,6 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } } - /** - * Store MCP config in the first available app window localStorage. - * @param mcpConfig MCP 配置对象 - */ - private async saveMcpConfigToAppWindow(mcpConfig: { - mcpServers: Record - }): Promise { - try { - const appWindow = await this.waitForFirstAppWindow() - if (!appWindow) { - console.error('No app window available to store MCP configuration') - return - } - - if (appWindow.webContents.isLoading()) { - await new Promise((resolve) => { - appWindow.webContents.once('dom-ready', () => resolve()) - }) - } - - await appWindow.webContents.executeJavaScript(` - localStorage.setItem('pending-mcp-install', '${JSON.stringify(mcpConfig).replace(/'/g, "\\'")}'); - `) - console.log('MCP configuration stored in app window localStorage for cold start') - } catch (error) { - console.error('Failed to store MCP configuration in app window localStorage:', error) - } - } - - /** - * Wait for the first app window to become available. - * @returns Promise - */ - private async waitForFirstAppWindow(): Promise { - return new Promise((resolve) => { - // 先检查是否已经有窗口 - const existingWindows = presenter.windowPresenter.getAllWindows() - if (existingWindows.length > 0) { - resolve(existingWindows[0]) - return - } - - // 监听窗口创建事件 - const checkForWindow = () => { - const windows = presenter.windowPresenter.getAllWindows() - if (windows.length > 0) { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - resolve(windows[0]) - } - } - - eventBus.on(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - - // 设置超时,避免无限等待 - setTimeout(() => { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - console.warn('Timeout waiting for app window creation') - resolve(null) - }, 10000) // 10秒超时 - }) - } - private parseProviderInstallParams(params: URLSearchParams): ProviderInstallPreview { const version = params.get('v') if (version !== PROVIDER_INSTALL_VERSION) { diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 94532ad1d..a907f3efd 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -15,6 +15,7 @@ import { IConfigPresenter, IWindowPresenter } from '@shared/presenter' // Window import { eventBus } from '@/eventbus' // Event bus import { CONFIG_EVENTS, + DEEPLINK_EVENTS, SETTINGS_EVENTS, SHORTCUT_EVENTS, SYSTEM_EVENTS, @@ -25,6 +26,7 @@ import windowStateManager from 'electron-window-state' // Window state manager // TrayPresenter is globally managed in main/index.ts, this Presenter is not responsible for its lifecycle import { TabPresenter } from '../tabPresenter' // TabPresenter type import { FloatingChatWindow } from './FloatingChatWindow' // Floating chat window +import type { ProviderInstallPreview } from '@shared/providerDeeplink' type PendingSettingsMessage = { channel: string @@ -49,6 +51,7 @@ export class WindowPresenter implements IWindowPresenter { private settingsWindow: BrowserWindow | null = null private settingsWindowReady = false private pendingSettingsMessages: PendingSettingsMessage[] = [] + private pendingSettingsProviderInstall: ProviderInstallPreview | null = null constructor(configPresenter: IConfigPresenter) { this.windows = new Map() @@ -1328,6 +1331,20 @@ export class WindowPresenter implements IWindowPresenter { return null } + public setPendingSettingsProviderInstall(preview: ProviderInstallPreview): void { + this.pendingSettingsProviderInstall = { ...preview } + } + + public consumePendingSettingsProviderInstall(): ProviderInstallPreview | null { + if (!this.pendingSettingsProviderInstall) { + return null + } + + const preview = { ...this.pendingSettingsProviderInstall } + this.pendingSettingsProviderInstall = null + return preview + } + /** * Close Settings Window if it exists */ @@ -1348,7 +1365,10 @@ export class WindowPresenter implements IWindowPresenter { } private shouldQueueSettingsMessage(channel: string): boolean { - return channel.startsWith('settings:') && !this.settingsWindowReady + return ( + !this.settingsWindowReady && + (channel.startsWith('settings:') || channel === DEEPLINK_EVENTS.MCP_INSTALL) + ) } private handleSettingsWindowReady(senderWebContentsId: number): void { diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 125830f1a..f2f558dee 100644 --- a/src/renderer/settings/App.vue +++ b/src/renderer/settings/App.vue @@ -45,6 +45,15 @@ } " /> +
@@ -52,7 +61,7 @@ diff --git a/src/renderer/settings/components/ModelProviderSettings.vue b/src/renderer/settings/components/ModelProviderSettings.vue index 36f5b8730..166eae061 100644 --- a/src/renderer/settings/components/ModelProviderSettings.vue +++ b/src/renderer/settings/components/ModelProviderSettings.vue @@ -186,14 +186,6 @@ v-model:open="isAddProviderDialogOpen" @provider-added="handleProviderAdded" /> -
@@ -209,7 +201,6 @@ import BedrockProviderSettingsDetail from './BedrockProviderSettingsDetail.vue' import ModelIcon from '@/components/icons/ModelIcon.vue' import { Icon } from '@iconify/vue' import AddCustomProviderDialog from './AddCustomProviderDialog.vue' -import ProviderDeeplinkImportDialog from './ProviderDeeplinkImportDialog.vue' import { useI18n } from 'vue-i18n' import type { AWS_BEDROCK_PROVIDER, LLM_PROVIDER } from '@shared/presenter' import { Switch } from '@shadcn/components/ui/switch' @@ -220,8 +211,6 @@ import { ScrollArea } from '@shadcn/components/ui/scroll-area' import { useThemeStore } from '@/stores/theme' import { useLanguageStore } from '@/stores/language' import { onMounted } from 'vue' -import { nanoid } from 'nanoid' -import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' const route = useRoute() const router = useRouter() @@ -230,12 +219,10 @@ const languageStore = useLanguageStore() const providerStore = useProviderStore() const modelStore = useModelStore() const themeStore = useThemeStore() -const providerDeeplinkImportStore = useProviderDeeplinkImportStore() const isAddProviderDialogOpen = ref(false) const searchQueryBase = ref('') const searchQuery = refDebounced(searchQueryBase, 150) const showClearButton = computed(() => searchQueryBase.value.trim().length > 0) -const isImportingProvider = ref(false) const editingProviderId = ref(null) const editingName = ref('') @@ -391,77 +378,6 @@ const handleProviderAdded = (provider: LLM_PROVIDER) => { setActiveProvider(provider.id) } -const pendingImportPreview = computed(() => providerDeeplinkImportStore.preview) - -const providerImportConfirmDisabled = computed(() => { - const preview = pendingImportPreview.value - if (!preview) { - return true - } - - if (preview.kind === 'builtin') { - return !providerStore.providers.some((provider) => provider.id === preview.id) - } - - return false -}) - -const handleProviderImportDialogOpenChange = (open: boolean) => { - if (!open) { - providerDeeplinkImportStore.clearPreview() - } -} - -const confirmProviderImport = async () => { - const preview = pendingImportPreview.value - if (!preview || isImportingProvider.value) { - return - } - - isImportingProvider.value = true - try { - if (preview.kind === 'builtin') { - const targetProvider = providerStore.providers.find((provider) => provider.id === preview.id) - if (!targetProvider) { - return - } - - await providerStore.updateProviderApi(preview.id, preview.apiKey, preview.baseUrl) - if (!targetProvider.enable) { - await providerStore.updateProviderStatus(preview.id, true) - } - - await modelStore.refreshProviderModels(preview.id) - setActiveProvider(preview.id) - await nextTick() - scrollToProvider(preview.id) - } else { - const providerId = nanoid() - const newProvider: LLM_PROVIDER = { - id: providerId, - name: preview.name, - apiType: preview.type, - apiKey: preview.apiKey, - baseUrl: preview.baseUrl, - enable: true, - custom: true - } - - await providerStore.addCustomProvider(newProvider) - await modelStore.refreshProviderModels(providerId) - setActiveProvider(providerId) - await nextTick() - scrollToProvider(providerId) - } - - providerDeeplinkImportStore.clearPreview() - } catch (error) { - console.error('Failed to import provider from deeplink:', error) - } finally { - isImportingProvider.value = false - } -} - onMounted(async () => { if (!providerStore.providers.length) { await providerStore.refreshProviders() diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 9b9ff643a..48d3176bf 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -5,8 +5,10 @@ import { usePresenter } from './composables/usePresenter' import SelectedTextContextMenu from './components/message/SelectedTextContextMenu.vue' import { useArtifactStore } from './stores/artifact' import { useSessionStore } from '@/stores/ui/session' +import { useAgentStore } from '@/stores/ui/agent' +import { useDraftStore, type StartDeeplinkPayload } from '@/stores/ui/draft' import { usePageRouterStore } from '@/stores/ui/pageRouter' -import { NOTIFICATION_EVENTS, SHORTCUT_EVENTS } from './events' +import { DEEPLINK_EVENTS, NOTIFICATION_EVENTS, SHORTCUT_EVENTS } from './events' import { Toaster } from '@shadcn/components/ui/sonner' import { useToast } from '@/components/use-toast' import { useUiSettingsStore } from '@/stores/uiSettingsStore' @@ -31,6 +33,8 @@ const route = useRoute() const configPresenter = usePresenter('configPresenter') const artifactStore = useArtifactStore() const sessionStore = useSessionStore() +const agentStore = useAgentStore() +const draftStore = useDraftStore() const pageRouterStore = usePageRouterStore() const { toast } = useToast() const uiSettingsStore = useUiSettingsStore() @@ -146,6 +150,8 @@ const handleErrorClosed = () => { const router = useRouter() const activeTab = ref('chat') const isStartupRouteReady = ref(false) +const processingStartDeeplinkToken = ref(null) +const processedStartDeeplinkToken = ref(null) const isDevWelcomeOverrideEnabled = () => { if (!import.meta.env.DEV) return false @@ -220,6 +226,65 @@ const handleCreateNewConversation = async () => { // Removed GO_SETTINGS handler; now handled in main via tab logic +const activatePendingStartDeeplink = async () => { + const pendingStartDeeplink = draftStore.pendingStartDeeplink + if (!pendingStartDeeplink || !isStartupRouteReady.value) { + return + } + + const token = pendingStartDeeplink.token + if (processingStartDeeplinkToken.value === token || processedStartDeeplinkToken.value === token) { + return + } + + processingStartDeeplinkToken.value = token + + try { + const initComplete = Boolean(await configPresenter.getSetting('init_complete')) + if (!initComplete) { + return + } + + await router.isReady() + if (router.currentRoute.value.name !== 'chat') { + await router.push({ name: 'chat' }) + } + + agentStore.setSelectedAgent('deepchat') + if (sessionStore.hasActiveSession) { + await sessionStore.closeSession() + processedStartDeeplinkToken.value = token + return + } + + pageRouterStore.goToNewThread() + processedStartDeeplinkToken.value = token + } finally { + if (processingStartDeeplinkToken.value === token) { + processingStartDeeplinkToken.value = null + } + } +} + +const handleStartDeeplink = (_event: unknown, payload?: Omit) => { + if (!payload?.msg) { + return + } + + draftStore.setPendingStartDeeplink({ + msg: payload.msg, + modelId: payload.modelId ?? null, + systemPrompt: payload.systemPrompt ?? '', + mentions: Array.isArray(payload.mentions) ? payload.mentions : [], + autoSend: Boolean(payload.autoSend) + }) + void activatePendingStartDeeplink() +} + +if (window?.electron?.ipcRenderer) { + window.electron.ipcRenderer.on(DEEPLINK_EVENTS.START, handleStartDeeplink) +} + // Handle ESC key - close floating chat window const handleEscKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -229,6 +294,15 @@ const handleEscKey = (event: KeyboardEvent) => { void ensureStartupWelcomeState() +watch( + () => + [isStartupRouteReady.value, route.name, draftStore.pendingStartDeeplink?.token ?? 0] as const, + () => { + void activatePendingStartDeeplink() + }, + { immediate: true } +) + onMounted(() => { // Set initial body class document.body.classList.add(themeStore.themeMode) @@ -349,6 +423,7 @@ onBeforeUnmount(() => { // GO_SETTINGS listener removed; handled in main window.electron.ipcRenderer.removeAllListeners(NOTIFICATION_EVENTS.SYS_NOTIFY_CLICKED) window.electron.ipcRenderer.removeAllListeners(NOTIFICATION_EVENTS.DATA_RESET_COMPLETE_DEV) + window.electron.ipcRenderer.removeAllListeners(DEEPLINK_EVENTS.START) cleanupMcpDeeplink() }) diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 06339b818..ce1d5a505 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -82,7 +82,7 @@