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/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..20f170793 --- /dev/null +++ b/src/main/lib/startupDeepLink.ts @@ -0,0 +1,71 @@ +const STARTUP_DEEPLINK_ENV_KEY = 'STARTUP_DEEPLINK' +const SECONDARY_STARTUP_ENV_KEYS = ['DEEPLINK_URL', 'deepchat_deeplink'] as const +let pendingStartupDeepLink: string | null = null + +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.startsWith('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 => { + if (pendingStartupDeepLink) { + return pendingStartupDeepLink + } + + 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) + pendingStartupDeepLink = normalized + return normalized +} + +export const consumeStartupDeepLink = (_env: NodeJS.ProcessEnv = process.env): string | null => { + const stored = pendingStartupDeepLink + pendingStartupDeepLink = null + return stored +} diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 40fe04541..abc34f1f9 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -2,8 +2,22 @@ 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 { consumeStartupDeepLink } from '@/lib/startupDeepLink' +import { + PROVIDER_INSTALL_VERSION, + isProviderInstallCustomType, + maskApiKey, + type ProviderInstallDeeplinkPayload, + type ProviderInstallPreview +} from '@shared/providerDeeplink' interface MCPInstallConfig { mcpServers: Record< @@ -35,10 +49,9 @@ 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) + console.log('Found startup deeplink URL:', this.redactDeepLinkUrlForLog(startupDeepLinkUrl)) this.startupUrl = startupDeepLinkUrl } @@ -53,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) + console.log('Processing startup URL:', this.redactDeepLinkUrlForLog(this.startupUrl)) + void this.handleDeepLink(this.startupUrl) this.startupUrl = null } }) @@ -80,130 +80,55 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { eventBus.on(MCP_EVENTS.INITIALIZED, () => { 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) + console.log( + 'Processing pending MCP install URL:', + this.redactDeepLinkUrlForLog(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 { - console.log('Received DeepLink:', url) - try { const urlObj = new URL(url) + console.log('Received DeepLink:', this.redactDeepLinkUrlForLog(url)) if (urlObj.protocol !== 'deepchat:') { console.error('Unsupported protocol:', urlObj.protocol) return } - // 从 hostname 获取命令 - const command = urlObj.hostname + const rawPath = [urlObj.hostname, urlObj.pathname.replace(/^\/+/, '')] + .filter((segment) => segment.length > 0) + .join('/') + const [command = '', subCommand = ''] = rawPath.split('/') + + 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 { console.warn('Unknown MCP subcommand:', subCommand) } + } else if (command === 'provider') { + if (subCommand === 'install') { + await this.handleProviderInstall(urlObj.searchParams) + } else { + console.warn('Unknown provider subcommand:', subCommand) + } } else { console.warn('Unknown DeepLink command:', command) } @@ -213,7 +138,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } async handleStart(params: URLSearchParams): Promise { - console.log('Processing start command, parameters:', Object.fromEntries(params.entries())) + console.log('Processing start command, parameters:', this.redactSearchParamsForLog(params)) let msg = params.get('msg') if (!msg) { @@ -257,17 +182,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, @@ -276,6 +198,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 @@ -298,7 +252,10 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } async handleMcpInstall(params: URLSearchParams): Promise { - console.log('Processing mcp/install command, parameters:', Object.fromEntries(params.entries())) + console.log( + 'Processing mcp/install command, parameters:', + this.redactSearchParamsForLog(params) + ) // 获取 JSON 数据 const jsonBase64 = params.get('code') @@ -314,7 +271,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { const jsonString = Buffer.from(jsonBase64, 'base64').toString('utf-8') const mcpConfig = JSON.parse(jsonString) as MCPInstallConfig - console.log('Parsed MCP config:', mcpConfig) + console.log('Parsed MCP config:', this.redactValueForLog(mcpConfig)) // 检查 MCP 配置是否有效 if (!mcpConfig || !mcpConfig.mcpServers) { @@ -322,12 +279,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: {} } @@ -426,104 +377,261 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { // 添加服务器配置到完整配置中 console.log( `Preparing to install MCP server: ${serverName} (type: ${determinedType})`, - finalConfig + this.redactValueForLog(finalConfig) ) 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) } } - /** - * Store MCP config in the first available app window localStorage. - * @param mcpConfig MCP 配置对象 - */ - private async saveMcpConfigToAppWindow(mcpConfig: { - mcpServers: Record - }): Promise { + async handleProviderInstall(params: URLSearchParams): Promise { + console.log( + 'Processing provider/install command, parameters:', + this.redactSearchParamsForLog(params) + ) + try { - const appWindow = await this.waitForFirstAppWindow() - if (!appWindow) { - console.error('No app window available to store MCP configuration') + const preview = this.parseProviderInstallParams(params) + const settingsWindowId = await presenter.windowPresenter.createSettingsWindow() + if (!settingsWindowId) { + this.notifyProviderImportError('Failed to open settings window for provider deeplink.') return } - if (appWindow.webContents.isLoading()) { - await new Promise((resolve) => { - appWindow.webContents.once('dom-ready', () => resolve()) - }) + presenter.windowPresenter.setPendingSettingsProviderInstall(preview) + presenter.windowPresenter.sendToWindow(settingsWindowId, SETTINGS_EVENTS.NAVIGATE, { + routeName: 'settings-provider' + }) + 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) + this.notifyProviderImportError(message) + } + } + + 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.') } - 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) + 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 } } - /** - * 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 + 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 normalizedOutput = buffer.toString('base64') + if (sanitizedBase64 !== 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.' + ) + } - // 监听窗口创建事件 - const checkForWindow = () => { - const windows = presenter.windowPresenter.getAllWindows() - if (windows.length > 0) { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - resolve(windows[0]) - } + 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 } + } - eventBus.on(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) + if (typeof payload.name !== 'string') { + throw new Error("Custom provider deeplink payload must include a string 'name'.") + } - // 设置超时,避免无限等待 - setTimeout(() => { - eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - console.warn('Timeout waiting for app window creation') - resolve(null) - }, 10000) // 10秒超时 + 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' }) } + private redactDeepLinkUrlForLog(url: string): string { + try { + const parsedUrl = new URL(url) + const sensitiveKeys = [...parsedUrl.searchParams.keys()].filter((key) => + this.isSensitiveLogKey(key) + ) + + sensitiveKeys.forEach((key) => { + parsedUrl.searchParams.set(key, '[REDACTED]') + }) + + return parsedUrl.toString() + } catch { + return url.replace( + /([?&](?:apiKey|api_key|token|password|data|code)=)[^&]*/gi, + '$1[REDACTED]' + ) + } + } + + private redactSearchParamsForLog(params: URLSearchParams): Record { + return this.redactValueForLog(Object.fromEntries(params.entries())) as Record + } + + private redactValueForLog(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.redactValueForLog(item)) + } + + if (!value || typeof value !== 'object') { + return value + } + + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + this.isSensitiveLogKey(key) ? '[REDACTED]' : this.redactValueForLog(nestedValue) + ]) + ) + } + + private isSensitiveLogKey(key: string): boolean { + const normalizedKey = key.replace(/[^a-z0-9]/gi, '').toLowerCase() + return ( + normalizedKey.includes('apikey') || + normalizedKey.includes('token') || + normalizedKey.includes('password') || + normalizedKey === 'data' || + normalizedKey === 'code' + ) + } + /** * 净化消息内容,防止恶意输入 * @param content 原始消息内容 diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 94532ad1d..58b055a54 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 pendingSettingsProviderInstalls: ProviderInstallPreview[] = [] constructor(configPresenter: IConfigPresenter) { this.windows = new Map() @@ -1328,6 +1331,19 @@ export class WindowPresenter implements IWindowPresenter { return null } + public setPendingSettingsProviderInstall(preview: ProviderInstallPreview): void { + this.pendingSettingsProviderInstalls.push(this.clonePendingSettingsProviderInstall(preview)) + } + + public consumePendingSettingsProviderInstall(): ProviderInstallPreview | null { + const preview = this.pendingSettingsProviderInstalls.shift() + if (!preview) { + return null + } + + return this.clonePendingSettingsProviderInstall(preview) + } + /** * Close Settings Window if it exists */ @@ -1348,7 +1364,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 { @@ -1391,9 +1410,23 @@ export class WindowPresenter implements IWindowPresenter { this.settingsWindowReady = false if (clearQueue) { this.pendingSettingsMessages = [] + this.clearPendingSettingsProviderInstalls() } } + private clonePendingSettingsProviderInstall( + preview: ProviderInstallPreview + ): ProviderInstallPreview { + return { ...preview } + } + + private clearPendingSettingsProviderInstalls(): void { + this.pendingSettingsProviderInstalls.forEach((preview) => { + preview.apiKey = '' + }) + this.pendingSettingsProviderInstalls = [] + } + public isApplicationQuitting(): boolean { return this.isQuitting } diff --git a/src/renderer/settings/App.vue b/src/renderer/settings/App.vue index 4ccc31704..ab4f08679 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/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/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/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/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 @@ + + diff --git a/test/renderer/components/App.startup.test.ts b/test/renderer/components/App.startup.test.ts index 3d5bf1ab9..d4f5813e1 100644 --- a/test/renderer/components/App.startup.test.ts +++ b/test/renderer/components/App.startup.test.ts @@ -1,14 +1,20 @@ import { mount, flushPromises } from '@vue/test-utils' import { reactive, ref } from 'vue' import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEEPLINK_EVENTS } from '@/events' const DEV_WELCOME_OVERRIDE_KEY = '__deepchat_dev_force_welcome' -const mountApp = async (options?: { initComplete?: boolean; routeName?: 'chat' | 'welcome' }) => { +const mountApp = async (options?: { + initComplete?: boolean + routeName?: 'chat' | 'welcome' + hasActiveSession?: boolean +}) => { vi.resetModules() const initComplete = options?.initComplete ?? false const routeName = options?.routeName ?? 'chat' + const hasActiveSession = options?.hasActiveSession ?? false const route = reactive({ name: routeName, path: routeName === 'welcome' ? '/welcome' : '/chat', @@ -42,12 +48,32 @@ const mountApp = async (options?: { initComplete?: boolean; routeName?: 'chat' | const pageRouterStore = { goToNewThread: vi.fn() } + const agentStore = { + setSelectedAgent: vi.fn() + } + const draftStore = reactive({ + pendingStartDeeplink: null as null | Record, + setPendingStartDeeplink: vi.fn((payload: Record) => { + draftStore.pendingStartDeeplink = { + ...payload, + token: 1 + } + }) + }) + const sessionStore = { + hasActiveSession, + activeSessionId: hasActiveSession ? 'session-1' : null, + closeSession: vi.fn().mockResolvedValue(undefined), + selectSession: vi.fn() + } const toast = vi.fn(() => ({ dismiss: vi.fn() })) + const ipcOn = vi.fn() + const ipcRemoveAllListeners = vi.fn() ;(window as any).electron = { ipcRenderer: { - on: vi.fn(), - removeAllListeners: vi.fn(), + on: ipcOn, + removeAllListeners: ipcRemoveAllListeners, send: vi.fn() } } @@ -79,12 +105,13 @@ const mountApp = async (options?: { initComplete?: boolean; routeName?: 'chat' | }) })) vi.doMock('@/stores/ui/session', () => ({ - useSessionStore: () => ({ - hasActiveSession: false, - activeSessionId: null, - closeSession: vi.fn(), - selectSession: vi.fn() - }) + useSessionStore: () => sessionStore + })) + vi.doMock('@/stores/ui/agent', () => ({ + useAgentStore: () => agentStore + })) + vi.doMock('@/stores/ui/draft', () => ({ + useDraftStore: () => draftStore })) vi.doMock('@/stores/ui/pageRouter', () => ({ usePageRouterStore: () => pageRouterStore @@ -164,7 +191,12 @@ const mountApp = async (options?: { initComplete?: boolean; routeName?: 'chat' | return { route, router, - configPresenter + configPresenter, + pageRouterStore, + agentStore, + draftStore, + sessionStore, + ipcOn } } @@ -205,4 +237,41 @@ describe('App startup welcome flow', () => { expect(router.replace).toHaveBeenCalledWith({ name: 'welcome' }) expect(route.name).toBe('welcome') }) + + it('stores start deeplink payload and routes to a new deepchat thread', async () => { + const { draftStore, pageRouterStore, agentStore, sessionStore, ipcOn } = await mountApp({ + initComplete: true, + routeName: 'chat', + hasActiveSession: true + }) + + const startHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === DEEPLINK_EVENTS.START + )?.[1] + + expect(startHandler).toBeTypeOf('function') + + await startHandler?.( + {}, + { + msg: '你好,DeepChat', + modelId: 'deepseek-chat', + systemPrompt: 'Be concise', + mentions: ['README.md'], + autoSend: false + } + ) + await flushPromises() + + expect(draftStore.setPendingStartDeeplink).toHaveBeenCalledWith({ + msg: '你好,DeepChat', + modelId: 'deepseek-chat', + systemPrompt: 'Be concise', + mentions: ['README.md'], + autoSend: false + }) + expect(agentStore.setSelectedAgent).toHaveBeenCalledWith('deepchat') + expect(sessionStore.closeSession).toHaveBeenCalledTimes(1) + expect(pageRouterStore.goToNewThread).not.toHaveBeenCalled() + }) }) diff --git a/test/renderer/components/ModelProviderSettings.test.ts b/test/renderer/components/ModelProviderSettings.test.ts index ae2662335..722be8a83 100644 --- a/test/renderer/components/ModelProviderSettings.test.ts +++ b/test/renderer/components/ModelProviderSettings.test.ts @@ -8,40 +8,49 @@ const passthrough = (name: string) => template: '
' }) +const draggableStub = defineComponent({ + name: 'draggable', + props: { + modelValue: { + type: Array, + default: () => [] + }, + itemKey: { + type: String, + default: 'id' + } + }, + template: + '
' +}) + const setup = async () => { vi.resetModules() + const provider = { + id: 'anthropic', + name: 'Anthropic', + apiType: 'anthropic', + apiKey: 'test-key', + baseUrl: 'https://api.anthropic.com', + enable: 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 router = { @@ -87,7 +96,7 @@ const setup = async () => { Switch: passthrough('Switch'), Icon: true, ModelIcon: true, - draggable: passthrough('draggable'), + draggable: draggableStub, AddCustomProviderDialog: true, OllamaProviderSettingsDetail: defineComponent({ name: 'OllamaProviderSettingsDetail', @@ -125,4 +134,17 @@ describe('ModelProviderSettings', () => { expect(wrapper.find('[data-testid="generic-detail"]').exists()).toBe(true) expect(wrapper.find('[data-testid="anthropic-detail"]').exists()).toBe(false) }) + + it('navigates to the selected provider when a provider row is clicked', async () => { + const { wrapper, router } = await setup() + + await wrapper.get('[data-provider-id="anthropic"]').trigger('click') + + expect(router.push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'anthropic' + } + }) + }) }) diff --git a/test/renderer/components/SettingsApp.providerDeeplink.test.ts b/test/renderer/components/SettingsApp.providerDeeplink.test.ts new file mode 100644 index 000000000..3eb94033c --- /dev/null +++ b/test/renderer/components/SettingsApp.providerDeeplink.test.ts @@ -0,0 +1,708 @@ +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, reactive, ref } from 'vue' +import { SETTINGS_EVENTS } from '@/events' + +const createProviderDeeplinkImportStore = () => { + const store = reactive({ + preview: null as Record | null, + previewToken: 0, + openPreview: vi.fn((payload: Record) => { + store.previewToken += 1 + store.preview = { ...payload } + }), + clearPreview: vi.fn(() => { + store.preview = null + }) + }) + + return store +} + +const mountSettingsApp = async (options?: { + routeName?: 'settings-common' | 'settings-provider' + providerId?: string + failImport?: boolean + failPreviewApply?: boolean + failConsumeOnce?: boolean + failRequeue?: boolean + failProviderNavigationOnce?: boolean +}) => { + vi.resetModules() + + const route = reactive({ + name: options?.routeName ?? 'settings-common', + query: {}, + params: options?.providerId ? { providerId: options.providerId } : {}, + path: options?.routeName === 'settings-provider' ? '/provider' : '/common' + }) + const currentRoute = ref(route) + let shouldFailProviderNavigationOnce = options?.failProviderNavigationOnce ?? false + const push = vi.fn().mockImplementation(async (target: { name?: string; params?: any }) => { + if (!target?.name) { + return + } + + if (shouldFailProviderNavigationOnce && target.name === 'settings-provider') { + shouldFailProviderNavigationOnce = false + throw new Error('navigate failed') + } + + route.name = target.name + route.params = target.params ?? {} + route.path = target.name === 'settings-provider' ? '/provider' : '/common' + currentRoute.value = route + }) + const router = { + hasRoute: vi.fn((routeName: string) => + ['settings-common', 'settings-provider', 'settings-mcp'].includes(routeName) + ), + isReady: vi.fn().mockResolvedValue(undefined), + 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 + } + + let shouldFailConsumeOnce = options?.failConsumeOnce ?? false + + const providerStore = reactive({ + providers: options?.failPreviewApply + ? [] + : [ + { + id: 'deepseek', + name: 'DeepSeek', + apiType: 'deepseek', + apiKey: 'old-key', + baseUrl: 'https://old.example.com/v1', + enable: false + } + ], + initialize: options?.failPreviewApply + ? vi.fn().mockRejectedValue(new Error('sync failed')) + : vi.fn().mockResolvedValue(undefined), + updateProviderApi: options?.failImport + ? vi.fn().mockRejectedValue(new Error('apply failed')) + : vi.fn().mockResolvedValue(undefined), + updateProviderStatus: vi + .fn() + .mockImplementation(async (providerId: string, enable: boolean) => { + const provider = providerStore.providers.find((item) => item.id === providerId) + if (provider) { + provider.enable = enable + } + }), + addCustomProvider: vi.fn().mockImplementation(async (provider: Record) => { + providerStore.providers.push(provider as any) + }) + }) + + const modelStore = reactive({ + initialize: vi.fn().mockResolvedValue(undefined), + refreshProviderModels: vi.fn().mockResolvedValue(undefined) + }) + const providerDeeplinkImportStore = createProviderDeeplinkImportStore() + const toast = vi.fn(() => ({ dismiss: vi.fn() })) + const ipcOn = vi.fn() + const ipcRemoveListener = vi.fn() + const ipcRemoveAllListeners = vi.fn() + const ipcSend = vi.fn() + const pendingProviderInstallQueue: Array> = [] + const consumePendingSettingsProviderInstall = vi.fn().mockImplementation(async () => { + if (shouldFailConsumeOnce) { + shouldFailConsumeOnce = false + throw new Error('consume failed') + } + + return pendingProviderInstallQueue.shift() ?? null + }) + const setPendingSettingsProviderInstall = vi + .fn() + .mockImplementation(async (payload: Record) => { + if (options?.failRequeue) { + throw new Error('requeue failed') + } + + pendingProviderInstallQueue.push(payload) + }) + const queuePendingProviderInstall = (payload: Record) => { + pendingProviderInstallQueue.push(payload) + } + + ;(window as any).electron = { + ipcRenderer: { + on: ipcOn, + removeListener: ipcRemoveListener, + removeAllListeners: ipcRemoveAllListeners, + send: ipcSend + } + } + + vi.doMock('vue-router', () => ({ + useRouter: () => router, + useRoute: () => route, + 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(), + consumePendingSettingsProviderInstall, + setPendingSettingsProviderInstall + } + } + 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: () => modelStore + })) + 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.doMock('nanoid', () => ({ + nanoid: () => 'custom-provider-id' + })) + + const SettingsApp = (await import('../../../src/renderer/settings/App.vue')).default + const wrapper = 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: '
' + }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: ['open', 'preview', 'confirmDisabled', 'submitting'], + emits: ['confirm', 'update:open'], + template: + '
{{ preview?.kind }}
' + }), + Toaster: true, + Icon: true + } + } + }) + + await flushPromises() + + const installHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === SETTINGS_EVENTS.PROVIDER_INSTALL + )?.[1] + + return { + wrapper, + route, + push, + toast, + providerStore, + modelStore, + providerDeeplinkImportStore, + installHandler, + queuePendingProviderInstall, + consumePendingSettingsProviderInstall, + setPendingSettingsProviderInstall, + pendingProviderInstallQueue + } +} + +describe('SettingsApp provider deeplink', () => { + it('confirms built-in provider imports from the settings root', async () => { + const { + wrapper, + push, + providerStore, + modelStore, + providerDeeplinkImportStore, + installHandler, + queuePendingProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-common' + }) + + const payload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + + queuePendingProviderInstall(payload) + await installHandler?.({}) + await flushPromises() + + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('builtin') + + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerStore.updateProviderApi).toHaveBeenCalledWith( + 'deepseek', + 'sk-deepseek-demo-key', + 'https://deepseek.example.com/v1' + ) + expect(providerStore.updateProviderStatus).toHaveBeenCalledWith('deepseek', true) + expect(modelStore.refreshProviderModels).toHaveBeenCalledWith('deepseek') + expect(push).toHaveBeenLastCalledWith({ + name: 'settings-provider', + params: { + providerId: 'deepseek' + } + }) + expect(providerDeeplinkImportStore.clearPreview).toHaveBeenCalledTimes(1) + expect(providerDeeplinkImportStore.preview).toBeNull() + }) + + it('confirms custom provider imports even when settings is already on provider route', async () => { + const { + wrapper, + push, + providerStore, + modelStore, + providerDeeplinkImportStore, + installHandler, + queuePendingProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek' + }) + + const payload = { + kind: 'custom' as const, + name: 'minimax Proxy', + type: 'minimax', + baseUrl: 'https://minimax.example.com/v1', + apiKey: 'sk-minimax-custom', + maskedApiKey: 'sk-m...stom', + iconModelId: 'minimax' + } + + queuePendingProviderInstall(payload) + await installHandler?.({}) + await flushPromises() + + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('custom') + + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerStore.addCustomProvider).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'custom-provider-id', + name: 'minimax Proxy', + apiType: 'minimax', + apiKey: 'sk-minimax-custom', + baseUrl: 'https://minimax.example.com/v1', + enable: true, + custom: true + }) + ) + expect(modelStore.refreshProviderModels).toHaveBeenCalledWith('custom-provider-id') + expect(push).toHaveBeenLastCalledWith({ + name: 'settings-provider', + params: { + providerId: 'custom-provider-id' + } + }) + expect(providerDeeplinkImportStore.clearPreview).toHaveBeenCalledTimes(1) + expect(providerDeeplinkImportStore.preview).toBeNull() + }) + + it('keeps the provider import preview open when import fails', async () => { + const { + wrapper, + toast, + providerDeeplinkImportStore, + installHandler, + queuePendingProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-common', + failImport: true + }) + + const payload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + + queuePendingProviderInstall(payload) + await installHandler?.({}) + await flushPromises() + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerDeeplinkImportStore.preview).toEqual(payload) + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'common.error', + description: 'apply failed', + variant: 'destructive' + }) + ) + }) + + it('replays pending provider imports when the settings window regains focus', async () => { + const { wrapper, queuePendingProviderInstall, consumePendingSettingsProviderInstall } = + await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek' + }) + + const payload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + + queuePendingProviderInstall(payload) + window.dispatchEvent(new Event('focus')) + await flushPromises() + + expect(consumePendingSettingsProviderInstall).toHaveBeenCalled() + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('builtin') + }) + + it('drains queued provider imports only after the active dialog is dismissed', async () => { + const { + wrapper, + installHandler, + queuePendingProviderInstall, + consumePendingSettingsProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek' + }) + + const firstPayload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + const secondPayload = { + kind: 'custom' as const, + name: 'minimax Proxy', + type: 'minimax', + baseUrl: 'https://minimax.example.com/v1', + apiKey: 'sk-minimax-custom', + maskedApiKey: 'sk-m...stom', + iconModelId: 'minimax' + } + const initialConsumeCount = consumePendingSettingsProviderInstall.mock.calls.length + + queuePendingProviderInstall(firstPayload) + queuePendingProviderInstall(secondPayload) + await installHandler?.({}) + await flushPromises() + + expect(consumePendingSettingsProviderInstall).toHaveBeenCalledTimes(initialConsumeCount + 1) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('builtin') + + window.dispatchEvent(new Event('focus')) + await flushPromises() + + expect(consumePendingSettingsProviderInstall).toHaveBeenCalledTimes(initialConsumeCount + 1) + + await wrapper.get('[data-testid="cancel-import"]').trigger('click') + await flushPromises() + + expect(consumePendingSettingsProviderInstall).toHaveBeenCalledTimes(initialConsumeCount + 2) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('custom') + }) + + it('drains queued provider imports after a successful confirm', async () => { + const { + wrapper, + installHandler, + queuePendingProviderInstall, + consumePendingSettingsProviderInstall, + providerStore, + modelStore + } = await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek' + }) + + const firstPayload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + const secondPayload = { + kind: 'custom' as const, + name: 'minimax Proxy', + type: 'minimax', + baseUrl: 'https://minimax.example.com/v1', + apiKey: 'sk-minimax-custom', + maskedApiKey: 'sk-m...stom', + iconModelId: 'minimax' + } + const initialConsumeCount = consumePendingSettingsProviderInstall.mock.calls.length + + queuePendingProviderInstall(firstPayload) + queuePendingProviderInstall(secondPayload) + await installHandler?.({}) + await flushPromises() + + await wrapper.get('[data-testid="confirm-import"]').trigger('click') + await flushPromises() + + expect(providerStore.updateProviderApi).toHaveBeenCalledWith( + 'deepseek', + 'sk-deepseek-demo-key', + 'https://deepseek.example.com/v1' + ) + expect(modelStore.refreshProviderModels).toHaveBeenCalledWith('deepseek') + expect(consumePendingSettingsProviderInstall).toHaveBeenCalledTimes(initialConsumeCount + 2) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('custom') + }) + + it('requeues pending provider installs when syncing the preview fails', async () => { + const { + wrapper, + installHandler, + queuePendingProviderInstall, + setPendingSettingsProviderInstall, + pendingProviderInstallQueue + } = await mountSettingsApp({ + routeName: 'settings-common', + failPreviewApply: true + }) + + const payload = { + kind: 'custom' as const, + name: 'DeepSeek Proxy', + type: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-custom', + maskedApiKey: 'sk-d...stom', + iconModelId: 'deepseek' + } + + queuePendingProviderInstall(payload) + await installHandler?.({}) + await flushPromises() + + expect(setPendingSettingsProviderInstall).toHaveBeenCalledWith(payload) + expect(pendingProviderInstallQueue).toEqual([payload]) + expect(wrapper.find('[data-testid="provider-import-dialog"]').exists()).toBe(false) + }) + + it('resets preview processing when consuming pending installs throws', async () => { + const { + wrapper, + installHandler, + queuePendingProviderInstall, + consumePendingSettingsProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek', + failConsumeOnce: true + }) + + const payload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + const initialConsumeCount = consumePendingSettingsProviderInstall.mock.calls.length + + await installHandler?.({}) + await flushPromises() + + expect(wrapper.find('[data-testid="provider-import-dialog"]').exists()).toBe(false) + expect(consumePendingSettingsProviderInstall).toHaveBeenCalledTimes(initialConsumeCount + 1) + + queuePendingProviderInstall(payload) + await installHandler?.({}) + await flushPromises() + + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('builtin') + }) + + it('resets preview processing when requeueing a failed preview throws', async () => { + const { + wrapper, + installHandler, + queuePendingProviderInstall, + setPendingSettingsProviderInstall + } = await mountSettingsApp({ + routeName: 'settings-provider', + providerId: 'deepseek', + failProviderNavigationOnce: true, + failRequeue: true + }) + + const firstPayload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + const secondPayload = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + + queuePendingProviderInstall(firstPayload) + await installHandler?.({}) + await flushPromises() + + expect(setPendingSettingsProviderInstall).toHaveBeenCalledWith(firstPayload) + expect(wrapper.find('[data-testid="provider-import-dialog"]').exists()).toBe(false) + + queuePendingProviderInstall(secondPayload) + await installHandler?.({}) + await flushPromises() + + expect(wrapper.get('[data-testid="provider-import-dialog"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="provider-import-kind"]').text()).toBe('builtin') + }) +}) diff --git a/test/renderer/components/SettingsApp.test.ts b/test/renderer/components/SettingsApp.test.ts index 27d86a18b..454268bc6 100644 --- a/test/renderer/components/SettingsApp.test.ts +++ b/test/renderer/components/SettingsApp.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' +import { flushPromises, mount } from '@vue/test-utils' import { defineComponent, ref } from 'vue' -import { SETTINGS_EVENTS } from '@/events' +import { DEEPLINK_EVENTS, SETTINGS_EVENTS } from '@/events' afterEach(() => { vi.restoreAllMocks() @@ -63,7 +63,8 @@ describe('Settings App', () => { } if (name === 'windowPresenter') { return { - closeSettingsWindow: vi.fn() + closeSettingsWindow: vi.fn(), + consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null) } } if (name === 'configPresenter') { @@ -101,9 +102,18 @@ describe('Settings App', () => { })) vi.doMock('../../../src/renderer/src/stores/providerStore', () => ({ useProviderStore: () => ({ + providers: [], initialize: vi.fn().mockResolvedValue(undefined) }) })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + }) + })) vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ useModelStore: () => ({ initialize: vi.fn().mockResolvedValue(undefined) @@ -174,14 +184,21 @@ describe('Settings App', () => { }, template: '
' }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: { + open: { type: Boolean, default: false }, + preview: { type: null, default: null } + }, + template: '
' + }), Toaster: true, Icon: true } } }) - await Promise.resolve() - await Promise.resolve() + await flushPromises() expect(isReady).toHaveBeenCalledTimes(1) expect(ipcSend).toHaveBeenCalledWith(SETTINGS_EVENTS.READY) @@ -251,7 +268,8 @@ describe('Settings App', () => { } if (name === 'windowPresenter') { return { - closeSettingsWindow: vi.fn() + closeSettingsWindow: vi.fn(), + consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null) } } if (name === 'configPresenter') { @@ -289,9 +307,18 @@ describe('Settings App', () => { })) vi.doMock('../../../src/renderer/src/stores/providerStore', () => ({ useProviderStore: () => ({ + providers: [], initialize: vi.fn().mockResolvedValue(undefined) }) })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + }) + })) vi.doMock('../../../src/renderer/src/stores/modelStore', () => ({ useModelStore: () => ({ initialize: vi.fn().mockResolvedValue(undefined) @@ -362,6 +389,14 @@ describe('Settings App', () => { }, template: '
' }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: { + open: { type: Boolean, default: false }, + preview: { type: null, default: null } + }, + template: '
' + }), Toaster: true, Icon: true } @@ -381,4 +416,466 @@ 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() + let resolveProviderInitialize: (() => void) | null = null + const providerInitializePromise = new Promise((resolve) => { + resolveProviderInitialize = resolve + }) + const providerStore = { + providers: [], + initialize: vi.fn().mockReturnValue(providerInitializePromise) + } + const providerDeeplinkImportStore = { + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + } + const consumePendingSettingsProviderInstall = vi.fn().mockResolvedValue(null) + + ;(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(), + consumePendingSettingsProviderInstall + } + } + 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: '
' + }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: { + open: { type: Boolean, default: false }, + preview: { type: null, default: null } + }, + template: '
' + }), + Toaster: true, + Icon: true + } + } + }) + + await flushPromises() + + expect(providerStore.initialize).toHaveBeenCalledTimes(1) + + 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') + + consumePendingSettingsProviderInstall.mockResolvedValueOnce(payload) + const installPromise = installHandler?.({}) + + expect(providerStore.initialize).toHaveBeenCalledTimes(1) + + resolveProviderInitialize?.() + await installPromise + await flushPromises() + + expect(push).toHaveBeenCalledWith({ + name: 'settings-provider', + params: { + providerId: 'openai' + } + }) + expect(providerDeeplinkImportStore.openPreview).toHaveBeenCalledWith(payload) + }) + + it('processes MCP deeplinks while the settings window is already open', async () => { + vi.resetModules() + vi.doUnmock('../../../src/renderer/src/lib/storeInitializer') + + 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 mcpStore = { + mcpEnabled: false, + setMcpEnabled: vi.fn().mockResolvedValue(undefined), + setMcpInstallCache: 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-mcp'), + 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: '/mcp', + name: 'settings-mcp', + meta: { + titleKey: 'routes.settings-mcp', + icon: 'lucide:server', + position: 5 + } + } + ]), + 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(), + consumePendingSettingsProviderInstall: vi.fn().mockResolvedValue(null) + } + } + 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: () => ({ + providers: [], + initialize: vi.fn().mockResolvedValue(undefined) + }) + })) + vi.doMock('../../../src/renderer/src/stores/providerDeeplinkImport', () => ({ + useProviderDeeplinkImportStore: () => ({ + preview: null, + previewToken: 0, + openPreview: vi.fn(), + clearPreview: vi.fn() + }) + })) + 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: () => mcpStore + })) + 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: '
' + }), + ProviderDeeplinkImportDialog: defineComponent({ + name: 'ProviderDeeplinkImportDialog', + props: { + open: { type: Boolean, default: false }, + preview: { type: null, default: null } + }, + template: '
' + }), + Toaster: true, + Icon: true + } + } + }) + + await Promise.resolve() + await Promise.resolve() + + const installHandler = ipcOn.mock.calls.find( + ([eventName]: [string]) => eventName === DEEPLINK_EVENTS.MCP_INSTALL + )?.[1] + + expect(installHandler).toBeTypeOf('function') + + const serializedConfig = JSON.stringify({ + mcpServers: { + demo: { + command: 'npx' + } + } + }) + + await installHandler?.({}, { mcpConfig: serializedConfig }) + + expect(mcpStore.setMcpEnabled).toHaveBeenCalledTimes(1) + expect(push).toHaveBeenCalledWith({ name: 'settings-mcp' }) + expect(mcpStore.setMcpInstallCache).toHaveBeenCalledWith(serializedConfig) + }) }) diff --git a/test/renderer/pages/NewThreadPage.test.ts b/test/renderer/pages/NewThreadPage.test.ts new file mode 100644 index 000000000..c92a8f29f --- /dev/null +++ b/test/renderer/pages/NewThreadPage.test.ts @@ -0,0 +1,171 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +const setup = async (pendingModelId: string) => { + vi.resetModules() + + const draftStore = reactive({ + providerId: undefined as string | undefined, + modelId: undefined as string | undefined, + projectDir: '/workspace/demo', + agentId: 'deepchat', + systemPrompt: undefined as string | undefined, + temperature: undefined as number | undefined, + contextLength: undefined as number | undefined, + maxTokens: undefined as number | undefined, + thinkingBudget: undefined as number | undefined, + reasoningEffort: undefined as string | undefined, + verbosity: undefined as string | undefined, + forceInterleavedThinkingCompat: undefined as boolean | undefined, + permissionMode: 'full_access', + disabledAgentTools: [] as string[], + pendingStartDeeplink: { + token: 1, + msg: '帮我总结一下这周的迭代状态', + modelId: pendingModelId, + systemPrompt: 'You are a concise project assistant.', + mentions: ['README.md', 'docs/spec.md'], + autoSend: false + }, + toGenerationSettings: vi.fn(() => undefined), + clearPendingStartDeeplink: vi.fn(() => { + draftStore.pendingStartDeeplink = null + }) + }) + const projectStore = reactive({ + selectedProject: { + name: 'demo', + path: '/workspace/demo' + }, + defaultProjectPath: null as string | null, + projects: [] as Array<{ name: string; path: string }>, + selectProject: vi.fn(), + openFolderPicker: vi.fn() + }) + const sessionStore = { + selectSession: vi.fn(), + sendMessage: vi.fn(), + createSession: vi.fn() + } + const agentStore = reactive({ + selectedAgentId: 'deepchat', + selectedAgent: null, + agents: [{ id: 'deepchat', type: 'deepchat' }] + }) + const modelStore = reactive({ + enabledModels: [ + { + providerId: 'openai', + models: [{ id: 'gpt-4o-mini' }, { id: 'deepseek-chat' }] + }, + { + providerId: 'deepseek', + models: [{ id: 'deepseek-chat' }] + } + ] + }) + const configPresenter = { + getSetting: vi.fn().mockResolvedValue(undefined), + resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({ + defaultModelPreset: { + providerId: 'openai', + modelId: 'gpt-4o-mini' + }, + systemPrompt: 'Default system prompt', + permissionMode: 'full_access', + disabledAgentTools: [] + }) + } + const newAgentPresenter = { + ensureAcpDraftSession: vi.fn() + } + + vi.doMock('@/stores/ui/project', () => ({ + useProjectStore: () => projectStore + })) + vi.doMock('@/stores/ui/session', () => ({ + useSessionStore: () => sessionStore + })) + vi.doMock('@/stores/ui/agent', () => ({ + useAgentStore: () => agentStore + })) + vi.doMock('@/stores/modelStore', () => ({ + useModelStore: () => modelStore + })) + vi.doMock('@/stores/ui/draft', () => ({ + useDraftStore: () => draftStore + })) + vi.doMock('@/composables/usePresenter', () => ({ + usePresenter: (name: string) => { + if (name === 'configPresenter') return configPresenter + if (name === 'newAgentPresenter') return newAgentPresenter + return {} + } + })) + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) + })) + vi.doMock('@iconify/vue', () => ({ + Icon: { + name: 'Icon', + template: '' + } + })) + + const NewThreadPage = (await import('@/pages/NewThreadPage.vue')).default + + const wrapper = mount(NewThreadPage, { + global: { + stubs: { + TooltipProvider: { + template: '
' + }, + DropdownMenu: true, + DropdownMenuTrigger: true, + DropdownMenuContent: true, + DropdownMenuLabel: true, + DropdownMenuItem: true, + DropdownMenuSeparator: true, + Button: true, + ChatInputToolbar: true, + ChatStatusBar: true, + ChatInputBox: { + name: 'ChatInputBox', + props: ['modelValue'], + template: '
{{ modelValue }}
' + } + } + } + }) + + await flushPromises() + + return { + wrapper, + draftStore + } +} + +describe('NewThreadPage start deeplink prefill', () => { + it('applies exact model matches and appends mentions into the input', async () => { + const { wrapper, draftStore } = await setup('deepseek-chat') + + expect(wrapper.get('[data-testid="chat-input"]').text()).toContain('帮我总结一下这周的迭代状态') + expect(wrapper.get('[data-testid="chat-input"]').text()).toContain('@README.md') + expect(wrapper.get('[data-testid="chat-input"]').text()).toContain('@docs/spec.md') + expect(draftStore.systemPrompt).toBe('You are a concise project assistant.') + expect(draftStore.providerId).toBe('openai') + expect(draftStore.modelId).toBe('deepseek-chat') + expect(draftStore.clearPendingStartDeeplink).toHaveBeenCalledTimes(1) + }) + + it('falls back to fuzzy model matching when no exact match exists', async () => { + const { draftStore } = await setup('seek-chat') + + expect(draftStore.providerId).toBe('openai') + expect(draftStore.modelId).toBe('deepseek-chat') + }) +}) diff --git a/test/renderer/stores/providerDeeplinkImportStore.test.ts b/test/renderer/stores/providerDeeplinkImportStore.test.ts new file mode 100644 index 000000000..c3be3e2e4 --- /dev/null +++ b/test/renderer/stores/providerDeeplinkImportStore.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useProviderDeeplinkImportStore } from '@/stores/providerDeeplinkImport' + +describe('providerDeeplinkImportStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('increments preview token for each provider deeplink preview', () => { + const store = useProviderDeeplinkImportStore() + const firstPreview = { + kind: 'builtin' as const, + id: 'deepseek', + baseUrl: 'https://deepseek.example.com/v1', + apiKey: 'sk-deepseek-demo-key', + maskedApiKey: 'sk-d...-key', + iconModelId: 'deepseek', + willOverwrite: true + } + + store.openPreview(firstPreview) + const firstToken = store.previewToken + + store.openPreview(firstPreview) + + expect(firstToken).toBe(1) + expect(store.previewToken).toBe(2) + expect(store.preview).toEqual(firstPreview) + expect(store.preview).not.toBe(firstPreview) + }) +})