From 6dba59e6654b800c1ebbbff77133043c85c994ca Mon Sep 17 00:00:00 2001 From: Xiao <79430613+zhouxiao-text@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:19:42 +0800 Subject: [PATCH 01/11] feat(renderer): replace SessionItem context menu with hover interaction (#1389) * feat(renderer): edit SessionItem style * feat(renderer): replace SessionItem context menu with hover interaction * feat(renderer): replace SessionItem context menu with hover interaction --------- Co-authored-by: xiao-test --- .../components/WindowSideBarSessionItem.vue | 92 +++++++------------ 1 file changed, 34 insertions(+), 58 deletions(-) diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index fe98d12bc..ac777ee08 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -1,64 +1,37 @@ From aa375cd4adac70963e1d07a0db1d1891dc8be7c8 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 25 Mar 2026 15:58:40 +0800 Subject: [PATCH 02/11] feat: add Novita AI as LLM provider (#1388) * feat: add Novita AI as LLM provider Add Novita AI (https://novita.ai) as a new LLM provider option. Novita offers OpenAI-compatible API endpoints with competitive pricing. * fix(provider): wire novita branding --------- Co-authored-by: zerob13 --- resources/model-db/providers.json | 176 +++++++++++++++++- .../presenter/configPresenter/providers.ts | 15 ++ .../src/assets/llm-icons/novitaai.svg | 3 + .../src/components/icons/ModelIcon.vue | 7 +- .../presenter/llmProviderPresenter.test.ts | 25 +++ test/renderer/components/ModelIcon.test.ts | 15 ++ 6 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/assets/llm-icons/novitaai.svg diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 513eb435e..d0af1c912 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -184267,6 +184267,180 @@ "type": "chat" } ] + }, + + "novita": { + "id": "novita", + "name": "Novita AI", + "display_name": "Novita AI", + "api": "https://api.novita.ai/openai", + "doc": "https://novita.ai/docs", + "models": [ + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "display_name": "Kimi K2.5", + "modalities": { + "input": [ + "text", + "image", + "video" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": true, + "open_weights": true, + "release_date": "2026-01-27", + "last_updated": "2026-01-27", + "cost": { + "input": 0.6, + "output": 3, + "cache_read": 0.1 + }, + "type": "chat" + }, + { + "id": "zai-org/glm-5", + "name": "GLM-5", + "display_name": "GLM-5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 202800, + "output": 131072 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-02-11", + "last_updated": "2026-02-11", + "cost": { + "input": 1, + "output": 3.2, + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "minimax/minimax-m2.5", + "name": "MiniMax M2.5", + "display_name": "MiniMax M2.5", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 204800, + "output": 131100 + }, + "temperature": true, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "extra_capabilities": { + "reasoning": { + "supported": true, + "interleaved": true, + "summaries": true, + "visibility": "summary", + "continuation": [ + "thinking_blocks" + ] + } + }, + "attachment": false, + "open_weights": true, + "release_date": "2026-02-12", + "last_updated": "2026-02-12", + "cost": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.03 + }, + "type": "chat" + }, + { + "id": "qwen/qwen3-embedding-0.6b", + "name": "Qwen3 Embedding 0.6B", + "display_name": "Qwen3 Embedding 0.6B", + "modalities": { + "input": [ + "text" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 8192, + "output": 1024 + }, + "temperature": false, + "tool_call": false, + "reasoning": { + "supported": false + }, + "attachment": false, + "open_weights": true, + "release_date": "2025-06-01", + "last_updated": "2025-06-01", + "cost": { + "input": 0.014 + }, + "type": "embedding" + } + ] } } -} \ No newline at end of file +} diff --git a/src/main/presenter/configPresenter/providers.ts b/src/main/presenter/configPresenter/providers.ts index 13bcc84ed..dc1b89c74 100644 --- a/src/main/presenter/configPresenter/providers.ts +++ b/src/main/presenter/configPresenter/providers.ts @@ -776,5 +776,20 @@ export const DEFAULT_PROVIDERS: LLM_PROVIDER_BASE[] = [ models: 'https://o3.fan/info/models', defaultBaseUrl: 'https://api.o3.fan/v1' } + }, + { + id: 'novita', + name: 'Novita AI', + apiType: 'openai-completions', + apiKey: '', + baseUrl: 'https://api.novita.ai/openai', + enable: false, + websites: { + official: 'https://novita.ai/', + apiKey: 'https://novita.ai/', + docs: 'https://novita.ai/docs', + models: 'https://novita.ai/models', + defaultBaseUrl: 'https://api.novita.ai/openai' + } } ] diff --git a/src/renderer/src/assets/llm-icons/novitaai.svg b/src/renderer/src/assets/llm-icons/novitaai.svg new file mode 100644 index 000000000..ae2d4815c --- /dev/null +++ b/src/renderer/src/assets/llm-icons/novitaai.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/src/components/icons/ModelIcon.vue b/src/renderer/src/components/icons/ModelIcon.vue index 80e03e70a..e0ee3e5f1 100644 --- a/src/renderer/src/components/icons/ModelIcon.vue +++ b/src/renderer/src/components/icons/ModelIcon.vue @@ -72,6 +72,7 @@ import burncloudColorIcon from '@/assets/llm-icons/burncloud-color.svg?url' import xiaomiColorIcon from '@/assets/llm-icons/xiaomi.png?url' import o3fanColorIcon from '@/assets/llm-icons/o3-fan.png?url' import voiceAiColorIcon from '@/assets/llm-icons/voiceai.svg?url' +import novitaAiIcon from '@/assets/llm-icons/novitaai.svg?url' // 导入所有图标 const icons = { @@ -157,6 +158,9 @@ const icons = { burncloud: burncloudColorIcon, xiaomi: xiaomiColorIcon, voiceai: voiceAiColorIcon, + novita: novitaAiIcon, + novitaai: novitaAiIcon, + 'novita.ai': novitaAiIcon, default: defaultIcon } @@ -244,7 +248,8 @@ const monoIconUrls = new Set([ lmstudioColorIcon, _302aiIcon, awsBedrockIcon, - voiceAiColorIcon + voiceAiColorIcon, + novitaAiIcon ]) const invert = computed(() => { diff --git a/test/main/presenter/llmProviderPresenter.test.ts b/test/main/presenter/llmProviderPresenter.test.ts index 35093212e..8e2322686 100644 --- a/test/main/presenter/llmProviderPresenter.test.ts +++ b/test/main/presenter/llmProviderPresenter.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi, beforeAll, afterEach } from 'vite import { LLMProviderPresenter } from '../../../src/main/presenter/llmProviderPresenter/index' import { ConfigPresenter } from '../../../src/main/presenter/configPresenter/index' import { LLM_PROVIDER, ChatMessage, ISQLitePresenter } from '../../../src/shared/presenter' +import { OpenAICompatibleProvider } from '../../../src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider' // Ensure electron is mocked for this suite to avoid CJS named export issues vi.mock('electron', () => { @@ -214,6 +215,30 @@ describe('LLMProviderPresenter Integration Tests', () => { const currentProvider = llmProviderPresenter.getCurrentProvider() expect(currentProvider?.id).toBe('mock-openai-api') }) + + it('should resolve novita via apiType fallback without an id-specific provider mapping', () => { + const novitaProvider: LLM_PROVIDER = { + id: 'novita', + name: 'Novita AI', + apiType: 'openai-completions', + apiKey: 'deepchatIsAwesome', + baseUrl: 'https://api.novita.ai/openai', + enable: true + } + + mockConfigPresenter.getProviders = vi.fn().mockReturnValue([novitaProvider]) + mockConfigPresenter.getProviderById = vi.fn().mockReturnValue(novitaProvider) + + llmProviderPresenter = new LLMProviderPresenter( + mockConfigPresenter, + mockSqlitePresenter, + presenterRuntimeMock.mcpPresenter as any + ) + + const providerInstance = llmProviderPresenter.getProviderInstance('novita') + + expect(providerInstance).toBeInstanceOf(OpenAICompatibleProvider) + }) }) describe('Model Management', () => { diff --git a/test/renderer/components/ModelIcon.test.ts b/test/renderer/components/ModelIcon.test.ts index 6fd602141..10bdc85f6 100644 --- a/test/renderer/components/ModelIcon.test.ts +++ b/test/renderer/components/ModelIcon.test.ts @@ -28,4 +28,19 @@ describe('ModelIcon', () => { expect(image.attributes('alt')).toBe('dimcode') expect(image.attributes('src')).toBe(dimcodeIcon) }) + + it('resolves novita to the novita.ai icon', async () => { + const ModelIcon = (await import('@/components/icons/ModelIcon.vue')).default + const novitaAiIcon = (await import('@/assets/llm-icons/novitaai.svg?url')).default + const wrapper = mount(ModelIcon, { + props: { + modelId: 'novita' + } + }) + + const image = wrapper.get('img') + + expect(image.attributes('alt')).toBe('novita') + expect(image.attributes('src')).toBe(novitaAiIcon) + }) }) From e01d39b3a4e37275a794cce058758de40ba0ec06 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Wed, 25 Mar 2026 18:47:08 +0800 Subject: [PATCH 03/11] feat: feishu bot remote (#1390) * feat(remote): add feishu support * fix(yobrowser): wait for interactive-ready * fix: update browser and feishu runtime * feat(remote): refine feishu binding flow * fix(remote): harden browser and runtime flows * fix(feishu): sanitize runtime error replies --- docs/specs/remote-multi-channel/plan.md | 51 + docs/specs/remote-multi-channel/spec.md | 49 + docs/specs/remote-multi-channel/tasks.md | 11 + package.json | 1 + src/main/presenter/browser/BrowserTab.ts | 199 ++- .../presenter/browser/YoBrowserPresenter.ts | 2 +- src/main/presenter/index.ts | 26 +- .../feishu/feishuClient.ts | 160 ++ .../feishu/feishuParser.ts | 69 + .../feishu/feishuRuntime.ts | 368 +++++ .../presenter/remoteControlPresenter/index.ts | 438 ++++- .../remoteControlPresenter/interface.ts | 2 + .../services/feishuAuthGuard.ts | 83 + .../services/feishuCommandRouter.ts | 295 ++++ .../services/remoteAuthGuard.ts | 19 +- .../services/remoteBindingStore.ts | 357 +++- .../services/remoteCommandRouter.ts | 32 +- .../services/remoteConversationRunner.ts | 71 +- .../telegram/telegramOutbound.ts | 39 +- .../presenter/remoteControlPresenter/types.ts | 454 +++++- src/main/presenter/toolPresenter/index.ts | 7 +- .../settings/components/RemoteSettings.vue | 1431 +++++++++++++---- src/renderer/src/components/WindowSideBar.vue | 89 +- src/renderer/src/i18n/da-DK/chat.json | 3 +- src/renderer/src/i18n/da-DK/settings.json | 44 +- src/renderer/src/i18n/en-US/chat.json | 1 + src/renderer/src/i18n/en-US/settings.json | 63 +- src/renderer/src/i18n/fa-IR/chat.json | 3 +- src/renderer/src/i18n/fa-IR/settings.json | 44 +- src/renderer/src/i18n/fr-FR/chat.json | 3 +- src/renderer/src/i18n/fr-FR/settings.json | 44 +- src/renderer/src/i18n/he-IL/chat.json | 3 +- src/renderer/src/i18n/he-IL/settings.json | 44 +- src/renderer/src/i18n/ja-JP/chat.json | 3 +- src/renderer/src/i18n/ja-JP/settings.json | 44 +- src/renderer/src/i18n/ko-KR/chat.json | 3 +- src/renderer/src/i18n/ko-KR/settings.json | 44 +- src/renderer/src/i18n/pt-BR/chat.json | 3 +- src/renderer/src/i18n/pt-BR/settings.json | 44 +- src/renderer/src/i18n/ru-RU/chat.json | 3 +- src/renderer/src/i18n/ru-RU/settings.json | 44 +- src/renderer/src/i18n/zh-CN/chat.json | 1 + src/renderer/src/i18n/zh-CN/settings.json | 61 +- src/renderer/src/i18n/zh-HK/chat.json | 3 +- src/renderer/src/i18n/zh-HK/settings.json | 46 +- src/renderer/src/i18n/zh-TW/chat.json | 3 +- src/renderer/src/i18n/zh-TW/settings.json | 46 +- src/shared/types/presenters/index.d.ts | 12 +- .../presenters/remote-control.presenter.d.ts | 87 +- .../main/presenter/browser/BrowserTab.test.ts | 364 ++++- .../feishuAuthGuard.test.ts | 64 + .../feishuCommandRouter.test.ts | 149 ++ .../feishuParser.test.ts | 89 + .../feishuRuntime.test.ts | 610 +++++++ .../remoteAuthGuard.test.ts | 43 +- .../remoteBindingStore.test.ts | 188 ++- .../remoteCommandRouter.test.ts | 107 +- .../remoteControlPresenter.test.ts | 20 +- .../remoteConversationRunner.test.ts | 118 ++ .../telegramOutbound.test.ts | 29 +- .../toolPresenter/toolPresenter.test.ts | 10 + .../components/RemoteSettings.test.ts | 513 +++++- .../renderer/components/WindowSideBar.test.ts | 22 + 63 files changed, 6594 insertions(+), 684 deletions(-) create mode 100644 docs/specs/remote-multi-channel/plan.md create mode 100644 docs/specs/remote-multi-channel/spec.md create mode 100644 docs/specs/remote-multi-channel/tasks.md create mode 100644 src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts create mode 100644 src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts create mode 100644 src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts create mode 100644 test/main/presenter/remoteControlPresenter/feishuAuthGuard.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/feishuCommandRouter.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/feishuParser.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/feishuRuntime.test.ts diff --git a/docs/specs/remote-multi-channel/plan.md b/docs/specs/remote-multi-channel/plan.md new file mode 100644 index 000000000..560df832a --- /dev/null +++ b/docs/specs/remote-multi-channel/plan.md @@ -0,0 +1,51 @@ +# Remote Multi-Channel Plan + +## Main Process + +- Expand `remoteControl` config normalization to include `telegram` and `feishu`. +- Keep the existing Telegram runtime, but add Feishu runtime management beside it. +- Reuse `RemoteConversationRunner` for both channels by passing endpoint binding metadata. +- Add a Feishu WebSocket runtime with: + - bot identity probe + - inbound message parsing + - endpoint-scoped serial queue + - text command routing + - final-text response delivery + +## Shared Contracts + +- Add `RemoteChannel = 'telegram' | 'feishu'`. +- Add channel-aware presenter methods: + - `getChannelSettings` + - `saveChannelSettings` + - `getChannelStatus` + - `getChannelBindings` + - `removeChannelBinding` + - `getChannelPairingSnapshot` + - `createChannelPairCode` + - `clearChannelPairCode` + - `clearChannelBindings` + - `getRemoteOverview` +- Keep Telegram hook test API separate. + +## Renderer + +- Rebuild `RemoteSettings.vue` into: + - shared overview header + - Telegram tab + - Feishu tab +- Telegram tab keeps hooks UI. +- Feishu tab only shows remote-control related sections. +- Binding rows display endpoint badges (`DM`, `Group`, `Topic`) instead of raw endpoint keys only. + +## Telegram Fix + +- Restrict draft-stream text extraction to stable visible content blocks. +- When no visible draft-safe text exists, do not send draft updates; keep typing/final-message behavior only. + +## Testing + +- Extend config normalization tests for legacy Telegram-only data plus new Feishu config. +- Add presenter/runtime tests for Feishu settings, bindings, pairing, and runtime enable/disable. +- Add Telegram regression tests proving reasoning/tool-call/pending-action states do not call `sendMessageDraft`. +- Update renderer tests for tab layout, overview, and per-channel dialogs. diff --git a/docs/specs/remote-multi-channel/spec.md b/docs/specs/remote-multi-channel/spec.md new file mode 100644 index 000000000..66e6d6f8b --- /dev/null +++ b/docs/specs/remote-multi-channel/spec.md @@ -0,0 +1,49 @@ +# Remote Multi-Channel + +## Summary + +Extend the existing Remote settings and runtime from Telegram-only to a fixed two-channel model: Telegram and Feishu. Telegram keeps hook notifications, while Feishu adds remote control only. Both channels continue to bind one remote endpoint to one DeepChat session and reuse the existing detached-session flow in Electron main. + +This iteration also fixes the Telegram "deleted message" draft issue by preventing draft streaming from using reasoning-only, tool-call-only, or permission/question-request blocks as visible text. + +## User Stories + +- As a desktop user, I can configure Telegram and Feishu remote control from one Remote page without mixing their credentials and rules together. +- As a Telegram user, I can continue using the existing private-chat pairing flow and hook notifications. +- As a Feishu user, I can pair in a bot DM, then continue the same DeepChat session from DM, group chat, or topic thread. +- As an admin of the desktop app, I can see per-channel runtime health and binding counts from a shared overview area. +- As a paired Feishu user, I can trigger a remote session in group/topic only when I explicitly `@bot`. +- As a Telegram user, I no longer see bot replies visually attached to a deleted draft/reference while the assistant is only reasoning or issuing tool calls. + +## Acceptance Criteria + +- The Remote page renders a shared overview plus separate Telegram and Feishu tabs. +- Telegram settings continue to support bot token, remote pairing, allowlist, default agent, hook settings, and hook test actions. +- Feishu settings support app credentials, pairing, paired user management, default agent selection, and binding management. +- Feishu runtime runs in Electron main via WebSocket event subscription and does not require a renderer window. +- Feishu endpoints are keyed by `chatId + optional threadId`, with topic/thread replies isolated from the group root conversation. +- Feishu authorization requires DM pairing first; in groups/topics, only paired users who `@bot` may send commands or plain text to the bound session. +- `/pair`, `/new`, `/sessions`, `/use`, `/stop`, `/status`, `/open`, and `/model` work for Feishu remote control. +- Telegram `/model` continues to use inline keyboard menus; Feishu `/model` uses text commands only. +- Telegram draft streaming only uses stable visible content blocks. Reasoning-only, tool-call-only, and pending action-only assistant states never call `sendMessageDraft`. +- Existing local desktop chat behavior remains unchanged. + +## Constraints + +- Keep a fixed two-channel architecture. Do not introduce a generic plugin registry for remote channels. +- Telegram hook notifications remain under the shared Remote page; Feishu hook notifications are out of scope. +- Remote sessions continue to use the existing `RemoteConversationRunner` and detached session creation path. +- Feishu v1 supports DM, group, and topic/thread input; media upload, cards, approvals, and user-OAuth automation remain out of scope. + +## Non-Goals + +- A general remote channel SDK or third-party channel plugin system. +- Feishu user-OAuth flows, approval cards, or hook notifications. +- Rich Feishu card-based model switching. +- Telegram group chat support. + +## Compatibility + +- Existing `remoteControl.telegram` store data stays valid and is normalized into the new dual-channel config. +- Existing Telegram hook settings remain valid and continue to be saved through the Remote page. +- New Feishu-specific state is additive under `remoteControl.feishu`. diff --git a/docs/specs/remote-multi-channel/tasks.md b/docs/specs/remote-multi-channel/tasks.md new file mode 100644 index 000000000..5b3036eb2 --- /dev/null +++ b/docs/specs/remote-multi-channel/tasks.md @@ -0,0 +1,11 @@ +# Remote Multi-Channel Tasks + +1. Expand shared remote-control presenter types for channel-aware APIs and overview snapshots. +2. Extend `remoteControl` config normalization for Telegram + Feishu runtime state. +3. Update `RemoteBindingStore` to read/write both channels and persist endpoint metadata. +4. Add Feishu client/runtime/parser/auth/router files under `src/main/presenter/remoteControlPresenter/feishu/`. +5. Update `RemoteControlPresenter` to manage both runtimes and expose channel-aware IPC methods. +6. Update Telegram runtime draft gating so reasoning/tool-call/pending-action states never stream drafts. +7. Rebuild `RemoteSettings.vue` into overview + tabs and refresh the sidebar remote indicator. +8. Update main/renderer tests and add Feishu runtime coverage. +9. Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and targeted Vitest suites. diff --git a/package.json b/package.json index 1a98750ba..17d53b24c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@electron-toolkit/utils": "^4.0.0", "@google/genai": "^1.34.0", "@jxa/run": "^1.4.0", + "@larksuiteoapi/node-sdk": "^1.59.0", "@modelcontextprotocol/sdk": "^1.25.1", "axios": "^1.13.2", "better-sqlite3-multiple-ciphers": "12.4.1", diff --git a/src/main/presenter/browser/BrowserTab.ts b/src/main/presenter/browser/BrowserTab.ts index 7e434cf23..4ef88a215 100644 --- a/src/main/presenter/browser/BrowserTab.ts +++ b/src/main/presenter/browser/BrowserTab.ts @@ -8,6 +8,10 @@ import { import { CDPManager } from './CDPManager' import { ScreenshotManager } from './ScreenshotManager' +const INTERACTIVE_READY_WAIT_TIMEOUT_MS = 2000 +const INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX = 'Timed out waiting for dom-ready:' +const NAVIGATION_CDP_METHODS = new Set(['Page.navigate', 'Page.reload']) + export class BrowserTab { readonly pageId: string readonly createdAt: number @@ -20,7 +24,10 @@ export class BrowserTab { private readonly cdpManager: CDPManager private readonly screenshotManager: ScreenshotManager private isAttached = false + private awaitingMainFrameInteractive = false private interactiveReady = false + private lastInteractiveAt: number | null = null + private loadingStartedAt: number | null = null private fullReady = false constructor( @@ -48,7 +55,7 @@ export class BrowserTab { } async navigate(url: string, timeoutMs?: number): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) try { await this.withTimeout(this.webContents.loadURL(url), timeoutMs ?? 30000) this.title = this.webContents.getTitle() || url @@ -62,7 +69,7 @@ export class BrowserTab { } async navigateUntilDomReady(url: string, timeoutMs: number = 30000): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) const loadPromise = this.webContents.loadURL(url) void loadPromise.catch((error) => { @@ -88,25 +95,51 @@ export class BrowserTab { } async extractDOM(selector?: string): Promise { - this.ensureInteractiveReady('extract DOM') + await this.ensureInteractiveReadyOrWait('extract DOM') const session = await this.ensureSession() return await this.cdpManager.getDOM(session, selector) } async evaluateScript(script: string): Promise { - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() return await this.cdpManager.evaluateScript(session, script) } async sendCdpCommand(method: string, params?: Record): Promise { - this.ensureInteractiveReady(`send CDP command ${method}`) + if (NAVIGATION_CDP_METHODS.has(method)) { + this.ensureAvailable() + } else { + await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`) + } + const session = await this.ensureSession() - return await session.sendCommand(method, params ?? {}) + const response = await session.sendCommand(method, params ?? {}) + + if (method === 'Page.navigate') { + const navigationResponse = response as { + loaderId?: string + errorText?: string + } + const hasCommittedCrossDocumentNavigation = + typeof navigationResponse.loaderId === 'string' && + navigationResponse.loaderId.trim() !== '' && + !navigationResponse.errorText + + if (hasCommittedCrossDocumentNavigation) { + this.beginMainFrameNavigation( + typeof params?.url === 'string' && params.url.trim() ? params.url : this.url + ) + } + } else if (method === 'Page.reload') { + this.beginMainFrameNavigation(this.url) + } + + return response } async takeScreenshot(options?: ScreenshotOptions): Promise { - this.ensureInteractiveReady('capture screenshot') + await this.ensureInteractiveReadyOrWait('capture screenshot') await this.ensureSession() this.ensureAvailable() @@ -491,7 +524,7 @@ export class BrowserTab { private async evaluate(fn: (...args: any[]) => T, ...args: any[]): Promise { this.ensureAvailable() - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() const serializedArgs = JSON.stringify(args, (_key, value) => value === undefined ? null : value @@ -507,23 +540,35 @@ export class BrowserTab { } } - private ensureInteractiveReady(action: string): void { + private async ensureInteractiveReadyOrWait( + action: string, + timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS + ): Promise { this.ensureAvailable() if (this.interactiveReady) { return } - const error = new Error( - `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` - ) - error.name = 'YoBrowserNotReadyError' - Object.assign(error, { - retryable: true, - url: this.url, - status: this.status - }) - throw error + if (this.awaitingMainFrameInteractive || this.status === BrowserPageStatus.Loading) { + try { + await this.waitForInteractiveReady(timeoutMs) + } catch (error) { + if (!this.isInteractiveReadyTimeoutError(error)) { + throw error + } + } + + if (this.interactiveReady) { + return + } + + if (await this.probeInteractiveReadiness()) { + return + } + } + + throw this.buildNotReadyError(action) } private validateKeyInput(key: string, count: number): string { @@ -693,17 +738,22 @@ export class BrowserTab { return this.webContents.debugger } - private prepareForNavigation(url: string): void { + private beginMainFrameNavigation(url: string): void { + const now = Date.now() this.url = url + this.awaitingMainFrameInteractive = true this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = now this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = now } private markNavigationError(error: unknown): void { + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { @@ -759,7 +809,7 @@ export class BrowserTab { timeoutId = setTimeout(() => { cleanup() - reject(new Error(`Timed out waiting for dom-ready: ${this.url}`)) + reject(new Error(`${INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX} ${this.url}`)) }, timeoutMs) this.webContents.once('dom-ready', onDomReady) @@ -768,6 +818,68 @@ export class BrowserTab { }) } + private isInteractiveReadyTimeoutError(error: unknown): error is Error { + return ( + error instanceof Error && error.message.startsWith(INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX) + ) + } + + private async probeInteractiveReadiness(): Promise { + try { + const session = await this.ensureSession() + const probe = (await this.cdpManager.evaluateScript( + session, + `(() => { + try { + return { + readyState: document.readyState, + hasBody: Boolean(document.body), + href: location.href + } + } catch { + return null + } + })()` + )) as { readyState?: unknown; hasBody?: unknown; href?: unknown } | null + + const readyState = typeof probe?.readyState === 'string' ? probe.readyState : '' + const hasBody = probe?.hasBody === true + if (readyState !== 'interactive' && readyState !== 'complete') { + return false + } + + if (!hasBody) { + return false + } + + this.awaitingMainFrameInteractive = false + this.interactiveReady = true + this.lastInteractiveAt = Date.now() + this.updatedAt = this.lastInteractiveAt + if (typeof probe?.href === 'string' && probe.href) { + this.url = probe.href + } + return true + } catch { + return false + } + } + + private buildNotReadyError(action: string): Error { + const error = new Error( + `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` + ) + error.name = 'YoBrowserNotReadyError' + Object.assign(error, { + retryable: true, + url: this.url, + status: this.status, + lastInteractiveAt: this.lastInteractiveAt, + loadingStartedAt: this.loadingStartedAt + }) + return error + } + private async withTimeout(promise: Promise, timeoutMs: number): Promise { return await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -788,28 +900,59 @@ export class BrowserTab { } private bindLifecycleEvents(): void { + this.webContents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + + this.beginMainFrameNavigation(details.url || this.url) + }) + + this.webContents.on('did-navigate-in-page', (_event, url: string, isMainFrame: boolean) => { + if (!isMainFrame) { + return + } + + this.url = url || this.url + this.updatedAt = Date.now() + }) + this.webContents.on('did-start-loading', () => { - this.interactiveReady = false - this.fullReady = false + this.loadingStartedAt = Date.now() this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = this.loadingStartedAt }) this.webContents.on('dom-ready', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true - this.updatedAt = Date.now() + this.lastInteractiveAt = now + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page dom-ready`, { url: this.url, status: this.status }) }) + this.webContents.on('did-stop-loading', () => { + this.loadingStartedAt = null + if (this.interactiveReady) { + this.fullReady = true + this.status = BrowserPageStatus.Ready + } + this.updatedAt = Date.now() + }) + this.webContents.on('did-finish-load', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true + this.lastInteractiveAt = now this.fullReady = true this.status = BrowserPageStatus.Ready this.title = this.webContents.getTitle() || this.url - this.updatedAt = Date.now() + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page did-finish-load`, { url: this.url, status: this.status @@ -830,8 +973,10 @@ export class BrowserTab { } this.url = validatedURL || this.url + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 325878d69..3cb3b884e 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -735,7 +735,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { canGoBack: state.page.contents.navigationHistory.canGoBack(), canGoForward: state.page.contents.navigationHistory.canGoForward(), visible: state.visible, - loading: state.page.status === BrowserPageStatus.Loading + loading: state.page.contents.isLoading() || state.page.status === BrowserPageStatus.Loading } } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c1075734b..6d513c108 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -116,6 +116,15 @@ export class Presenter implements IPresenter { ]) static readonly REMOTE_CONTROL_METHODS = new Set([ + 'getChannelSettings', + 'saveChannelSettings', + 'getChannelStatus', + 'getChannelBindings', + 'removeChannelBinding', + 'getChannelPairingSnapshot', + 'createChannelPairCode', + 'clearChannelPairCode', + 'clearChannelBindings', 'getTelegramSettings', 'saveTelegramSettings', 'getTelegramStatus', @@ -353,20 +362,7 @@ export class Presenter implements IPresenter { this.configPresenter.setHooksNotificationsConfig(config), testTelegramHookNotification: () => this.configPresenter.testTelegramNotification() }) - this.#remoteControlBridge = { - getTelegramSettings: () => this.#remoteControlPresenter.getTelegramSettings(), - saveTelegramSettings: (input) => this.#remoteControlPresenter.saveTelegramSettings(input), - getTelegramStatus: () => this.#remoteControlPresenter.getTelegramStatus(), - getTelegramBindings: () => this.#remoteControlPresenter.getTelegramBindings(), - removeTelegramBinding: (endpointKey) => - this.#remoteControlPresenter.removeTelegramBinding(endpointKey), - getTelegramPairingSnapshot: () => this.#remoteControlPresenter.getTelegramPairingSnapshot(), - createTelegramPairCode: () => this.#remoteControlPresenter.createTelegramPairCode(), - clearTelegramPairCode: () => this.#remoteControlPresenter.clearTelegramPairCode(), - clearTelegramBindings: () => this.#remoteControlPresenter.clearTelegramBindings(), - testTelegramHookNotification: () => - this.#remoteControlPresenter.testTelegramHookNotification() - } + this.#remoteControlBridge = this.#remoteControlPresenter // Update hooksNotifications with actual dependencies now that newAgentPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -528,7 +524,7 @@ export class Presenter implements IPresenter { } const handler = this.#remoteControlBridge[method] as (...args: unknown[]) => unknown - return await handler(...payloads) + return await Reflect.apply(handler, this.#remoteControlBridge, payloads) } // 从配置中同步自定义模型到 LLMProviderPresenter diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts new file mode 100644 index 000000000..44c7476e3 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts @@ -0,0 +1,160 @@ +import * as Lark from '@larksuiteoapi/node-sdk' +import type { EventHandles } from '@larksuiteoapi/node-sdk' +import type { FeishuTransportTarget } from '../types' + +const FEISHU_OUTBOUND_TEXT_LIMIT = 8_000 + +export type FeishuRawMessageEvent = Parameters< + NonNullable +>[0] + +export interface FeishuBotIdentity { + openId: string + name?: string +} + +const createTextPayload = (text: string): string => + JSON.stringify({ + text + }) + +const chunkFeishuText = (text: string): string[] => { + const normalized = text.trim() || '(No text output)' + if (normalized.length <= FEISHU_OUTBOUND_TEXT_LIMIT) { + return [normalized] + } + + const chunks: string[] = [] + let remaining = normalized + + while (remaining.length > FEISHU_OUTBOUND_TEXT_LIMIT) { + const window = remaining.slice(0, FEISHU_OUTBOUND_TEXT_LIMIT) + const splitIndex = Math.max(window.lastIndexOf('\n\n'), window.lastIndexOf('\n')) + const nextIndex = + splitIndex > Math.floor(FEISHU_OUTBOUND_TEXT_LIMIT * 0.55) + ? splitIndex + : FEISHU_OUTBOUND_TEXT_LIMIT + chunks.push(remaining.slice(0, nextIndex).trim()) + remaining = remaining.slice(nextIndex).trim() + } + + if (remaining) { + chunks.push(remaining) + } + + return chunks +} + +export class FeishuClient { + private readonly sdk: Lark.Client + private wsClient: Lark.WSClient | null = null + + constructor( + private readonly credentials: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + } + ) { + this.sdk = new Lark.Client({ + appId: credentials.appId, + appSecret: credentials.appSecret, + appType: Lark.AppType.SelfBuild + }) + } + + async probeBot(): Promise { + const response = await (this.sdk as any).request({ + method: 'GET', + url: '/open-apis/bot/v3/info', + data: {} + }) + + if (response?.code !== 0) { + throw new Error(response?.msg?.trim() || 'Failed to fetch Feishu bot info.') + } + + const bot = response?.bot || response?.data?.bot + const openId = bot?.open_id?.trim() + if (!openId) { + throw new Error('Feishu bot open_id is missing from bot/v3/info response.') + } + + return { + openId, + name: bot?.bot_name?.trim() || undefined + } + } + + async startMessageStream(params: { + onMessage: (event: FeishuRawMessageEvent) => Promise + }): Promise { + this.stop() + + const dispatcherOptions: { + encryptKey?: string + verificationToken?: string + } = {} + + if (this.credentials.encryptKey.trim()) { + dispatcherOptions.encryptKey = this.credentials.encryptKey + } + + if (this.credentials.verificationToken.trim()) { + dispatcherOptions.verificationToken = this.credentials.verificationToken + } + + const dispatcher = new Lark.EventDispatcher(dispatcherOptions) + + dispatcher.register({ + 'im.message.receive_v1': async (event: FeishuRawMessageEvent) => { + await params.onMessage(event) + } + }) + + this.wsClient = new Lark.WSClient({ + appId: this.credentials.appId, + appSecret: this.credentials.appSecret, + loggerLevel: Lark.LoggerLevel.info + }) + + await this.wsClient.start({ + eventDispatcher: dispatcher + }) + } + + stop(): void { + this.wsClient?.close({ force: true }) + this.wsClient = null + } + + async sendText(target: FeishuTransportTarget, text: string): Promise { + for (const chunk of chunkFeishuText(text)) { + if (target.replyToMessageId) { + await this.sdk.im.message.reply({ + path: { + message_id: target.replyToMessageId + }, + data: { + content: createTextPayload(chunk), + msg_type: 'text', + reply_in_thread: Boolean(target.threadId) + } + }) + continue + } + + await this.sdk.im.message.create({ + params: { + receive_id_type: 'chat_id' + }, + data: { + receive_id: target.chatId, + msg_type: 'text', + content: createTextPayload(chunk) + } + }) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts new file mode 100644 index 000000000..fb05da665 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts @@ -0,0 +1,69 @@ +import type { TelegramCommandPayload } from '../types' +import type { FeishuRawMessageEvent } from './feishuClient' +import type { FeishuInboundMessage } from '../types' + +const FEISHU_COMMAND_REGEX = /^\/([a-zA-Z0-9_]+)(?:\s+([\s\S]*))?$/ +const FEISHU_LEADING_AT_TAG_REGEX = /^(?:\s*]*>.*?<\/at>\s*)+/i +const FEISHU_LEADING_AT_TEXT_REGEX = /^(?:\s*@[\w.-]+\s*)+/i + +const parseTextContent = (content: string): string => { + try { + const parsed = JSON.parse(content) as { text?: string } + if (typeof parsed?.text === 'string') { + return parsed.text.trim() + } + } catch { + // Fall through to raw content. + } + + return content.trim() +} + +const stripLeadingMentions = (text: string): string => + text.replace(FEISHU_LEADING_AT_TAG_REGEX, '').replace(FEISHU_LEADING_AT_TEXT_REGEX, '').trim() + +const parseCommand = (text: string): TelegramCommandPayload | null => { + const match = FEISHU_COMMAND_REGEX.exec(text) + if (!match) { + return null + } + + return { + name: match[1].toLowerCase(), + args: match[2]?.trim() ?? '' + } +} + +export class FeishuParser { + parseEvent(event: FeishuRawMessageEvent, botOpenId?: string): FeishuInboundMessage | null { + const rawText = parseTextContent(event.message?.content ?? '') + if (!rawText) { + return null + } + + const mentions = event.message?.mentions ?? [] + const mentionedBot = Boolean( + botOpenId && + mentions.some((mention) => mention.id?.open_id && mention.id.open_id === botOpenId) + ) + + const normalizedText = stripLeadingMentions(rawText) + if (!normalizedText) { + return null + } + + return { + kind: 'message', + eventId: event.event_id?.trim() || event.uuid?.trim() || event.message.message_id, + chatId: event.message.chat_id, + threadId: event.message.thread_id || event.message.root_id || null, + messageId: event.message.message_id, + chatType: event.message.chat_type === 'p2p' ? 'p2p' : 'group', + senderOpenId: event.sender?.sender_id?.open_id?.trim() || null, + text: normalizedText, + command: parseCommand(normalizedText), + mentionedBot, + mentions + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts new file mode 100644 index 000000000..1f01d2362 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts @@ -0,0 +1,368 @@ +import { + FEISHU_CONVERSATION_POLL_TIMEOUT_MS, + FEISHU_INBOUND_DEDUP_LIMIT, + FEISHU_INBOUND_DEDUP_TTL_MS, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type FeishuTransportTarget +} from '../types' +import { FeishuCommandRouter } from '../services/feishuCommandRouter' +import type { RemoteConversationExecution } from '../services/remoteConversationRunner' +import { FeishuClient, type FeishuBotIdentity } from './feishuClient' +import { FeishuParser } from './feishuParser' + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +const FEISHU_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.' + +type FeishuRuntimeDeps = { + client: FeishuClient + parser: FeishuParser + router: FeishuCommandRouter + logger?: { + error: (...params: unknown[]) => void + } + onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void + onFatalError?: (message: string) => void +} + +type FeishuProcessedInboundEntry = { + receivedAt: number + eventId: string | null +} + +export class FeishuRuntime { + private runId = 0 + private started = false + private stopRequested = false + private statusSnapshot: FeishuRuntimeStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null + } + private readonly processedInboundByMessage = new Map() + private readonly processedEventToMessage = new Map() + private readonly endpointOperations = new Map>() + + constructor(private readonly deps: FeishuRuntimeDeps) {} + + async start(): Promise { + if (this.started) { + return + } + + const runId = ++this.runId + this.started = true + this.stopRequested = false + this.setStatus({ + state: 'starting', + lastError: null + }) + + try { + const botUser = await this.deps.client.probeBot() + if (!this.isCurrentRun(runId)) { + return + } + + this.setBotUser(botUser) + await this.deps.client.startMessageStream({ + onMessage: async (event) => { + try { + this.acceptRawMessage(event, runId) + } catch (error) { + console.warn('[FeishuRuntime] Failed to enqueue event:', error) + } + } + }) + if (!this.isCurrentRun(runId)) { + return + } + + this.setStatus({ + state: 'running', + lastError: null + }) + } catch (error) { + if (!this.isCurrentRun(runId)) { + return + } + + this.started = false + this.setStatus({ + state: 'error', + lastError: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + + async stop(): Promise { + this.stopRequested = true + this.started = false + this.runId += 1 + this.deps.client.stop() + this.endpointOperations.clear() + this.processedInboundByMessage.clear() + this.processedEventToMessage.clear() + this.setStatus({ + state: 'stopped' + }) + } + + getStatusSnapshot(): FeishuRuntimeStatusSnapshot { + return { ...this.statusSnapshot } + } + + private isCurrentRun(runId: number): boolean { + return this.runId === runId && this.started && !this.stopRequested + } + + private acceptRawMessage(event: Parameters[0], runId: number): void { + if (!this.isCurrentRun(runId)) { + return + } + + const parsed = this.deps.parser.parseEvent(event, this.statusSnapshot.botUser?.openId) + if (!parsed) { + return + } + + const duplicateReason = this.rememberInboundMessage(parsed) + if (duplicateReason) { + console.info('[FeishuRuntime] Dropped duplicate inbound message.', { + reason: duplicateReason, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + }) + return + } + + const endpointKey = buildFeishuEndpointKey(parsed.chatId, parsed.threadId) + if (parsed.command?.name === 'stop') { + void this.processInboundMessage(parsed, runId) + return + } + + this.enqueueEndpointOperation(endpointKey, runId, async () => { + await this.processInboundMessage(parsed, runId) + }) + } + + private rememberInboundMessage(message: FeishuInboundMessage): 'eventId' | 'messageId' | null { + const now = Date.now() + this.pruneProcessedInbound(now) + + const messageKey = this.buildMessageDedupKey(message) + if (this.processedInboundByMessage.has(messageKey)) { + return 'messageId' + } + + const normalizedEventId = message.eventId.trim() + if (normalizedEventId && this.processedEventToMessage.has(normalizedEventId)) { + return 'eventId' + } + + this.processedInboundByMessage.set(messageKey, { + receivedAt: now, + eventId: normalizedEventId || null + }) + if (normalizedEventId) { + this.processedEventToMessage.set(normalizedEventId, messageKey) + } + + while (this.processedInboundByMessage.size > FEISHU_INBOUND_DEDUP_LIMIT) { + const oldestKey = this.processedInboundByMessage.keys().next().value + if (!oldestKey) { + break + } + this.deleteProcessedInbound(oldestKey) + } + + return null + } + + private buildMessageDedupKey(message: FeishuInboundMessage): string { + return `${message.chatId}:${message.messageId}` + } + + private pruneProcessedInbound(now: number): void { + for (const [messageKey, entry] of this.processedInboundByMessage.entries()) { + if (now - entry.receivedAt <= FEISHU_INBOUND_DEDUP_TTL_MS) { + break + } + this.deleteProcessedInbound(messageKey) + } + } + + private deleteProcessedInbound(messageKey: string): void { + const entry = this.processedInboundByMessage.get(messageKey) + if (!entry) { + return + } + + this.processedInboundByMessage.delete(messageKey) + if (entry.eventId) { + this.processedEventToMessage.delete(entry.eventId) + } + } + + private enqueueEndpointOperation( + endpointKey: string, + runId: number, + operation: () => Promise + ): void { + const previous = this.endpointOperations.get(endpointKey) ?? Promise.resolve() + const next = previous + .catch(() => undefined) + .then(async () => { + if (!this.isCurrentRun(runId)) { + return + } + + await operation() + }) + .finally(() => { + if (this.endpointOperations.get(endpointKey) === next) { + this.endpointOperations.delete(endpointKey) + } + }) + + this.endpointOperations.set(endpointKey, next) + } + + private async processInboundMessage(parsed: FeishuInboundMessage, runId: number): Promise { + if (!this.isCurrentRun(runId)) { + return + } + + const target: FeishuTransportTarget = { + chatId: parsed.chatId, + threadId: parsed.threadId, + replyToMessageId: parsed.messageId + } + + try { + const routed = await this.deps.router.handleMessage(parsed) + if (!this.isCurrentRun(runId)) { + return + } + + for (const reply of routed.replies) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, reply) + } + + if (routed.conversation) { + await this.deliverConversation(target, routed.conversation, runId) + } + } catch (error) { + const diagnostics = { + runId, + target, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + } + + console.warn('[FeishuRuntime] Failed to handle event:', { + ...diagnostics, + error + }) + if (this.deps.logger?.error) { + this.deps.logger.error(error, diagnostics) + } else { + console.error('[FeishuRuntime] Failed to handle event:', error, diagnostics) + } + + if (!this.isCurrentRun(runId)) { + return + } + + try { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, FEISHU_INTERNAL_ERROR_REPLY) + } catch (sendError) { + console.warn('[FeishuRuntime] Failed to send error reply:', { + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId, + error: sendError + }) + } + } + } + + private async deliverConversation( + target: FeishuTransportTarget, + execution: RemoteConversationExecution, + runId: number + ): Promise { + const startedAt = Date.now() + + while (this.isCurrentRun(runId)) { + const snapshot = await execution.getSnapshot() + if (!this.isCurrentRun(runId)) { + return + } + + if (snapshot.completed) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, snapshot.text) + return + } + + if (Date.now() - startedAt >= FEISHU_CONVERSATION_POLL_TIMEOUT_MS) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText( + target, + 'The current conversation timed out before finishing. Please try again.' + ) + return + } + + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) + } + } + + private setBotUser(botUser: FeishuBotIdentity): void { + this.setStatus({ + botUser: { + openId: botUser.openId, + name: botUser.name + } + }) + } + + private setStatus( + patch: Partial & { + state?: FeishuRuntimeStatusSnapshot['state'] + } + ): void { + this.statusSnapshot = { + ...this.statusSnapshot, + ...patch + } + this.deps.onStatusChange?.(this.getStatusSnapshot()) + + if (patch.state === 'error' && patch.lastError) { + this.deps.onFatalError?.(patch.lastError) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index f9cea6de0..9512d6cee 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -1,5 +1,12 @@ import type { HookTestResult, TelegramNotificationsConfig } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingSummary, + RemoteChannel, + RemoteChannelSettings, + RemoteChannelStatus, TelegramPairingSnapshot, TelegramRemoteBindingSummary, TelegramRemoteSettings, @@ -8,20 +15,35 @@ import type { import { TELEGRAM_REMOTE_COMMANDS, TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + buildBindingSummary, + normalizeFeishuSettingsInput, normalizeTelegramSettingsInput, parseTelegramEndpointKey, + type FeishuRuntimeStatusSnapshot, type TelegramPollerStatusSnapshot } from './types' import type { RemoteControlPresenterDeps } from './interface' +import logger from '@shared/logger' import { RemoteBindingStore } from './services/remoteBindingStore' +import { FeishuAuthGuard } from './services/feishuAuthGuard' +import { FeishuCommandRouter } from './services/feishuCommandRouter' import { RemoteAuthGuard } from './services/remoteAuthGuard' import { RemoteConversationRunner } from './services/remoteConversationRunner' import { RemoteCommandRouter } from './services/remoteCommandRouter' +import { FeishuClient } from './feishu/feishuClient' +import { FeishuParser } from './feishu/feishuParser' +import { FeishuRuntime } from './feishu/feishuRuntime' import { TelegramClient } from './telegram/telegramClient' import { TelegramParser } from './telegram/telegramParser' import { TelegramPoller } from './telegram/telegramPoller' -const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { +const DEFAULT_TELEGRAM_POLLER_STATUS: TelegramPollerStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null +} + +const DEFAULT_FEISHU_RUNTIME_STATUS: FeishuRuntimeStatusSnapshot = { state: 'stopped', lastError: null, botUser: null @@ -30,8 +52,11 @@ const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { export class RemoteControlPresenter { private readonly bindingStore: RemoteBindingStore private telegramPoller: TelegramPoller | null = null - private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_POLLER_STATUS } + private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_TELEGRAM_POLLER_STATUS } private activeBotToken: string | null = null + private feishuRuntime: FeishuRuntime | null = null + private feishuRuntimeStatus: FeishuRuntimeStatusSnapshot = { ...DEFAULT_FEISHU_RUNTIME_STATUS } + private activeFeishuRuntimeKey: string | null = null private runtimeOperation: Promise = Promise.resolve() constructor(private readonly deps: RemoteControlPresenterDeps) { @@ -40,13 +65,13 @@ export class RemoteControlPresenter { async initialize(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.rebuildTelegramRuntime() + await Promise.all([this.rebuildTelegramRuntime(), this.rebuildFeishuRuntime()]) }) } async destroy(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.stopTelegramRuntime() + await Promise.all([this.stopTelegramRuntime(), this.stopFeishuRuntime()]) }) } @@ -68,9 +93,112 @@ export class RemoteControlPresenter { } } + buildFeishuSettingsSnapshot(): FeishuRemoteSettings { + const remoteConfig = this.bindingStore.getFeishuConfig() + return { + appId: remoteConfig.appId, + appSecret: remoteConfig.appSecret, + verificationToken: remoteConfig.verificationToken, + encryptKey: remoteConfig.encryptKey, + remoteEnabled: remoteConfig.enabled, + defaultAgentId: remoteConfig.defaultAgentId, + pairedUserOpenIds: [...remoteConfig.pairedUserOpenIds] + } + } + + async getChannelSettings(channel: 'telegram'): Promise + async getChannelSettings(channel: 'feishu'): Promise + async getChannelSettings(channel: RemoteChannel): Promise + async getChannelSettings(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramSettings() + } + + return await this.getFeishuSettings() + } + + async saveChannelSettings( + channel: 'telegram', + input: TelegramRemoteSettings + ): Promise + async saveChannelSettings( + channel: 'feishu', + input: FeishuRemoteSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise { + if (channel === 'telegram') { + return await this.saveTelegramSettings(input as TelegramRemoteSettings) + } + + return await this.saveFeishuSettings(input as FeishuRemoteSettings) + } + + async getChannelStatus(channel: 'telegram'): Promise + async getChannelStatus(channel: 'feishu'): Promise + async getChannelStatus(channel: RemoteChannel): Promise + async getChannelStatus(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramStatus() + } + + return await this.getFeishuStatus() + } + + async getChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore + .listBindings(channel) + .map(({ endpointKey, binding }) => buildBindingSummary(endpointKey, binding)) + .filter((binding): binding is RemoteBindingSummary => binding !== null) + .sort((left, right) => right.updatedAt - left.updatedAt) + } + + async removeChannelBinding(channel: RemoteChannel, endpointKey: string): Promise { + if (!endpointKey.startsWith(`${channel}:`)) { + return + } + + this.bindingStore.clearBinding(endpointKey) + } + + async getChannelPairingSnapshot(channel: 'telegram'): Promise + async getChannelPairingSnapshot(channel: 'feishu'): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise { + if (channel === 'telegram') { + return this.bindingStore.getTelegramPairingSnapshot() + } + + return this.bindingStore.getFeishuPairingSnapshot() + } + + async createChannelPairCode( + channel: RemoteChannel + ): Promise<{ code: string; expiresAt: number }> { + return this.bindingStore.createPairCode(channel) + } + + async clearChannelPairCode(channel: RemoteChannel): Promise { + this.bindingStore.clearPairCode(channel) + } + + async clearChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore.clearBindings(channel) + } + async getTelegramSettings(): Promise { const snapshot = this.buildTelegramSettingsSnapshot() - const defaultAgentId = await this.sanitizeDefaultAgentId(snapshot.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', snapshot.defaultAgentId) return { ...snapshot, defaultAgentId @@ -79,7 +207,7 @@ export class RemoteControlPresenter { async saveTelegramSettings(input: TelegramRemoteSettings): Promise { const normalized = normalizeTelegramSettingsInput(input) - const defaultAgentId = await this.sanitizeDefaultAgentId(normalized.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', normalized.defaultAgentId) const currentHooksConfig = this.deps.getHooksNotificationsConfig() const currentRemoteConfig = this.bindingStore.getTelegramConfig() const currentBotToken = currentHooksConfig.telegram.botToken.trim() @@ -97,7 +225,7 @@ export class RemoteControlPresenter { enabled: normalized.remoteEnabled, allowlist: normalized.allowedUserIds, defaultAgentId, - streamMode: 'draft', + streamMode: currentRemoteConfig.streamMode, lastFatalError: shouldClearFatalError ? null : config.lastFatalError, pairing: config.pairing })) @@ -111,13 +239,14 @@ export class RemoteControlPresenter { async getTelegramStatus(): Promise { const remoteConfig = this.bindingStore.getTelegramConfig() const hooksConfig = this.deps.getHooksNotificationsConfig().telegram - const runtimeStatus = this.getEffectivePollerStatus( + const runtimeStatus = this.getEffectiveTelegramStatus( hooksConfig.botToken, remoteConfig.enabled, remoteConfig.lastFatalError ) return { + channel: 'telegram', enabled: remoteConfig.enabled, state: runtimeStatus.state, pollOffset: remoteConfig.pollOffset, @@ -130,7 +259,7 @@ export class RemoteControlPresenter { async getTelegramBindings(): Promise { return this.bindingStore - .listBindings() + .listBindings('telegram') .map(({ endpointKey, binding }) => { const endpoint = parseTelegramEndpointKey(endpointKey) if (!endpoint) { @@ -150,23 +279,82 @@ export class RemoteControlPresenter { } async removeTelegramBinding(endpointKey: string): Promise { - this.bindingStore.clearBinding(endpointKey) + await this.removeChannelBinding('telegram', endpointKey) } async getTelegramPairingSnapshot(): Promise { - return this.bindingStore.getPairingSnapshot() + return this.bindingStore.getTelegramPairingSnapshot() } async createTelegramPairCode(): Promise<{ code: string; expiresAt: number }> { - return this.bindingStore.createPairCode() + return await this.createChannelPairCode('telegram') } async clearTelegramPairCode(): Promise { - this.bindingStore.clearPairCode() + await this.clearChannelPairCode('telegram') } async clearTelegramBindings(): Promise { - return this.bindingStore.clearBindings() + return await this.clearChannelBindings('telegram') + } + + async getFeishuSettings(): Promise { + const snapshot = this.buildFeishuSettingsSnapshot() + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', snapshot.defaultAgentId) + return { + ...snapshot, + defaultAgentId + } + } + + async saveFeishuSettings(input: FeishuRemoteSettings): Promise { + const normalized = normalizeFeishuSettingsInput(input) + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', normalized.defaultAgentId) + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + const shouldClearFatalError = + currentRemoteConfig.enabled !== normalized.remoteEnabled || + currentRemoteConfig.appId !== normalized.appId || + currentRemoteConfig.appSecret !== normalized.appSecret || + currentRemoteConfig.verificationToken !== normalized.verificationToken || + currentRemoteConfig.encryptKey !== normalized.encryptKey + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + appId: normalized.appId, + appSecret: normalized.appSecret, + verificationToken: normalized.verificationToken, + encryptKey: normalized.encryptKey, + enabled: normalized.remoteEnabled, + defaultAgentId, + pairedUserOpenIds: normalized.pairedUserOpenIds, + lastFatalError: shouldClearFatalError ? null : config.lastFatalError, + pairing: config.pairing + })) + + await this.enqueueRuntimeOperation(async () => { + await this.rebuildFeishuRuntime() + }) + return await this.getFeishuSettings() + } + + async getFeishuStatus(): Promise { + const remoteConfig = this.bindingStore.getFeishuConfig() + const runtimeStatus = this.getEffectiveFeishuStatus( + remoteConfig.enabled, + remoteConfig.lastFatalError, + remoteConfig.appId, + remoteConfig.appSecret + ) + + return { + channel: 'feishu', + enabled: remoteConfig.enabled, + state: runtimeStatus.state, + bindingCount: Object.keys(remoteConfig.bindings).length, + pairedUserCount: remoteConfig.pairedUserOpenIds.length, + lastError: runtimeStatus.lastError, + botUser: runtimeStatus.botUser + } } async testTelegramHookNotification(): Promise { @@ -227,23 +415,12 @@ export class RemoteControlPresenter { await this.registerTelegramCommands(client) const authGuard = new RemoteAuthGuard(this.bindingStore) - const runner = new RemoteConversationRunner( - { - configPresenter: this.deps.configPresenter, - newAgentPresenter: this.deps.newAgentPresenter, - deepchatAgentPresenter: this.deps.deepchatAgentPresenter, - windowPresenter: this.deps.windowPresenter, - tabPresenter: this.deps.tabPresenter, - resolveDefaultAgentId: async () => - await this.sanitizeDefaultAgentId(this.bindingStore.getDefaultAgentId()) - }, - this.bindingStore - ) + const runner = this.createConversationRunner('telegram') const router = new RemoteCommandRouter({ authGuard, runner, bindingStore: this.bindingStore, - getPollerStatus: () => this.getEffectivePollerStatus(botToken, true, null) + getPollerStatus: () => this.getEffectiveTelegramStatus(botToken, true, null) }) this.telegramPoller = new TelegramPoller({ @@ -273,6 +450,84 @@ export class RemoteControlPresenter { } } + private async rebuildFeishuRuntime(): Promise { + const settings = this.buildFeishuSettingsSnapshot() + const runtimeKey = this.buildFeishuRuntimeKey(settings) + + if (!settings.remoteEnabled) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'disabled', + lastError: null, + botUser: null + } + return + } + + if (!settings.appId.trim() || !settings.appSecret.trim()) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + return + } + + if (this.feishuRuntime && this.activeFeishuRuntimeKey === runtimeKey) { + return + } + + await this.stopFeishuRuntime() + this.activeFeishuRuntimeKey = runtimeKey + this.feishuRuntimeStatus = { + state: 'starting', + lastError: null, + botUser: null + } + + const client = new FeishuClient({ + appId: settings.appId, + appSecret: settings.appSecret, + verificationToken: settings.verificationToken, + encryptKey: settings.encryptKey + }) + const runner = this.createConversationRunner('feishu') + const router = new FeishuCommandRouter({ + authGuard: new FeishuAuthGuard(this.bindingStore), + runner, + bindingStore: this.bindingStore, + getRuntimeStatus: () => + this.getEffectiveFeishuStatus(true, null, settings.appId, settings.appSecret) + }) + + this.feishuRuntime = new FeishuRuntime({ + client, + parser: new FeishuParser(), + router, + logger, + onStatusChange: (snapshot) => { + this.feishuRuntimeStatus = snapshot + }, + onFatalError: (message) => { + void this.enqueueRuntimeOperation(async () => { + await this.disableFeishuRuntimeForFatalError(runtimeKey, message) + }) + } + }) + + try { + await this.feishuRuntime.start() + } catch (error) { + this.feishuRuntimeStatus = { + state: 'error', + lastError: error instanceof Error ? error.message : String(error), + botUser: null + } + await this.stopFeishuRuntime() + } + } + private async stopTelegramRuntime(): Promise { const poller = this.telegramPoller this.telegramPoller = null @@ -285,7 +540,19 @@ export class RemoteControlPresenter { await poller.stop() } - private getEffectivePollerStatus( + private async stopFeishuRuntime(): Promise { + const runtime = this.feishuRuntime + this.feishuRuntime = null + this.activeFeishuRuntimeKey = null + + if (!runtime) { + return + } + + await runtime.stop() + } + + private getEffectiveTelegramStatus( botToken: string, remoteEnabled: boolean, lastFatalError: string | null @@ -317,6 +584,39 @@ export class RemoteControlPresenter { return { ...this.telegramPollerStatus } } + private getEffectiveFeishuStatus( + remoteEnabled: boolean, + lastFatalError: string | null, + appId: string, + appSecret: string + ): FeishuRuntimeStatusSnapshot { + if (!remoteEnabled) { + if (lastFatalError) { + return { + state: 'error', + lastError: lastFatalError, + botUser: null + } + } + + return { + state: 'disabled', + lastError: null, + botUser: null + } + } + + if (!appId.trim() || !appSecret.trim()) { + return { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + } + + return { ...this.feishuRuntimeStatus } + } + private async disableTelegramRuntimeForFatalError( botToken: string, errorMessage: string @@ -342,13 +642,82 @@ export class RemoteControlPresenter { } } + private async disableFeishuRuntimeForFatalError( + runtimeKey: string, + errorMessage: string + ): Promise { + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + if ( + !currentRemoteConfig.enabled || + this.buildFeishuRuntimeKey({ + appId: currentRemoteConfig.appId, + appSecret: currentRemoteConfig.appSecret, + verificationToken: currentRemoteConfig.verificationToken, + encryptKey: currentRemoteConfig.encryptKey + }) !== runtimeKey + ) { + return + } + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + enabled: false, + lastFatalError: errorMessage + })) + + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: errorMessage, + botUser: null + } + } + + private createConversationRunner(channel: RemoteChannel): RemoteConversationRunner { + return new RemoteConversationRunner( + { + configPresenter: this.deps.configPresenter, + newAgentPresenter: this.deps.newAgentPresenter, + deepchatAgentPresenter: this.deps.deepchatAgentPresenter, + windowPresenter: this.deps.windowPresenter, + tabPresenter: this.deps.tabPresenter, + resolveDefaultAgentId: async () => + await this.sanitizeDefaultAgentId(channel, this.getDefaultAgentId(channel)) + }, + this.bindingStore + ) + } + + private getDefaultAgentId(channel: RemoteChannel): string { + return channel === 'telegram' + ? this.bindingStore.getTelegramDefaultAgentId() + : this.bindingStore.getFeishuDefaultAgentId() + } + + private buildFeishuRuntimeKey(settings: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + }): string { + return [ + settings.appId.trim(), + settings.appSecret.trim(), + settings.verificationToken.trim(), + settings.encryptKey.trim() + ].join('::') + } + private enqueueRuntimeOperation(operation: () => Promise): Promise { const nextOperation = this.runtimeOperation.then(operation, operation) this.runtimeOperation = nextOperation.catch(() => {}) return nextOperation } - private async sanitizeDefaultAgentId(candidate: string | null | undefined): Promise { + private async sanitizeDefaultAgentId( + channel: RemoteChannel, + candidate: string | null | undefined + ): Promise { const normalizedCandidate = candidate?.trim() || TELEGRAM_REMOTE_DEFAULT_AGENT_ID const agents = await this.deps.configPresenter.listAgents() const enabledDeepChatAgents = agents.filter( @@ -361,8 +730,15 @@ export class RemoteControlPresenter { ? TELEGRAM_REMOTE_DEFAULT_AGENT_ID : enabledDeepChatAgents[0]?.id || TELEGRAM_REMOTE_DEFAULT_AGENT_ID - if (this.bindingStore.getDefaultAgentId() !== nextDefaultAgentId) { - this.bindingStore.updateTelegramConfig((config) => ({ + if (channel === 'telegram') { + if (this.bindingStore.getTelegramDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + defaultAgentId: nextDefaultAgentId + })) + } + } else if (this.bindingStore.getFeishuDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateFeishuConfig((config) => ({ ...config, defaultAgentId: nextDefaultAgentId })) diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts index b955446c6..cdcd74b01 100644 --- a/src/main/presenter/remoteControlPresenter/interface.ts +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -1,5 +1,6 @@ import type { HookTestResult, HooksNotificationsSettings } from '@shared/hooksNotifications' import type { + FeishuRemoteSettings, IConfigPresenter, INewAgentPresenter, IRemoteControlPresenter, @@ -28,4 +29,5 @@ export interface RemoteRuntimeLifecycle { export interface RemoteControlPresenterLike extends IRemoteControlPresenter, RemoteRuntimeLifecycle { buildTelegramSettingsSnapshot(): TelegramRemoteSettings + buildFeishuSettingsSnapshot(): FeishuRemoteSettings } diff --git a/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts new file mode 100644 index 000000000..66f523c07 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts @@ -0,0 +1,83 @@ +import { REMOTE_PAIR_CODE_MAX_FAILURES, type FeishuInboundMessage } from '../types' +import { RemoteBindingStore } from './remoteBindingStore' + +export type FeishuAuthResult = + | { + ok: true + userOpenId: string + } + | { + ok: false + message: string + silent?: boolean + } + +export class FeishuAuthGuard { + constructor(private readonly bindingStore: RemoteBindingStore) {} + + ensureAuthorized(message: FeishuInboundMessage): FeishuAuthResult { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + ok: false, + message: '', + silent: true + } + } + + if (!message.senderOpenId) { + return { + ok: false, + message: 'Unable to verify your Feishu account.' + } + } + + if (this.bindingStore.isFeishuPairedUser(message.senderOpenId)) { + return { + ok: true, + userOpenId: message.senderOpenId + } + } + + return { + ok: false, + message: + 'This Feishu account is not paired. Open Remote settings and use the current /pair code.' + } + } + + pair(message: FeishuInboundMessage, rawCode: string): string { + if (message.chatType !== 'p2p') { + return 'Pairing is only available in a private chat with the Feishu bot.' + } + + if (!message.senderOpenId) { + return 'Unable to verify your Feishu account for pairing.' + } + + const normalizedCode = rawCode.trim() + if (!/^\d{6}$/.test(normalizedCode)) { + return 'Usage: /pair <6-digit-code>' + } + + const pairing = this.bindingStore.getFeishuPairingState() + if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { + this.bindingStore.clearPairCode('feishu') + return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' + } + + if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'feishu', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } + return 'Pairing code is invalid.' + } + + this.bindingStore.addFeishuPairedUser(message.senderOpenId) + this.bindingStore.clearPairCode('feishu') + return `Pairing complete. Feishu user ${message.senderOpenId} is now authorized.` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts new file mode 100644 index 000000000..614d983f9 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts @@ -0,0 +1,295 @@ +import type { SessionWithState } from '@shared/types/agent-interface' +import { + FEISHU_REMOTE_COMMANDS, + buildFeishuBindingMeta, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type TelegramModelProviderOption +} from '../types' +import type { RemoteConversationExecution } from './remoteConversationRunner' +import { FeishuAuthGuard } from './feishuAuthGuard' +import { RemoteBindingStore } from './remoteBindingStore' +import { RemoteConversationRunner } from './remoteConversationRunner' + +export interface FeishuCommandRouteResult { + replies: string[] + conversation?: RemoteConversationExecution +} + +type FeishuCommandRouterDeps = { + authGuard: FeishuAuthGuard + runner: RemoteConversationRunner + bindingStore: RemoteBindingStore + getRuntimeStatus: () => FeishuRuntimeStatusSnapshot +} + +export class FeishuCommandRouter { + constructor(private readonly deps: FeishuCommandRouterDeps) {} + + async handleMessage(message: FeishuInboundMessage): Promise { + const endpointKey = buildFeishuEndpointKey(message.chatId, message.threadId) + const bindingMeta = buildFeishuBindingMeta({ + chatId: message.chatId, + threadId: message.threadId, + chatType: message.chatType + }) + const command = message.command?.name + + if (command === 'start') { + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok && auth.silent) { + return { + replies: [] + } + } + + return { + replies: [this.formatStartMessage(auth.ok)] + } + } + + if (command === 'help') { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + replies: [] + } + } + + return { + replies: [this.formatHelpMessage()] + } + } + + if (command === 'pair') { + return { + replies: [this.deps.authGuard.pair(message, message.command?.args ?? '')] + } + } + + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok) { + return { + replies: auth.silent ? [] : [auth.message] + } + } + + try { + switch (command) { + case 'new': { + const title = message.command?.args?.trim() + const session = await this.deps.runner.createNewSession(endpointKey, title, bindingMeta) + return { + replies: [`Started a new session: ${this.formatSessionLabel(session)}`] + } + } + + case 'sessions': { + const sessions = await this.deps.runner.listSessions(endpointKey) + if (sessions.length === 0) { + return { + replies: ['No DeepChat sessions were found.'] + } + } + + return { + replies: [ + [ + 'Recent sessions:', + ...sessions.map((session, index) => this.formatSessionLine(session, index + 1)) + ].join('\n') + ] + } + } + + case 'use': { + const rawIndex = message.command?.args?.trim() + const index = Number.parseInt(rawIndex ?? '', 10) + if (!Number.isInteger(index) || index <= 0) { + return { + replies: ['Usage: /use '] + } + } + + const session = await this.deps.runner.useSessionByIndex( + endpointKey, + index - 1, + bindingMeta + ) + return { + replies: [`Now using: ${this.formatSessionLabel(session)}`] + } + } + + case 'stop': { + const stopped = await this.deps.runner.stop(endpointKey) + return { + replies: [ + stopped ? 'Stopped the active generation.' : 'There is no active generation to stop.' + ] + } + } + + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + + case 'model': + return await this.handleModelCommand(message, endpointKey) + + case 'status': { + const runtime = this.deps.getRuntimeStatus() + const status = await this.deps.runner.getStatus(endpointKey) + const defaultAgentId = await this.deps.runner.getDefaultAgentId() + const feishuConfig = this.deps.bindingStore.getFeishuConfig() + return { + replies: [ + [ + 'DeepChat Feishu Remote', + `Runtime: ${runtime.state}`, + `Default agent: ${defaultAgentId}`, + `Current session: ${status.session ? this.formatSessionLabel(status.session) : 'none'}`, + `Current agent: ${status.session?.agentId ?? 'none'}`, + `Current model: ${status.session?.modelId ?? 'none'}`, + `Generating: ${status.isGenerating ? 'yes' : 'no'}`, + `Paired users: ${feishuConfig.pairedUserOpenIds.length}`, + `Bindings: ${Object.keys(feishuConfig.bindings).length}`, + `Last error: ${runtime.lastError ?? 'none'}` + ].join('\n') + ] + } + } + + default: + break + } + + return { + replies: [], + conversation: await this.deps.runner.sendText(endpointKey, message.text, bindingMeta) + } + } catch (error) { + return { + replies: [error instanceof Error ? error.message : String(error)] + } + } + } + + private async handleModelCommand( + message: FeishuInboundMessage, + endpointKey: string + ): Promise { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const providers = await this.deps.runner.listAvailableModelProviders() + if (providers.length === 0) { + return { + replies: ['No enabled providers or models are available.'] + } + } + + const rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatModelOverview(session, providers)] + } + } + + const [providerId, ...modelParts] = rawArgs.split(/\s+/) + const modelId = modelParts.join(' ').trim() + if (!providerId || !modelId) { + return { + replies: ['Usage: /model '] + } + } + + const provider = providers.find((item) => item.providerId === providerId) + const model = provider?.models.find((item) => item.modelId === modelId) + if (!provider || !model) { + return { + replies: [ + `Model "${providerId} ${modelId}" is not enabled.\n\n${this.formatModelOverview(session, providers)}` + ] + } + } + + const updatedSession = await this.deps.runner.setSessionModel( + endpointKey, + provider.providerId, + model.modelId + ) + + return { + replies: [ + [ + 'Model updated.', + `Session: ${this.formatSessionLabel(updatedSession)}`, + `Provider: ${provider.providerName}`, + `Model: ${model.modelName}` + ].join('\n') + ] + } + } + + private formatModelOverview( + session: SessionWithState, + providers: TelegramModelProviderOption[] + ): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current model: ${session.modelId ?? 'none'}`, + 'Usage: /model ', + '', + 'Available models:', + ...providers.flatMap((provider) => [ + `${provider.providerName} (${provider.providerId})`, + ...provider.models.map( + (model) => `- ${model.modelName} (${provider.providerId} ${model.modelId})` + ) + ]) + ].join('\n') + } + + private formatStartMessage(isAuthorized: boolean): string { + if (isAuthorized) { + return [ + 'DeepChat Feishu Remote is ready.', + 'Send any message to continue the bound session, or /help for commands.' + ].join('\n') + } + + return [ + 'DeepChat Feishu Remote is online.', + 'Pair first from a direct message with /pair before using group control.' + ].join('\n') + } + + private formatHelpMessage(): string { + return [ + 'DeepChat Feishu Remote commands:', + ...FEISHU_REMOTE_COMMANDS.map((item) => `/${item.command} - ${item.description}`) + ].join('\n') + } + + private formatSessionLabel(session: Pick): string { + return `${session.title} [${session.id}]` + } + + private formatSessionLine(session: SessionWithState, index: number): string { + return `${index}. ${session.title} [${session.id}]` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts index 428e6fdc9..e7c171344 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts @@ -1,4 +1,8 @@ -import type { TelegramInboundEvent, TelegramInboundMessage } from '../types' +import { + REMOTE_PAIR_CODE_MAX_FAILURES, + type TelegramInboundEvent, + type TelegramInboundMessage +} from '../types' import { RemoteBindingStore } from './remoteBindingStore' export type RemoteAuthResult = @@ -54,19 +58,26 @@ export class RemoteAuthGuard { return 'Usage: /pair <6-digit-code>' } - const pairing = this.bindingStore.getPairingState() + const pairing = this.bindingStore.getTelegramPairingState() if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' } if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'telegram', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } return 'Pairing code is invalid.' } const userId = message.fromId as number this.bindingStore.addAllowedUser(userId) - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return `Pairing complete. Telegram user ${userId} is now authorized.` } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts index d22f5678d..d4c8ada4d 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -1,14 +1,18 @@ -import type { IConfigPresenter } from '@shared/presenter' +import type { IConfigPresenter, RemoteChannel } from '@shared/presenter' import { REMOTE_CONTROL_SETTING_KEY, TELEGRAM_MODEL_MENU_TTL_MS, - buildTelegramEndpointKey, normalizeRemoteControlConfig, createPairCode, createTelegramCallbackToken, + buildFeishuPairingSnapshot, + buildTelegramEndpointKey, buildTelegramPairingSnapshot, + type FeishuPairingState, + type FeishuRemoteRuntimeConfig, type RemoteControlConfig, - type TelegramEndpointBinding, + type RemoteEndpointBinding, + type RemoteEndpointBindingMeta, type TelegramInboundEvent, type TelegramModelMenuState, type TelegramPairingState, @@ -28,8 +32,20 @@ export class RemoteBindingStore { ) } + getChannelConfig(channel: 'telegram'): TelegramRemoteRuntimeConfig + getChannelConfig(channel: 'feishu'): FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel): TelegramRemoteRuntimeConfig | FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel) { + const config = this.getConfig() + return config[channel] + } + getTelegramConfig(): TelegramRemoteRuntimeConfig { - return this.getConfig().telegram + return this.getChannelConfig('telegram') + } + + getFeishuConfig(): FeishuRemoteRuntimeConfig { + return this.getChannelConfig('feishu') } updateTelegramConfig( @@ -44,25 +60,55 @@ export class RemoteBindingStore { return next.telegram } + updateFeishuConfig( + updater: (config: FeishuRemoteRuntimeConfig) => FeishuRemoteRuntimeConfig + ): FeishuRemoteRuntimeConfig { + const current = this.getConfig() + const next = normalizeRemoteControlConfig({ + ...current, + feishu: updater(current.feishu) + }) + this.configPresenter.setSetting(REMOTE_CONTROL_SETTING_KEY, next) + return next.feishu + } + getEndpointKey( target: { chatId: number; messageThreadId?: number } | TelegramInboundEvent ): string { return buildTelegramEndpointKey(target.chatId, target.messageThreadId ?? 0) } - getBinding(endpointKey: string): TelegramEndpointBinding | null { - return this.getTelegramConfig().bindings[endpointKey] ?? null + getBinding(endpointKey: string): RemoteEndpointBinding | null { + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return null + } + + return this.getChannelBindings(channel)[endpointKey] ?? null } - setBinding(endpointKey: string, sessionId: string): void { - this.updateTelegramConfig((config) => ({ - ...config, - bindings: { - ...config.bindings, - [endpointKey]: { - sessionId, - updatedAt: Date.now() - } + setBinding(endpointKey: string, sessionId: string, meta?: RemoteEndpointBindingMeta): void { + const resolvedChannel = this.resolveChannelFromEndpointKey(endpointKey) + if (!resolvedChannel) { + return + } + + this.updateBindings(resolvedChannel, (bindings) => ({ + ...bindings, + [endpointKey]: { + sessionId, + updatedAt: Date.now(), + meta: meta + ? { + ...meta, + channel: resolvedChannel + } + : bindings[endpointKey]?.meta + ? { + ...bindings[endpointKey].meta, + channel: resolvedChannel + } + : undefined } })) this.activeEvents.delete(endpointKey) @@ -70,43 +116,75 @@ export class RemoteBindingStore { } clearBinding(endpointKey: string): void { - this.updateTelegramConfig((config) => { - const bindings = { ...config.bindings } - delete bindings[endpointKey] - return { - ...config, - bindings - } + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return + } + + this.updateBindings(channel, (bindings) => { + const nextBindings = { ...bindings } + delete nextBindings[endpointKey] + return nextBindings }) - this.activeEvents.delete(endpointKey) - this.sessionSnapshots.delete(endpointKey) - this.clearModelMenuStatesForEndpoint(endpointKey) + this.clearTransientStateForEndpoint(endpointKey) } - listBindings(): Array<{ + listBindings(channel?: RemoteChannel): Array<{ endpointKey: string - binding: TelegramEndpointBinding + binding: RemoteEndpointBinding }> { - return Object.entries(this.getTelegramConfig().bindings).map(([endpointKey, binding]) => ({ - endpointKey, - binding - })) + const configs = + channel === undefined + ? (['telegram', 'feishu'] as const).map( + (key) => [key, this.getChannelBindings(key)] as const + ) + : ([[channel, this.getChannelBindings(channel)]] as const) + + return configs.flatMap((entry) => { + const bindings = entry[1] + return Object.entries(bindings).map(([endpointKey, binding]) => ({ + endpointKey, + binding + })) + }) } - clearBindings(): number { - const count = Object.keys(this.getTelegramConfig().bindings).length - this.updateTelegramConfig((config) => ({ - ...config, - bindings: {} - })) - this.activeEvents.clear() - this.sessionSnapshots.clear() - this.modelMenuStates.clear() - return count + clearBindings(channel?: RemoteChannel): number { + const entries = this.listBindings(channel) + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + } else if (channel === 'feishu') { + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } else { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } + + for (const { endpointKey } of entries) { + this.clearTransientStateForEndpoint(endpointKey) + } + + if (channel === undefined) { + this.modelMenuStates.clear() + } + + return entries.length } - countBindings(): number { - return Object.keys(this.getTelegramConfig().bindings).length + countBindings(channel?: RemoteChannel): number { + return this.listBindings(channel).length } getPollOffset(): number { @@ -124,10 +202,18 @@ export class RemoteBindingStore { return this.getTelegramConfig().allowlist } - getDefaultAgentId(): string { + getTelegramDefaultAgentId(): string { return this.getTelegramConfig().defaultAgentId } + getDefaultAgentId(): string { + return this.getTelegramDefaultAgentId() + } + + getFeishuDefaultAgentId(): string { + return this.getFeishuConfig().defaultAgentId + } + isAllowedUser(userId: number | null | undefined): boolean { if (!userId) { return false @@ -144,33 +230,153 @@ export class RemoteBindingStore { })) } - getPairingState(): TelegramPairingState { + getFeishuPairedUserOpenIds(): string[] { + return this.getFeishuConfig().pairedUserOpenIds + } + + isFeishuPairedUser(openId: string | null | undefined): boolean { + if (!openId) { + return false + } + return this.getFeishuPairedUserOpenIds().includes(openId.trim()) + } + + addFeishuPairedUser(openId: string): void { + const normalized = openId.trim() + if (!normalized) { + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + pairedUserOpenIds: Array.from(new Set([...config.pairedUserOpenIds, normalized])).sort( + (left, right) => left.localeCompare(right) + ) + })) + } + + getTelegramPairingState(): TelegramPairingState { return this.getTelegramConfig().pairing } - getPairingSnapshot() { + getPairingState(): TelegramPairingState { + return this.getTelegramPairingState() + } + + getFeishuPairingState(): FeishuPairingState { + return this.getFeishuConfig().pairing + } + + getTelegramPairingSnapshot() { return buildTelegramPairingSnapshot(this.getTelegramConfig()) } - createPairCode(): { code: string; expiresAt: number } { + getFeishuPairingSnapshot() { + return buildFeishuPairingSnapshot(this.getFeishuConfig()) + } + + createPairCode(channel: RemoteChannel = 'telegram'): { code: string; expiresAt: number } { const pairing = createPairCode() - this.updateTelegramConfig((config) => ({ - ...config, - pairing - })) - return pairing + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing + })) + } else { + this.updateFeishuConfig((config) => ({ + ...config, + pairing + })) + } + return { + code: pairing.code!, + expiresAt: pairing.expiresAt! + } } - clearPairCode(): void { - this.updateTelegramConfig((config) => ({ + clearPairCode(channel: RemoteChannel = 'telegram'): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 + } + })) + return + } + + this.updateFeishuConfig((config) => ({ ...config, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 } })) } + recordPairCodeFailure( + channel: RemoteChannel, + maxAttempts: number + ): { attempts: number; exhausted: boolean } { + let result = { + attempts: 0, + exhausted: false + } + + if (channel === 'telegram') { + this.updateTelegramConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } else { + this.updateFeishuConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } + + return result + } + rememberActiveEvent(endpointKey: string, eventId: string): void { this.activeEvents.set(endpointKey, eventId) } @@ -236,6 +442,47 @@ export class RemoteBindingStore { this.modelMenuStates.delete(token) } + private getChannelBindings(channel: RemoteChannel): Record { + const config = this.getChannelConfig(channel) + return config.bindings + } + + private updateBindings( + channel: RemoteChannel, + updater: ( + bindings: Record + ) => Record + ): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + } + + private resolveChannelFromEndpointKey(endpointKey: string): RemoteChannel | null { + if (endpointKey.startsWith('telegram:')) { + return 'telegram' + } + if (endpointKey.startsWith('feishu:')) { + return 'feishu' + } + return null + } + + private clearTransientStateForEndpoint(endpointKey: string): void { + this.activeEvents.delete(endpointKey) + this.sessionSnapshots.delete(endpointKey) + this.clearModelMenuStatesForEndpoint(endpointKey) + } + private clearExpiredModelMenuStates(): void { const now = Date.now() for (const [token, state] of this.modelMenuStates.entries()) { diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts index 1f17ac06b..1d424c51e 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -10,6 +10,7 @@ import type { } from '../types' import { TELEGRAM_MODEL_MENU_TTL_MS, + TELEGRAM_REMOTE_COMMANDS, buildModelMenuBackCallbackData, buildModelMenuCancelCallbackData, buildModelMenuChoiceCallbackData, @@ -130,6 +131,19 @@ export class RemoteCommandRouter { } } + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + case 'model': { const session = await this.deps.runner.getCurrentSession(endpointKey) if (!session) { @@ -426,15 +440,15 @@ export class RemoteCommandRouter { private formatHelpMessage(): string { return [ 'Commands:', - '/start', - '/help', - '/pair ', - '/new [title]', - '/sessions', - '/use ', - '/stop', - '/status', - '/model', + ...TELEGRAM_REMOTE_COMMANDS.map((item) => + item.command === 'pair' + ? '/pair - Authorize this Telegram account' + : item.command === 'new' + ? '/new [title] - Start a new DeepChat session' + : item.command === 'use' + ? '/use - Bind a listed session' + : `/${item.command} - ${item.description}` + ), 'Plain text sends to the current bound session.' ].join('\n') } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index c5e23b94a..1a1ae6b8a 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -10,11 +10,12 @@ import type { DeepChatAgentPresenter } from '../../deepchatAgentPresenter' import { TELEGRAM_RECENT_SESSION_LIMIT, TELEGRAM_STREAM_POLL_INTERVAL_MS, + type RemoteEndpointBindingMeta, type TelegramModelProviderOption } from '../types' import { buildTelegramFinalText, - extractTelegramStreamText, + extractTelegramDraftText, safeParseAssistantBlocks } from '../telegram/telegramOutbound' import { RemoteBindingStore } from './remoteBindingStore' @@ -41,6 +42,18 @@ export interface RemoteRunnerStatus { isGenerating: boolean } +export type RemoteOpenSessionResult = + | { + status: 'noSession' + } + | { + status: 'windowNotFound' + } + | { + status: 'ok' + session: SessionWithState + } + type RemoteConversationRunnerDeps = { configPresenter: IConfigPresenter newAgentPresenter: INewAgentPresenter @@ -60,13 +73,21 @@ export class RemoteConversationRunner { private readonly bindingStore: RemoteBindingStore ) {} - async createNewSession(endpointKey: string, title?: string): Promise { + async createNewSession( + endpointKey: string, + title?: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const agentId = await this.deps.resolveDefaultAgentId() const session = await this.deps.newAgentPresenter.createDetachedSession({ title: title?.trim() || 'New Chat', agentId }) - this.bindingStore.setBinding(endpointKey, session.id) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -85,13 +106,16 @@ export class RemoteConversationRunner { return session } - async ensureBoundSession(endpointKey: string): Promise { + async ensureBoundSession( + endpointKey: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const existing = await this.getCurrentSession(endpointKey) if (existing) { return existing } - return await this.createNewSession(endpointKey) + return await this.createNewSession(endpointKey, undefined, bindingMeta) } async listSessions(endpointKey: string): Promise { @@ -109,7 +133,11 @@ export class RemoteConversationRunner { return sorted } - async useSessionByIndex(endpointKey: string, index: number): Promise { + async useSessionByIndex( + endpointKey: string, + index: number, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const snapshot = this.bindingStore.getSessionSnapshot(endpointKey) if (snapshot.length === 0) { throw new Error('Run /sessions first before using /use.') @@ -125,7 +153,11 @@ export class RemoteConversationRunner { throw new Error('Selected session no longer exists.') } - this.bindingStore.setBinding(endpointKey, session.id) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -161,8 +193,12 @@ export class RemoteConversationRunner { return await this.deps.newAgentPresenter.setSessionModel(session.id, providerId, modelId) } - async sendText(endpointKey: string, text: string): Promise { - const session = await this.ensureBoundSession(endpointKey) + async sendText( + endpointKey: string, + text: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { + const session = await this.ensureBoundSession(endpointKey, bindingMeta) const beforeMessages = await this.deps.newAgentPresenter.getMessages(session.id) const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0 const previousActiveEventId = @@ -214,20 +250,27 @@ export class RemoteConversationRunner { return stopped } - async open(endpointKey: string): Promise { + async open(endpointKey: string): Promise { const session = await this.getCurrentSession(endpointKey) if (!session) { - return null + return { + status: 'noSession' + } } const window = await this.resolveChatWindow() if (!window || window.isDestroyed()) { - return null + return { + status: 'windowNotFound' + } } await this.deps.newAgentPresenter.activateSession(window.webContents.id, session.id) this.deps.windowPresenter.show(window.id, true) - return session + return { + status: 'ok', + session + } } async getStatus(endpointKey: string): Promise { @@ -315,7 +358,7 @@ export class RemoteConversationRunner { return { messageId: trackedMessage.id, - text: completed ? buildTelegramFinalText(blocks) : extractTelegramStreamText(blocks), + text: completed ? buildTelegramFinalText(blocks) : extractTelegramDraftText(blocks), completed } } diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts index a68da993d..3ef44ed3d 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts @@ -44,22 +44,39 @@ export const blocksRequireDesktopConfirmation = (blocks: AssistantMessageBlock[] block.extra?.needsUserAction !== false ) -export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { - const preferred = blocks - .filter((block) => block.type === 'content' && typeof block.content === 'string') +const collectText = ( + blocks: AssistantMessageBlock[], + predicate: (block: AssistantMessageBlock) => boolean +): string => + blocks + .filter(predicate) .map((block) => block.content?.trim() ?? '') .filter(Boolean) + .join('\n\n') + .trim() + +export const extractTelegramDraftText = (blocks: AssistantMessageBlock[]): string => + collectText(blocks, (block) => block.type === 'content' && typeof block.content === 'string') + +export const shouldSendTelegramDraft = (blocks: AssistantMessageBlock[]): boolean => + Boolean(extractTelegramDraftText(blocks)) + +export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { + const preferred = extractTelegramDraftText(blocks) - if (preferred.length > 0) { - return preferred.join('\n\n').trim() + if (preferred) { + return preferred } - return blocks - .filter((block) => block.type !== 'tool_call' && typeof block.content === 'string') - .map((block) => block.content?.trim() ?? '') - .filter(Boolean) - .join('\n\n') - .trim() + return collectText( + blocks, + (block) => + typeof block.content === 'string' && + (block.type === 'content' || + (block.type === 'action' && + (block.action_type === 'tool_call_permission' || + block.action_type === 'question_request'))) + ) } export const buildTelegramFinalText = (blocks: AssistantMessageBlock[]): string => { diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index 0ed146f28..3d2046211 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -1,9 +1,15 @@ import { z } from 'zod' import type { HookEventName } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingKind, + RemoteBindingSummary, + RemoteChannel, + RemoteRuntimeState, TelegramPairingSnapshot, TelegramRemoteBindingSummary, - TelegramRemoteRuntimeState, TelegramRemoteSettings, TelegramRemoteStatus, TelegramStreamMode @@ -14,6 +20,11 @@ export const TELEGRAM_REMOTE_POLL_LIMIT = 20 export const TELEGRAM_REMOTE_POLL_TIMEOUT_SEC = 30 export const TELEGRAM_OUTBOUND_TEXT_LIMIT = 4096 export const TELEGRAM_PAIR_CODE_TTL_MS = 10 * 60 * 1000 +export const FEISHU_PAIR_CODE_TTL_MS = TELEGRAM_PAIR_CODE_TTL_MS +export const REMOTE_PAIR_CODE_MAX_FAILURES = 5 +export const FEISHU_INBOUND_DEDUP_TTL_MS = 30 * 60 * 1000 +export const FEISHU_INBOUND_DEDUP_LIMIT = 2048 +export const FEISHU_CONVERSATION_POLL_TIMEOUT_MS = 5 * 60 * 1000 export const TELEGRAM_TYPING_DELAY_MS = 800 export const TELEGRAM_STREAM_POLL_INTERVAL_MS = 450 export const TELEGRAM_STREAM_START_TIMEOUT_MS = 8_000 @@ -21,6 +32,7 @@ export const TELEGRAM_PRIVATE_THREAD_DEFAULT = 0 export const TELEGRAM_RECENT_SESSION_LIMIT = 10 export const TELEGRAM_MODEL_MENU_TTL_MS = 10 * 60 * 1000 export const TELEGRAM_REMOTE_DEFAULT_AGENT_ID = 'deepchat' +export const FEISHU_REMOTE_DEFAULT_AGENT_ID = TELEGRAM_REMOTE_DEFAULT_AGENT_ID export const TELEGRAM_REMOTE_REACTION_EMOJI = '🤯' export const TELEGRAM_REMOTE_COMMANDS = [ { @@ -51,6 +63,10 @@ export const TELEGRAM_REMOTE_COMMANDS = [ command: 'stop', description: 'Stop the active generation' }, + { + command: 'open', + description: 'Open the current session on desktop' + }, { command: 'model', description: 'Switch provider and model' @@ -61,16 +77,72 @@ export const TELEGRAM_REMOTE_COMMANDS = [ } ] as const -export type TelegramEndpointBinding = { +export const FEISHU_REMOTE_COMMANDS = [ + { + command: 'start', + description: 'Show remote control status' + }, + { + command: 'help', + description: 'Show available commands' + }, + { + command: 'pair', + description: 'Authorize this Feishu account' + }, + { + command: 'new', + description: 'Start a new DeepChat session' + }, + { + command: 'sessions', + description: 'List recent sessions' + }, + { + command: 'use', + description: 'Bind a listed session' + }, + { + command: 'stop', + description: 'Stop the active generation' + }, + { + command: 'open', + description: 'Open the current session on desktop' + }, + { + command: 'model', + description: 'View or switch the current model' + }, + { + command: 'status', + description: 'Show runtime and session status' + } +] as const + +export interface RemoteEndpointBindingMeta { + channel: RemoteChannel + kind: RemoteBindingKind + chatId: string + threadId: string | null +} + +export type RemoteEndpointBinding = { sessionId: string updatedAt: number + meta?: RemoteEndpointBindingMeta } +export type TelegramEndpointBinding = RemoteEndpointBinding + export type TelegramPairingState = { code: string | null expiresAt: number | null + failedAttempts: number } +export type FeishuPairingState = TelegramPairingState + export type TelegramCommandPayload = { name: string args: string @@ -87,8 +159,22 @@ export interface TelegramRemoteRuntimeConfig { bindings: Record } +export interface FeishuRemoteRuntimeConfig { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + enabled: boolean + defaultAgentId: string + pairedUserOpenIds: string[] + lastFatalError: string | null + pairing: FeishuPairingState + bindings: Record +} + export interface RemoteControlConfig { telegram: TelegramRemoteRuntimeConfig + feishu: FeishuRemoteRuntimeConfig } interface TelegramInboundBase { @@ -114,6 +200,28 @@ export interface TelegramInboundCallbackQuery extends TelegramInboundBase { export type TelegramInboundEvent = TelegramInboundMessage | TelegramInboundCallbackQuery +export interface FeishuRawMention { + key: string + id?: { + open_id?: string + } + name?: string +} + +export interface FeishuInboundMessage { + kind: 'message' + eventId: string + chatId: string + threadId: string | null + messageId: string + chatType: 'p2p' | 'group' + senderOpenId: string | null + text: string + command: TelegramCommandPayload | null + mentionedBot: boolean + mentions: FeishuRawMention[] +} + export interface TelegramInlineKeyboardButton { text: string callback_data: string @@ -177,6 +285,8 @@ export type TelegramModelMenuCallback = } const TELEGRAM_MODEL_MENU_CALLBACK_PREFIX = 'model' +const TELEGRAM_ENDPOINT_KEY_REGEX = /^telegram:(-?\d+):(-?\d+)$/ +const FEISHU_ENDPOINT_KEY_REGEX = /^feishu:([^:]+):([^:]+)$/ export const createTelegramCallbackToken = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` @@ -251,16 +361,28 @@ export const parseModelMenuCallbackData = (data: string): TelegramModelMenuCallb } export interface TelegramPollerStatusSnapshot { - state: TelegramRemoteRuntimeState + state: RemoteRuntimeState lastError: string | null botUser: TelegramRemoteStatus['botUser'] } +export interface FeishuRuntimeStatusSnapshot { + state: RemoteRuntimeState + lastError: string | null + botUser: FeishuRemoteStatus['botUser'] +} + export interface TelegramTransportTarget { chatId: number messageThreadId: number } +export interface FeishuTransportTarget { + chatId: string + threadId: string | null + replyToMessageId?: string | null +} + export interface TelegramRemoteHookSettingsInput { enabled: boolean chatId: string @@ -278,23 +400,51 @@ export const createDefaultRemoteControlConfig = (): RemoteControlConfig => ({ lastFatalError: null, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 + }, + bindings: {} + }, + feishu: { + appId: '', + appSecret: '', + verificationToken: '', + encryptKey: '', + enabled: false, + defaultAgentId: FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: [], + lastFatalError: null, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 }, bindings: {} } }) -const TelegramEndpointBindingSchema = z +const RemoteEndpointBindingMetaSchema = z + .object({ + channel: z.enum(['telegram', 'feishu']).optional(), + kind: z.enum(['dm', 'group', 'topic']).optional(), + chatId: z.string().optional(), + threadId: z.string().nullable().optional() + }) + .strip() + +const RemoteEndpointBindingSchema = z .object({ sessionId: z.string().min(1), - updatedAt: z.number().int().nonnegative().optional() + updatedAt: z.number().int().nonnegative().optional(), + meta: RemoteEndpointBindingMetaSchema.optional() }) .strip() -const TelegramPairingStateSchema = z +const PairingStateSchema = z .object({ code: z.string().nullable().optional(), - expiresAt: z.number().int().nonnegative().nullable().optional() + expiresAt: z.number().int().nonnegative().nullable().optional(), + failedAttempts: z.number().int().nonnegative().optional() }) .strip() @@ -306,17 +456,97 @@ const TelegramRemoteRuntimeConfigSchema = z streamMode: z.enum(['draft', 'final']).optional(), pollOffset: z.number().int().nonnegative().optional(), lastFatalError: z.string().nullable().optional(), - pairing: TelegramPairingStateSchema.optional(), + pairing: PairingStateSchema.optional(), + bindings: z.record(z.string(), z.unknown()).optional() + }) + .strip() + +const FeishuRemoteRuntimeConfigSchema = z + .object({ + appId: z.string().optional(), + appSecret: z.string().optional(), + verificationToken: z.string().optional(), + encryptKey: z.string().optional(), + enabled: z.boolean().optional(), + defaultAgentId: z.string().optional(), + pairedUserOpenIds: z.array(z.string()).optional(), + lastFatalError: z.string().nullable().optional(), + pairing: PairingStateSchema.optional(), bindings: z.record(z.string(), z.unknown()).optional() }) .strip() const RemoteControlConfigSchema = z .object({ - telegram: TelegramRemoteRuntimeConfigSchema.optional() + telegram: TelegramRemoteRuntimeConfigSchema.optional(), + feishu: FeishuRemoteRuntimeConfigSchema.optional() }) .strip() +type LegacyTelegramRemoteConfig = z.infer +type LegacyFeishuRemoteConfig = z.infer + +const hasOwn = (value: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key) + +const hasAnyOwn = (value: Record, keys: string[]): boolean => + keys.some((key) => hasOwn(value, key)) + +const hasBindingPrefix = (value: Record, prefix: string): boolean => { + const bindings = value.bindings + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) { + return false + } + + return Object.keys(bindings as Record).some((key) => key.startsWith(prefix)) +} + +const extractLegacyTelegramConfig = (input: unknown): LegacyTelegramRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, ['allowlist', 'streamMode', 'pollOffset', 'lastFatalError']) && + !hasBindingPrefix(record, 'telegram:') + ) { + return null + } + + const parsed = TelegramRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const extractLegacyFeishuConfig = (input: unknown): LegacyFeishuRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, [ + 'appId', + 'appSecret', + 'verificationToken', + 'encryptKey', + 'pairedUserOpenIds', + 'lastFatalError' + ]) && + !hasBindingPrefix(record, 'feishu:') + ) { + return null + } + + const parsed = FeishuRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const normalizeStringList = (input: Array | undefined): string[] => + Array.from( + new Set((input ?? []).map((value) => String(value ?? '').trim()).filter(Boolean)) + ).sort((left, right) => left.localeCompare(right)) + export const normalizeTelegramUserIds = (input: Array | undefined): number[] => { const normalized = new Set() for (const value of input ?? []) { @@ -333,17 +563,38 @@ export const normalizeTelegramUserIds = (input: Array | undefin return Array.from(normalized).sort((left, right) => left - right) } -export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { - const defaults = createDefaultRemoteControlConfig() - const parsed = RemoteControlConfigSchema.safeParse(input) - if (!parsed.success) { - return defaults +export const normalizeFeishuOpenIds = (input: Array | undefined): string[] => + normalizeStringList(input) + +const normalizeBindingMeta = ( + endpointKey: string, + meta: unknown, + fallbackChannel: RemoteChannel +): RemoteEndpointBindingMeta | undefined => { + const parsed = RemoteEndpointBindingMetaSchema.safeParse(meta) + if (parsed.success && parsed.data.channel && parsed.data.kind && parsed.data.chatId) { + return { + channel: parsed.data.channel, + kind: parsed.data.kind, + chatId: parsed.data.chatId, + threadId: parsed.data.threadId ?? null + } + } + + if (fallbackChannel === 'telegram') { + return deriveTelegramBindingMeta(endpointKey) ?? undefined } - const telegram = parsed.data.telegram ?? {} - const bindings: Record = {} - for (const [endpointKey, binding] of Object.entries(telegram.bindings ?? {})) { - const parsedBinding = TelegramEndpointBindingSchema.safeParse(binding) + return deriveFeishuBindingMeta(endpointKey) ?? undefined +} + +const normalizeBindings = ( + rawBindings: Record | undefined, + channel: RemoteChannel +): Record => { + const bindings: Record = {} + for (const [endpointKey, binding] of Object.entries(rawBindings ?? {})) { + const parsedBinding = RemoteEndpointBindingSchema.safeParse(binding) if (!parsedBinding.success) { continue } @@ -355,15 +606,28 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi bindings[endpointKey] = { sessionId: normalizedSessionId, - updatedAt: parsedBinding.data.updatedAt ?? Date.now() + updatedAt: parsedBinding.data.updatedAt ?? Date.now(), + meta: normalizeBindingMeta(endpointKey, parsedBinding.data.meta, channel) } } + return bindings +} + +export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { + const defaults = createDefaultRemoteControlConfig() + const parsed = RemoteControlConfigSchema.safeParse(input) + if (!parsed.success) { + return defaults + } + + const telegram = parsed.data.telegram ?? extractLegacyTelegramConfig(input) ?? {} + const feishu = parsed.data.feishu ?? extractLegacyFeishuConfig(input) ?? {} return { telegram: { enabled: Boolean(telegram.enabled), allowlist: normalizeTelegramUserIds(telegram.allowlist), - streamMode: 'draft', + streamMode: telegram.streamMode === 'final' ? 'final' : defaults.telegram.streamMode, defaultAgentId: telegram.defaultAgentId?.trim() || defaults.telegram.defaultAgentId, pollOffset: typeof telegram.pollOffset === 'number' && telegram.pollOffset >= 0 @@ -373,9 +637,33 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi pairing: { code: telegram.pairing?.code?.trim() || null, expiresAt: - typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null + typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null, + failedAttempts: + typeof telegram.pairing?.failedAttempts === 'number' && + telegram.pairing.failedAttempts >= 0 + ? Math.trunc(telegram.pairing.failedAttempts) + : 0 }, - bindings + bindings: normalizeBindings(telegram.bindings, 'telegram') + }, + feishu: { + appId: feishu.appId?.trim() || '', + appSecret: feishu.appSecret?.trim() || '', + verificationToken: feishu.verificationToken?.trim() || '', + encryptKey: feishu.encryptKey?.trim() || '', + enabled: Boolean(feishu.enabled), + defaultAgentId: feishu.defaultAgentId?.trim() || defaults.feishu.defaultAgentId, + pairedUserOpenIds: normalizeFeishuOpenIds(feishu.pairedUserOpenIds), + lastFatalError: feishu.lastFatalError?.trim() || null, + pairing: { + code: feishu.pairing?.code?.trim() || null, + expiresAt: typeof feishu.pairing?.expiresAt === 'number' ? feishu.pairing.expiresAt : null, + failedAttempts: + typeof feishu.pairing?.failedAttempts === 'number' && feishu.pairing.failedAttempts >= 0 + ? Math.trunc(feishu.pairing.failedAttempts) + : 0 + }, + bindings: normalizeBindings(feishu.bindings, 'feishu') } } } @@ -386,7 +674,7 @@ export const buildTelegramEndpointKey = (chatId: number, messageThreadId: number export const parseTelegramEndpointKey = ( endpointKey: string ): Pick | null => { - const match = /^telegram:(-?\d+):(-?\d+)$/.exec(endpointKey.trim()) + const match = TELEGRAM_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) if (!match) { return null } @@ -397,11 +685,101 @@ export const parseTelegramEndpointKey = ( } } -export const createPairCode = (): { code: string; expiresAt: number } => { - const code = `${Math.floor(100000 + Math.random() * 900000)}` +export const buildTelegramBindingMeta = ( + chatId: number, + messageThreadId: number +): RemoteEndpointBindingMeta => { + const normalizedThreadId = messageThreadId || TELEGRAM_PRIVATE_THREAD_DEFAULT + const isTopic = normalizedThreadId > 0 + const isGroup = chatId < 0 return { - code, - expiresAt: Date.now() + TELEGRAM_PAIR_CODE_TTL_MS + channel: 'telegram', + kind: isTopic ? 'topic' : isGroup ? 'group' : 'dm', + chatId: String(chatId), + threadId: isTopic ? String(normalizedThreadId) : null + } +} + +export const deriveTelegramBindingMeta = ( + endpointKey: string +): RemoteEndpointBindingMeta | null => { + const endpoint = parseTelegramEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return buildTelegramBindingMeta(endpoint.chatId, endpoint.messageThreadId) +} + +export const buildFeishuEndpointKey = (chatId: string, threadId?: string | null): string => + `feishu:${chatId}:${threadId?.trim() || 'root'}` + +export const parseFeishuEndpointKey = ( + endpointKey: string +): Pick | null => { + const match = FEISHU_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) + if (!match) { + return null + } + + return { + chatId: match[1], + threadId: match[2] === 'root' ? null : match[2] + } +} + +export const buildFeishuBindingMeta = (params: { + chatId: string + threadId?: string | null + chatType: 'p2p' | 'group' +}): RemoteEndpointBindingMeta => ({ + channel: 'feishu', + kind: params.chatType === 'p2p' ? 'dm' : params.threadId ? 'topic' : 'group', + chatId: params.chatId.trim(), + threadId: params.threadId?.trim() || null +}) + +export const deriveFeishuBindingMeta = (endpointKey: string): RemoteEndpointBindingMeta | null => { + const endpoint = parseFeishuEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return { + channel: 'feishu', + kind: endpoint.threadId ? 'topic' : 'group', + chatId: endpoint.chatId, + threadId: endpoint.threadId + } +} + +export const buildBindingSummary = ( + endpointKey: string, + binding: RemoteEndpointBinding +): RemoteBindingSummary | null => { + const meta = + binding.meta ?? deriveTelegramBindingMeta(endpointKey) ?? deriveFeishuBindingMeta(endpointKey) + + if (!meta) { + return null + } + + return { + channel: meta.channel, + endpointKey, + sessionId: binding.sessionId, + chatId: meta.chatId, + threadId: meta.threadId, + kind: meta.kind, + updatedAt: binding.updatedAt + } +} + +export const createPairCode = (ttlMs: number = TELEGRAM_PAIR_CODE_TTL_MS): TelegramPairingState => { + return { + code: `${Math.floor(100000 + Math.random() * 900000)}`, + failedAttempts: 0, + expiresAt: Date.now() + ttlMs } } @@ -420,6 +798,18 @@ export const normalizeTelegramSettingsInput = ( } }) +export const normalizeFeishuSettingsInput = ( + input: FeishuRemoteSettings +): FeishuRemoteSettings => ({ + appId: input.appId?.trim() ?? '', + appSecret: input.appSecret?.trim() ?? '', + verificationToken: input.verificationToken?.trim() ?? '', + encryptKey: input.encryptKey?.trim() ?? '', + remoteEnabled: Boolean(input.remoteEnabled), + defaultAgentId: input.defaultAgentId?.trim() || FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: normalizeFeishuOpenIds(input.pairedUserOpenIds) +}) + export const buildTelegramPairingSnapshot = ( settings: TelegramRemoteRuntimeConfig ): TelegramPairingSnapshot => ({ @@ -427,3 +817,11 @@ export const buildTelegramPairingSnapshot = ( pairCodeExpiresAt: settings.pairing.expiresAt, allowedUserIds: [...settings.allowlist] }) + +export const buildFeishuPairingSnapshot = ( + settings: FeishuRemoteRuntimeConfig +): FeishuPairingSnapshot => ({ + pairCode: settings.pairing.code, + pairCodeExpiresAt: settings.pairing.expiresAt, + pairedUserOpenIds: [...settings.pairedUserOpenIds] +}) diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 9d29d63eb..71dcf250c 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -461,10 +461,13 @@ export class ToolPresenter implements IToolPresenter { lines.push('- Use `get_browser_status` to inspect the current session browser state.') } if (toolNames.has('load_url')) { - lines.push('- Use `load_url` to lazily create the session browser and navigate to a page.') + lines.push('- Prefer `load_url` to create the session browser and handle navigation.') } if (toolNames.has('cdp_send')) { - lines.push('- Use `cdp_send` for DOM inspection, scripted interaction, and screenshots.') + lines.push( + '- Use `cdp_send` for DOM inspection, scripted interaction, screenshots, and low-level CDP commands.' + ) + lines.push('- Avoid using `cdp_send` `Page.navigate` for normal navigation unless needed.') } return lines.join('\n') diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue index 73d606dd6..62ac6c261 100644 --- a/src/renderer/settings/components/RemoteSettings.vue +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -4,14 +4,17 @@
{{ t('common.loading') }}
-
+
{{ t('common.error.requestFailed') }}
@@ -296,9 +546,19 @@
- {{ t('settings.remote.remoteControl.pairDialogTitle') }} + + {{ + t('settings.remote.remoteControl.pairDialogTitle', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} + - {{ t('settings.remote.remoteControl.pairDialogDescription') }} + {{ + t('settings.remote.remoteControl.pairDialogDescription', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} @@ -321,7 +581,11 @@
- {{ t('settings.remote.remoteControl.pairDialogInstruction') }} + {{ + pairDialogChannel === 'feishu' + ? t('settings.remote.remoteControl.pairDialogInstructionFeishu') + : t('settings.remote.remoteControl.pairDialogInstructionTelegram') + }}
/pair {{ pairDialogCode || '------' }} @@ -342,9 +606,23 @@
- {{ t('settings.remote.remoteControl.bindingsDialogTitle') }} + + {{ + t('settings.remote.remoteControl.bindingsDialogTitle', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} + - {{ t('settings.remote.remoteControl.bindingsDialogDescription') }} + {{ + t('settings.remote.remoteControl.bindingsDialogDescription', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} @@ -367,9 +645,20 @@ class="flex items-center justify-between gap-3 rounded-lg border p-3" >
-
{{ binding.sessionId }}
+
+
{{ binding.sessionId }}
+ + {{ t(`settings.remote.bindingKinds.${binding.kind}`) }} + +
- telegram:{{ binding.chatId }}:{{ binding.messageThreadId }} + {{ binding.channel }}:{{ binding.chatId + }}{{ binding.threadId ? `:${binding.threadId}` : '' }}
' - }) - const WindowSideBarSessionItem = (await import('@/components/WindowSideBarSessionItem.vue')) .default const wrapper = mount(WindowSideBarSessionItem, { props: { - session: createSession(isPinned), - active: false + session: createSession(options), + active: false, + region: options?.isPinned ? 'pinned' : 'grouped', + heroHidden: options?.heroHidden ?? false, + pinFeedbackMode: options?.pinFeedbackMode ?? null }, global: { stubs: { - ContextMenu: passthrough, - ContextMenuTrigger: passthrough, - ContextMenuContent: passthrough, - ContextMenuSeparator: passthrough, - ContextMenuItem: contextMenuItemStub, Icon: true } } @@ -61,42 +57,63 @@ describe('WindowSideBarSessionItem', () => { it('emits select when the list item is clicked', async () => { const wrapper = await mountComponent() - await wrapper.find('button').trigger('click') + await wrapper.find('.session-item').trigger('click') expect(wrapper.emitted('select')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) }, 10000) it('renders the correct pin action label for pinned and unpinned sessions', async () => { - const unpinnedWrapper = await mountComponent(false) - const pinnedWrapper = await mountComponent(true) + const unpinnedWrapper = await mountComponent({ isPinned: false }) + const pinnedWrapper = await mountComponent({ isPinned: true }) - expect(unpinnedWrapper.text()).toContain('thread.actions.pin') - expect(pinnedWrapper.text()).toContain('thread.actions.unpin') + const unpinnedPinButton = unpinnedWrapper.find('[aria-label="thread.actions.pin"]') + const pinnedPinButton = pinnedWrapper.find('[aria-label="thread.actions.unpin"]') + + expect(unpinnedPinButton.exists()).toBe(true) + expect(unpinnedPinButton.attributes('aria-pressed')).toBe('false') + expect(pinnedPinButton.exists()).toBe(true) + expect(pinnedPinButton.attributes('aria-pressed')).toBe('true') }, 10000) - it('emits context menu actions with the session payload', async () => { + it('emits toggle-pin and delete with the session payload', async () => { const wrapper = await mountComponent() - const menuButtons = wrapper.findAll('button') - const renameButton = menuButtons.find((button) => - button.text().includes('thread.actions.rename') - ) - const clearButton = menuButtons.find((button) => - button.text().includes('thread.actions.cleanMessages') - ) - const deleteButton = menuButtons.find((button) => - button.text().includes('thread.actions.delete') - ) - - expect(renameButton).toBeTruthy() - expect(clearButton).toBeTruthy() - expect(deleteButton).toBeTruthy() - - await renameButton!.trigger('click') - await clearButton!.trigger('click') - await deleteButton!.trigger('click') - - expect(wrapper.emitted('rename')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) - expect(wrapper.emitted('clear')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) + + await wrapper.find('[aria-label="thread.actions.pin"]').trigger('click') + await wrapper.find('[aria-label="thread.actions.delete"]').trigger('click') + + expect(wrapper.emitted('toggle-pin')?.[0]).toEqual([ + expect.objectContaining({ id: 'session-1' }) + ]) expect(wrapper.emitted('delete')?.[0]).toEqual([expect.objectContaining({ id: 'session-1' })]) }, 10000) + + it('applies the loading shimmer to the title without rendering loading text', async () => { + const wrapper = await mountComponent({ status: 'working' }) + + const title = wrapper.find('.session-title') + const sheen = wrapper.find('.session-title__sheen') + + expect(title.classes()).toContain('session-title--loading') + expect(sheen.exists()).toBe(true) + expect(sheen.attributes('aria-hidden')).toBe('true') + expect(wrapper.find('.session-status-loading').exists()).toBe(false) + expect(wrapper.text()).not.toContain('common.loading') + expect(wrapper.find('[aria-label="thread.actions.pin"]').exists()).toBe(true) + }, 10000) + + it('exposes hero transition and pin feedback state on the rendered item', async () => { + const wrapper = await mountComponent({ + isPinned: true, + heroHidden: true, + pinFeedbackMode: 'pinning' + }) + + const item = wrapper.find('.session-item') + const pinButton = wrapper.find('.pin-button') + + expect(item.attributes('data-pin-fx')).toBe('pinning') + expect(item.attributes('data-session-id')).toBe('session-1') + expect(item.attributes('data-hero-hidden')).toBe('true') + expect(pinButton.attributes('data-pin-fx')).toBe('pinning') + }, 10000) }) From f445015e9916e2cf79790ebface5a63e39988b53 Mon Sep 17 00:00:00 2001 From: xiaomo Date: Thu, 26 Mar 2026 14:49:43 +0800 Subject: [PATCH 05/11] fix: fix message title selection (#1397) --- src/renderer/src/components/WindowSideBarSessionItem.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/WindowSideBarSessionItem.vue b/src/renderer/src/components/WindowSideBarSessionItem.vue index 8a3fd44cd..3579519d0 100644 --- a/src/renderer/src/components/WindowSideBarSessionItem.vue +++ b/src/renderer/src/components/WindowSideBarSessionItem.vue @@ -36,7 +36,7 @@ const pinActionLabel = computed(() => @@ -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) + }) +}) From ef353a17f652857b2d97e06e6c1014f120d2c763 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Thu, 26 Mar 2026 23:27:58 +0800 Subject: [PATCH 09/11] chore: deps bump (#1399) * fix(renderer): prebundle infographic * chore: bump up deps --- electron.vite.config.ts | 1 + package.json | 74 ++++++++++----------- src/main/presenter/windowPresenter/index.ts | 8 ++- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index d8955694c..54d47a976 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ optimizeDeps: { exclude: ['markstream-vue', 'stream-monaco'], include: [ + '@antv/infographic', 'monaco-editor', 'axios' ] diff --git a/package.json b/package.json index fd82ea685..fce33ce5f 100644 --- a/package.json +++ b/package.json @@ -62,43 +62,42 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.16.1", "@anthropic-ai/sdk": "^0.53.0", - "@antv/infographic": "^0.2.7", "@aws-sdk/client-bedrock": "^3.958.0", "@aws-sdk/client-bedrock-runtime": "^3.958.0", "@duckdb/node-api": "1.3.2-alpha.25", "@e2b/code-interpreter": "^1.5.1", "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@google/genai": "^1.34.0", + "@google/genai": "^1.46.0", "@jxa/run": "^1.4.0", - "@larksuiteoapi/node-sdk": "^1.59.0", - "@modelcontextprotocol/sdk": "^1.25.1", - "axios": "^1.13.2", + "@larksuiteoapi/node-sdk": "^1.60.0", + "@modelcontextprotocol/sdk": "^1.28.0", + "axios": "^1.13.6", "better-sqlite3-multiple-ciphers": "12.4.1", - "cheerio": "^1.1.2", + "cheerio": "^1.2.0", "chokidar": "^5.0.0", "compare-versions": "^6.1.1", "cross-spawn": "^7.0.6", - "diff": "^8.0.3", + "diff": "^8.0.4", "electron-log": "^5.4.3", "electron-store": "^8.2.0", - "electron-updater": "^6.6.2", + "electron-updater": "^6.8.3", "electron-window-state": "^5.0.3", "es-mime-types": "^0.1.4", "fflate": "^0.8.2", - "font-list": "^2.0.1", - "glob": "^13.0.0", + "font-list": "^2.0.2", + "glob": "^13.0.6", "gray-matter": "^4.0.3", "https-proxy-agent": "^7.0.6", - "jsonrepair": "^3.13.1", - "mammoth": "^1.11.0", - "nanoid": "^5.1.6", + "jsonrepair": "^3.13.3", + "mammoth": "^1.12.0", + "nanoid": "^5.1.7", "node-pty": "^1.1.0", "ollama": "^0.5.18", - "openai": "^6.32.0", + "openai": "^6.33.0", "pdf-parse-new": "^1.4.1", "run-applescript": "^7.1.0", - "safe-regex2": "^5.0.0", + "safe-regex2": "^5.1.0", "sharp": "^0.33.5", "together-ai": "^0.16.0", "tokenx": "^0.4.1", @@ -111,13 +110,13 @@ "devDependencies": { "@electron-toolkit/tsconfig": "^1.0.1", "@electron/notarize": "^3.1.1", - "@iconify-json/lucide": "^1.2.82", - "@iconify-json/vscode-icons": "^1.2.37", + "@iconify-json/lucide": "^1.2.99", + "@iconify-json/vscode-icons": "^1.2.45", "@iconify/vue": "^5.0.0", "@lingual/i18n-check": "0.8.12", "@pinia/colada": "^0.20.0", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.2.2", "@tiptap/core": "^2.11.7", "@tiptap/extension-code-block": "^2.11.9", "@tiptap/extension-document": "^2.11.7", @@ -132,13 +131,13 @@ "@tiptap/vue-3": "^2.11.7", "@types/better-sqlite3": "^7.6.13", "@types/mime-types": "^3.0.1", - "@types/node": "^22.19.3", + "@types/node": "^22.19.15", "@types/xlsx": "^0.0.35", "@typescript/native-preview": "7.0.0-dev.20260115.1", "@unovis/ts": "1.6.4", "@unovis/vue": "1.6.4", "@vee-validate/zod": "^4.15.1", - "@vitejs/plugin-vue": "^6.0.3", + "@vitejs/plugin-vue": "^6.0.5", "@vitest/ui": "^3.2.4", "@vue/test-utils": "^2.4.6", "@vueuse/core": "^12.8.2", @@ -154,41 +153,42 @@ "electron-vite": "^4.0.1", "jsdom": "^26.1.0", "katex": "^0.16.27", - "lint-staged": "^16.2.7", + "lint-staged": "^16.4.0", "lucide-vue-next": "^0.544.0", "markstream-vue": "0.0.8-beta.1", - "mermaid": "^11.12.2", - "minimatch": "^10.1.1", + "mermaid": "^11.13.0", + "minimatch": "^10.2.4", "monaco-editor": "^0.52.2", "oxfmt": "^0.42.0", - "oxlint": "^1.35.0", + "oxlint": "^1.57.0", "picocolors": "^1.1.1", "pinia": "^3.0.4", - "reka-ui": "^2.7.0", + "reka-ui": "^2.9.2", "simple-git-hooks": "^2.13.1", - "stream-monaco": "^0.0.15", - "tailwind-merge": "^3.4.0", + "stream-monaco": "^0.0.20", + "tailwind-merge": "^3.5.0", "tailwind-scrollbar-hide": "^4.0.0", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "tailwindcss-animate": "^1.0.7", "tippy.js": "^6.3.7", "tw-animate-css": "^1.4.0", - "typescript": "^5.8.3", + "typescript": "^5.9.3", "vee-validate": "^4.15.1", - "vite": "^7.1.11", + "vite": "^7.3.1", "vite-plugin-monaco-editor-esm": "^2.0.2", - "vite-plugin-vue-devtools": "^8.0.5", - "vite-svg-loader": "^5.1.0", + "vite-plugin-vue-devtools": "^8.1.1", + "vite-svg-loader": "^5.1.1", "vitest": "^3.2.4", - "vue": "^3.5.26", - "vue-i18n": "^11.2.7", + "vue": "^3.5.31", + "vue-i18n": "^11.3.0", "vue-router": "4", "vue-sonner": "^2.0.9", "vue-tsgo": "0.0.1-yggdrasill.11", - "vue-virtual-scroller": "^2.0.0-beta.8", + "vue-virtual-scroller": "^2.0.0-beta.10", "vuedraggable": "^4.1.0", - "yaml": "^2.8.2", - "zod-to-json-schema": "^3.25.0" + "yaml": "^2.8.3", + "@antv/infographic": "^0.2.7", + "zod-to-json-schema": "^3.25.1" }, "simple-git-hooks": { "pre-commit": "node scripts/pre-commit.mjs", diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 58b055a54..efc53e5c0 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -867,7 +867,9 @@ export class WindowPresenter implements IWindowPresenter { console.log( `Loading packaged main renderer file: ${join(__dirname, '../renderer/index.html')}` ) - appWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/chat' }) + appWindow.loadFile(join(__dirname, '../renderer/index.html'), { + hash: '/chat' + }) } // DevTools 不再自动打开,需要手动通过菜单或快捷键打开 @@ -1204,8 +1206,8 @@ export class WindowPresenter implements IWindowPresenter { // Initialize window state manager to remember position and size const settingsWindowState = windowStateManager({ file: 'settings-window-state.json', - defaultWidth: 900, - defaultHeight: 600 + defaultWidth: 1300, + defaultHeight: 800 }) // Create Settings Window with state persistence From 8441ce4dfd97527f72bbebd98bf2c8a4545a3dc4 Mon Sep 17 00:00:00 2001 From: duskzhen Date: Fri, 27 Mar 2026 11:35:29 +0800 Subject: [PATCH 10/11] refactor(mcp): retire expired mcp servers (#1400) * refactor(mcp): retire meeting server * fix(agent): use session vision for screenshots * refactor(vision): unify session image analysis * fix(main): harden vision resolution * fix(main): cancel nested vision work --- archives/code/dead-code-batch-3/README.md | 14 + .../inMemoryServers/meetingServer.ts | 0 scripts/generate-i18n-types.js | 4 +- src/main/events.ts | 5 - src/main/presenter/configPresenter/index.ts | 110 ++-- .../configPresenter/mcpConfHelper.ts | 52 +- .../deepchatAgentPresenter/dispatch.ts | 14 + .../presenter/deepchatAgentPresenter/index.ts | 244 +++++++- .../presenter/deepchatAgentPresenter/types.ts | 10 +- src/main/presenter/index.ts | 12 + .../presenter/llmProviderPresenter/index.ts | 35 +- .../mcpPresenter/inMemoryServers/builder.ts | 8 +- .../inMemoryServers/imageServer.ts | 479 ---------------- .../agentTools/agentToolManager.ts | 80 ++- src/main/presenter/toolPresenter/index.ts | 5 + .../presenter/toolPresenter/runtimePorts.ts | 7 + .../presenter/vision/sessionVisionResolver.ts | 91 +++ .../settings/components/AcpSettings.vue | 15 +- .../common/DefaultModelSettingsSection.vue | 63 +-- .../components/mcp-config/mcpServerForm.vue | 84 +-- src/renderer/src/events.ts | 5 - src/renderer/src/i18n/da-DK/mcp.json | 8 - src/renderer/src/i18n/da-DK/settings.json | 4 +- src/renderer/src/i18n/en-US/mcp.json | 8 - src/renderer/src/i18n/en-US/settings.json | 4 +- src/renderer/src/i18n/fa-IR/mcp.json | 8 - src/renderer/src/i18n/fa-IR/settings.json | 4 +- src/renderer/src/i18n/fr-FR/mcp.json | 8 - src/renderer/src/i18n/fr-FR/settings.json | 4 +- src/renderer/src/i18n/he-IL/mcp.json | 8 - src/renderer/src/i18n/he-IL/settings.json | 4 +- src/renderer/src/i18n/ja-JP/mcp.json | 8 - src/renderer/src/i18n/ja-JP/settings.json | 4 +- src/renderer/src/i18n/ko-KR/mcp.json | 8 - src/renderer/src/i18n/ko-KR/settings.json | 4 +- src/renderer/src/i18n/pt-BR/mcp.json | 8 - src/renderer/src/i18n/pt-BR/settings.json | 4 +- src/renderer/src/i18n/ru-RU/mcp.json | 8 - src/renderer/src/i18n/ru-RU/settings.json | 4 +- src/renderer/src/i18n/zh-CN/mcp.json | 8 - src/renderer/src/i18n/zh-CN/settings.json | 4 +- src/renderer/src/i18n/zh-HK/mcp.json | 8 - src/renderer/src/i18n/zh-HK/settings.json | 4 +- src/renderer/src/i18n/zh-TW/mcp.json | 8 - src/renderer/src/i18n/zh-TW/settings.json | 4 +- .../types/presenters/legacy.presenters.d.ts | 7 +- .../presenters/llmprovider.presenter.d.ts | 3 +- src/types/i18n.d.ts | 528 +++++++++++++++++- .../anthropicProviderMigration.test.ts | 74 ++- .../defaultModelSettings.test.ts | 23 - .../deepchatAgentPresenter.test.ts | 213 ++++++- .../deepchatAgentPresenter/dispatch.test.ts | 62 ++ .../agentTools/agentToolManagerRead.test.ts | 114 +++- .../agentToolManagerSettings.test.ts | 1 + .../toolPresenter/toolPresenter.test.ts | 12 +- .../vision/sessionVisionResolver.test.ts | 49 ++ 56 files changed, 1626 insertions(+), 941 deletions(-) create mode 100644 archives/code/dead-code-batch-3/README.md rename {src => archives/code/dead-code-batch-3/src}/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts (100%) delete mode 100644 src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts create mode 100644 src/main/presenter/vision/sessionVisionResolver.ts create mode 100644 test/main/presenter/vision/sessionVisionResolver.test.ts diff --git a/archives/code/dead-code-batch-3/README.md b/archives/code/dead-code-batch-3/README.md new file mode 100644 index 000000000..bea975789 --- /dev/null +++ b/archives/code/dead-code-batch-3/README.md @@ -0,0 +1,14 @@ +# Dead Code Batch 3 + +- Purpose: archive retired MCP runtime code that is no longer part of the active in-memory server set. +- Archived at: 2026-03-26 +- Rationale: `meetingServer.ts` has been removed from live MCP registration and default config, but is retained in source form for precise rollback if the feature is rebuilt later. + +## Archived Paths + +- `src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts` + +## Notes + +- This directory is not part of the runtime, build, typecheck, or test target set. +- Restore by moving files back to their original paths only if a future audit proves the retired MCP server is needed again. diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts b/archives/code/dead-code-batch-3/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts similarity index 100% rename from src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts rename to archives/code/dead-code-batch-3/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts diff --git a/scripts/generate-i18n-types.js b/scripts/generate-i18n-types.js index 9e24b7c3a..5c212e7c7 100644 --- a/scripts/generate-i18n-types.js +++ b/scripts/generate-i18n-types.js @@ -1,6 +1,6 @@ import fs from 'fs' import path from 'path' -import { fileURLToPath } from 'url' +import { fileURLToPath, pathToFileURL } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -57,6 +57,6 @@ async function main() { } // 仅需要在本地开发时执行 -if (import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main() } diff --git a/src/main/events.ts b/src/main/events.ts index 38c6ac140..77da1c471 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -219,11 +219,6 @@ export const TRAY_EVENTS = { CHECK_FOR_UPDATES: 'tray:check-for-updates' // 托盘检查更新 } -// MCP会议专用事件 -export const MEETING_EVENTS = { - INSTRUCTION: 'mcp:meeting-instruction' // 主进程向渲染进程发送指令 -} - // 悬浮按钮相关事件 export const FLOATING_BUTTON_EVENTS = { CLICKED: 'floating-button:clicked', // 悬浮按钮被点击 diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 12a9a7fb9..353f3ac99 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -102,7 +102,7 @@ interface IAppSettings { enableSkills?: boolean // Skills system global toggle hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings defaultModel?: { providerId: string; modelId: string } // Default model for new conversations - defaultVisionModel?: { providerId: string; modelId: string } // Default vision model for image tools + defaultVisionModel?: { providerId: string; modelId: string } // Legacy vision model setting for migration only defaultProjectPath?: string | null acpRegistryMigrationVersion?: number unifiedAgentsMigrationVersion?: number @@ -153,6 +153,15 @@ const isModelSelection = (value: unknown): value is ModelSelection => { return typeof record.providerId === 'string' && typeof record.modelId === 'string' } +const normalizeKnownModelId = (modelId: string): string => { + const normalizedModelId = modelId.trim().toLowerCase() + return normalizedModelId.replace(/^models\//, '') +} + +const normalizeKnownProviderId = (providerId: string): string => + modelCapabilities.resolveProviderId(providerId.trim().toLowerCase()) || + providerId.trim().toLowerCase() + export const getAnthropicModelSelectionKeysToClear = ( settings: Partial< Record< @@ -362,6 +371,7 @@ export class ConfigPresenter implements IConfigPresenter { setAgentRepository(agentRepository: AgentRepository): void { this.agentRepository = agentRepository this.initializeUnifiedAgents() + this.migrateLegacyDefaultVisionModelToBuiltinAgent() } private getAgentRepositoryOrThrow(): AgentRepository { @@ -396,6 +406,35 @@ export class ConfigPresenter implements IConfigPresenter { this.syncRegistryAgentsToRepository() } + private migrateLegacyDefaultVisionModelToBuiltinAgent(): void { + const legacySelection = this.store.get('defaultVisionModel') as unknown + if (legacySelection === undefined) { + return + } + + const builtinVisionModel = this.getBuiltinDeepChatConfig().visionModel + + if ( + isModelSelection(legacySelection) && + (!builtinVisionModel?.providerId || !builtinVisionModel?.modelId) + ) { + const providerId = legacySelection.providerId.trim() + const modelId = legacySelection.modelId.trim() + + if (providerId && modelId) { + this.updateBuiltinDeepChatConfig({ + visionModel: { + providerId, + modelId + } + }) + } + } + + this.store.delete('defaultVisionModel') + eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultVisionModel', undefined) + } + private buildLegacyBuiltinDeepChatConfig(): DeepChatAgentConfig { const defaultModel = this.store.get('defaultModel') as ModelSelection | undefined const assistantModel = this.store.get('assistantModel') as ModelSelection | undefined @@ -760,7 +799,9 @@ export class ConfigPresenter implements IConfigPresenter { const keysToClear = getAnthropicModelSelectionKeysToClear({ defaultModel: this.getSetting('defaultModel'), assistantModel: this.getSetting('assistantModel'), - defaultVisionModel: this.getSetting('defaultVisionModel'), + defaultVisionModel: this.store.get('defaultVisionModel') as + | { providerId: string; modelId: string } + | undefined, preferredModel: this.getSetting('preferredModel') }) @@ -780,9 +821,6 @@ export class ConfigPresenter implements IConfigPresenter { if (key === 'assistantModel') { return this.getBuiltinDeepChatConfig().assistantModel as T | undefined } - if (key === 'defaultVisionModel') { - return this.getDefaultVisionModel() as T | undefined - } if (key === 'default_system_prompt') { return this.getBuiltinDeepChatConfig().systemPrompt as T | undefined } @@ -808,10 +846,6 @@ export class ConfigPresenter implements IConfigPresenter { eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, key, value) return } - if (key === 'defaultVisionModel') { - this.setDefaultVisionModel(value as { providerId: string; modelId: string } | undefined) - return - } if (key === 'default_system_prompt') { this.updateBuiltinDeepChatConfig({ systemPrompt: typeof value === 'string' ? value : '' @@ -1015,6 +1049,26 @@ export class ConfigPresenter implements IConfigPresenter { return this.providerModelHelper.getCustomModels(providerId) } + isKnownModel(providerId: string, modelId: string): boolean { + const normalizedProviderId = normalizeKnownProviderId(providerId) + const normalizedModelId = normalizeKnownModelId(modelId) + + if (!normalizedProviderId || !normalizedModelId) { + return false + } + + const hasKnownModel = (models: Array<{ id: string }> | undefined): boolean => + Array.isArray(models) && + models.some((model) => normalizeKnownModelId(model.id) === normalizedModelId) + + return ( + this.hasUserModelConfig(normalizedModelId, normalizedProviderId) || + hasKnownModel(this.getProviderModels(normalizedProviderId)) || + hasKnownModel(this.getCustomModels(normalizedProviderId)) || + hasKnownModel(this.getDbProviderModels(normalizedProviderId)) + ) + } + setCustomModels(providerId: string, models: MODEL_META[]): void { this.providerModelHelper.setCustomModels(providerId, models) } @@ -1688,6 +1742,18 @@ export class ConfigPresenter implements IConfigPresenter { ) } + async agentSupportsCapability(agentId: string, capability: 'vision'): Promise { + if (capability !== 'vision') { + return false + } + + const agentConfig = await this.resolveDeepChatAgentConfig(agentId) + const providerId = agentConfig.visionModel?.providerId?.trim() + const modelId = agentConfig.visionModel?.modelId?.trim() + + return Boolean(providerId && modelId && this.getModelConfig(modelId, providerId)?.vision) + } + async createDeepChatAgent(input: CreateDeepChatAgentInput): Promise { const created = this.getAgentRepositoryOrThrow().createDeepChatAgent(input) this.notifyAcpAgentsChanged() @@ -2312,32 +2378,6 @@ export class ConfigPresenter implements IConfigPresenter { eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultModel', model) } - getDefaultVisionModel(): { providerId: string; modelId: string } | undefined { - const selection = this.getBuiltinDeepChatConfig().visionModel - if (selection?.providerId && selection?.modelId) { - return { - providerId: selection.providerId, - modelId: selection.modelId - } - } - return this.store.get('defaultVisionModel') as - | { providerId: string; modelId: string } - | undefined - } - - setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void { - this.updateBuiltinDeepChatConfig({ - visionModel: - model?.providerId && model?.modelId - ? { - providerId: model.providerId, - modelId: model.modelId - } - : null - }) - eventBus.sendToMain(CONFIG_EVENTS.SETTING_CHANGED, 'defaultVisionModel', model) - } - getDefaultProjectPath(): string | null { const path = this.getSetting('defaultProjectPath') return path?.trim() ? path.trim() : null diff --git a/src/main/presenter/configPresenter/mcpConfHelper.ts b/src/main/presenter/configPresenter/mcpConfHelper.ts index b3c5f0a5b..4fd1a2543 100644 --- a/src/main/presenter/configPresenter/mcpConfHelper.ts +++ b/src/main/presenter/configPresenter/mcpConfHelper.ts @@ -165,16 +165,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> }, disable: false }, - imageServer: { - args: [], - descriptions: 'Image processing MCP service', - icons: '🖼️', - autoApprove: ['read_image_base64', 'read_multiple_images_base64'], // Auto-approve reading, require confirmation for uploads - type: 'inmemory' as MCPServerType, - command: 'image', // We need to map this command to the ImageServer class later - env: {}, - disable: false - }, ragflowKnowledge: { args: [], descriptions: 'DeepChat内置RAGFlow知识库检索服务', @@ -258,16 +248,6 @@ const DEFAULT_INMEMORY_SERVERS: Record> env: {}, disable: false }, - 'deepchat-inmemory/meeting-server': { - args: [], - descriptions: 'DeepChat内置会议服务,用于组织多Agent讨论', - icons: '👥', - autoApprove: ['all'], - type: 'inmemory' as MCPServerType, - command: 'deepchat-inmemory/meeting-server', - env: {}, - disable: false - }, // Merge platform-specific services ...PLATFORM_SPECIFIC_SERVERS } @@ -384,15 +364,35 @@ export class McpConfHelper { private removeDeprecatedBuiltInServers( servers: Record ): Record { - const deprecatedBuiltInServers = ['powerpack'] + const deprecatedBuiltInServers = [ + 'powerpack', + 'deepchat-inmemory/meeting-server', + 'imageServer' + ] + let hasChanges = false + const removedBuiltInServers = new Set(this.getRemovedBuiltInServers()) + let removedListChanged = false for (const serverName of deprecatedBuiltInServers) { if (servers[serverName]) { console.log(`Removing deprecated built-in MCP service: ${serverName}`) delete servers[serverName] + hasChanges = true + } + + if (removedBuiltInServers.delete(serverName)) { + removedListChanged = true } } + if (hasChanges) { + this.mcpStore.set('mcpServers', servers) + } + + if (removedListChanged) { + this.setRemovedBuiltInServers(Array.from(removedBuiltInServers)) + } + return servers } @@ -913,15 +913,9 @@ export class McpConfHelper { } try { - const mcpServers = this.mcpStore.get('mcpServers') || {} - - if (mcpServers.powerpack) { - console.log('Removing deprecated powerpack MCP server') - delete mcpServers.powerpack - this.mcpStore.set('mcpServers', mcpServers) - } + this.removeDeprecatedBuiltInServers(this.mcpStore.get('mcpServers') || {}) } catch (error) { - console.error('Error occurred while removing deprecated powerpack server:', error) + console.error('Error occurred while removing deprecated built-in MCP servers:', error) } // 升级后检查并添加平台特有服务 diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index 6874379db..37fdd809e 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -679,6 +679,20 @@ export async function executeTools( } } + if (hooks?.normalizeToolResult) { + toolRawData = { + ...toolRawData, + content: await hooks.normalizeToolResult({ + sessionId: io.sessionId, + toolCallId: tc.id, + toolName: tc.name, + toolArgs: tc.arguments, + content: toolRawData.content, + isError: toolRawData.isError === true + }) + } + } + const searchPayload = extractSearchPayload( toolRawData.content, toolContext.name, diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 6971a592e..eaf527c24 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -52,6 +52,7 @@ import { ToolOutputGuard } from './toolOutputGuard' import type { ProviderRequestTracePayload } from '../llmProviderPresenter/requestTrace' import type { NewSessionHooksBridge } from '../hooksNotifications/newSessionBridge' import { providerDbLoader } from '../configPresenter/providerDbLoader' +import { resolveSessionVisionTarget } from '../vision/sessionVisionResolver' type PendingInteractionEntry = { interaction: PendingToolInteraction @@ -107,6 +108,16 @@ const isReasoningEffort = (value: unknown): value is 'minimal' | 'low' | 'medium const isVerbosity = (value: unknown): value is 'low' | 'medium' | 'high' => value === 'low' || value === 'medium' || value === 'high' +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + export class DeepChatAgentPresenter implements IAgentImplementation { private readonly llmProviderPresenter: ILlmProviderPresenter private readonly configPresenter: IConfigPresenter @@ -1011,6 +1022,23 @@ export class DeepChatAgentPresenter implements IAgentImplementation { return undefined } + private getAbortSignalForSession(sessionId: string): AbortSignal | undefined { + return ( + this.activeGenerations.get(sessionId)?.abortController.signal ?? + this.abortControllers.get(sessionId)?.signal + ) + } + + private throwIfAbortRequested(signal?: AbortSignal): void { + if (signal?.aborted) { + throw createAbortError() + } + } + + private isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'CanceledError') + } + private dispatchResolvedToolHook(params: { sessionId: string messageId: string @@ -1424,7 +1452,17 @@ export class DeepChatAgentPresenter implements IAgentImplementation { body: gap } }) - } + }, + normalizeToolResult: async (tool) => + await this.normalizeToolResultContent({ + sessionId: tool.sessionId, + toolCallId: tool.toolCallId, + toolName: tool.toolName, + toolArgs: tool.toolArgs, + content: tool.content, + isError: tool.isError, + abortSignal: abortController.signal + }) }, io: { sessionId, @@ -2867,7 +2905,16 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionRequest: rawData.permissionRequest as PendingToolInteraction['permission'] } } - const responseText = this.toolContentToText(rawData.content) + const normalizedContent = await this.normalizeToolResultContent({ + sessionId, + toolCallId: toolCall.id || '', + toolName, + toolArgs: toolCall.params || '{}', + content: rawData.content, + isError: rawData.isError === true, + abortSignal: this.getAbortSignalForSession(sessionId) + }) + const responseText = this.toolContentToText(normalizedContent) const prepared = await this.toolOutputGuard.prepareToolOutput({ sessionId, toolCallId: toolCall.id || '', @@ -2956,6 +3003,199 @@ export class DeepChatAgentPresenter implements IAgentImplementation { }) } + private async normalizeToolResultContent(params: { + sessionId: string + toolCallId: string + toolName: string + toolArgs: string + content: MCPToolResponse['content'] + isError: boolean + abortSignal?: AbortSignal + }): Promise { + if (params.isError) { + return params.content + } + + const abortSignal = params.abortSignal ?? this.getAbortSignalForSession(params.sessionId) + const screenshotPayload = this.extractScreenshotToolPayload( + params.toolName, + params.toolArgs, + params.content + ) + if (!screenshotPayload) { + return params.content + } + + try { + this.throwIfAbortRequested(abortSignal) + const visionModel = await this.resolveScreenshotVisionModel(params.sessionId, abortSignal) + this.throwIfAbortRequested(abortSignal) + + if (!visionModel) { + return 'Screenshot captured, but automatic English analysis is unavailable because neither the current session model nor the agent vision model can analyze images.' + } + + const messages: ChatMessage[] = [ + { + role: 'user', + content: [ + { + type: 'text', + text: this.buildScreenshotAnalysisPrompt() + }, + { + type: 'image_url', + image_url: { + url: screenshotPayload.dataUrl, + detail: 'auto' + } + } + ] + } + ] + + const modelConfig = this.configPresenter.getModelConfig( + visionModel.modelId, + visionModel.providerId + ) + const response = await this.llmProviderPresenter.generateCompletionStandalone( + visionModel.providerId, + messages, + visionModel.modelId, + modelConfig?.temperature ?? 0.2, + Math.min(modelConfig?.maxTokens ?? 900, 900), + abortSignal ? { signal: abortSignal } : undefined + ) + this.throwIfAbortRequested(abortSignal) + const normalized = response.trim() + if (!normalized) { + return 'Screenshot captured, but automatic English analysis returned no usable description.' + } + return normalized + } catch (error) { + if (this.isAbortError(error)) { + return 'Screenshot captured, but automatic English analysis was canceled.' + } + + const message = error instanceof Error ? error.message : String(error) + console.warn('[DeepChatAgent] Failed to normalize screenshot tool output:', { + sessionId: params.sessionId, + toolCallId: params.toolCallId, + error: message + }) + return `Screenshot captured, but automatic English analysis failed: ${message}` + } + } + + private extractScreenshotToolPayload( + toolName: string, + toolArgs: string, + content: MCPToolResponse['content'] + ): { dataUrl: string } | null { + if (toolName !== 'cdp_send' || typeof content !== 'string') { + return null + } + + const parsedArgs = this.parseJsonRecord(toolArgs) + if (!parsedArgs || parsedArgs.method !== 'Page.captureScreenshot') { + return null + } + + const parsedContent = this.parseJsonRecord(content) + const rawData = typeof parsedContent?.data === 'string' ? parsedContent.data.trim() : '' + if (!rawData) { + return null + } + + const screenshotParams = this.normalizeJsonRecord(parsedArgs.params) + const mimeType = this.resolveScreenshotMimeType(screenshotParams?.format) + const dataUrl = rawData.startsWith('data:image/') + ? rawData + : `data:${mimeType};base64,${rawData}` + + return { dataUrl } + } + + private normalizeJsonRecord(value: unknown): Record | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + + if (typeof value !== 'string' || !value.trim()) { + return null + } + + return this.parseJsonRecord(value) + } + + private parseJsonRecord(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record + } + } catch {} + + return null + } + + private resolveScreenshotMimeType(format: unknown): string { + if (format === 'jpeg') { + return 'image/jpeg' + } + if (format === 'webp') { + return 'image/webp' + } + return 'image/png' + } + + private async resolveScreenshotVisionModel( + sessionId: string, + abortSignal?: AbortSignal + ): Promise<{ providerId: string; modelId: string } | null> { + this.throwIfAbortRequested(abortSignal) + const state = this.runtimeState.get(sessionId) + const dbSession = this.sessionStore.get(sessionId) + const agentId = this.getSessionAgentId(sessionId) ?? 'deepchat' + const resolved = await resolveSessionVisionTarget({ + providerId: state?.providerId ?? dbSession?.provider_id, + modelId: state?.modelId ?? dbSession?.model_id, + agentId, + configPresenter: this.configPresenter, + signal: abortSignal, + logLabel: `screenshot:${sessionId}` + }) + this.throwIfAbortRequested(abortSignal) + + if (!resolved) { + return null + } + + if (resolved.source === 'agent-vision-model') { + const agentSupportsVision = + (await this.configPresenter.agentSupportsCapability?.(agentId, 'vision')) === true + this.throwIfAbortRequested(abortSignal) + if (!agentSupportsVision) { + return null + } + } + + return { + providerId: resolved.providerId, + modelId: resolved.modelId + } + } + + private buildScreenshotAnalysisPrompt(): string { + return [ + 'Analyze this browser screenshot and respond in English only.', + 'Describe only what is clearly visible.', + 'Include the page type or layout, the most important visible text, interactive controls, status indicators, warnings, errors, and any detail that matters for the next browser action.', + 'Do not speculate about hidden or unreadable content.', + 'Return detailed plain text in a single paragraph.' + ].join('\n') + } + private toolContentToText(content: MCPToolResponse['content']): string { if (typeof content === 'string') { return content diff --git a/src/main/presenter/deepchatAgentPresenter/types.ts b/src/main/presenter/deepchatAgentPresenter/types.ts index 785ae76e1..df53d0936 100644 --- a/src/main/presenter/deepchatAgentPresenter/types.ts +++ b/src/main/presenter/deepchatAgentPresenter/types.ts @@ -6,7 +6,7 @@ import type { } from '@shared/types/agent-interface' import type { LLMCoreStreamEvent } from '@shared/types/core/llm-events' import type { ChatMessage } from '@shared/types/core/chat-message' -import type { MCPToolDefinition } from '@shared/types/core/mcp' +import type { MCPToolDefinition, MCPToolResponse } from '@shared/types/core/mcp' import type { ModelConfig } from '@shared/presenter' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { DeepChatMessageStore } from './messageStore' @@ -76,6 +76,14 @@ export interface ProcessHooks { reasoningContentLength: number toolCallCount: number }) => void + normalizeToolResult?: (tool: { + sessionId: string + toolCallId: string + toolName: string + toolArgs: string + content: MCPToolResponse['content'] + isError: boolean + }) => Promise } export interface PendingToolInteraction { diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 6d513c108..185eb9ccb 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -259,6 +259,18 @@ export class Presenter implements IPresenter { return null }, + resolveConversationSessionInfo: async (conversationId) => { + const session = await this.newAgentPresenter?.getSession(conversationId) + if (!session) { + return null + } + + return { + agentId: session.agentId, + providerId: session.providerId, + modelId: session.modelId + } + }, getSkillPresenter: () => this.skillPresenter, getYoBrowserToolHandler: () => this.yoBrowserPresenter.toolHandler, getFilePresenter: () => ({ diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index d5caa1eaf..8cd0ca95c 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -33,6 +33,16 @@ import { AcpSessionPersistence } from './acp' import { AcpProvider } from './providers/acpProvider' import type { ProviderMcpRuntimePort } from './runtimePorts' +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + export class LLMProviderPresenter implements ILlmProviderPresenter { private currentProviderId: string | null = null private readonly activeStreams: Map = new Map() @@ -258,16 +268,37 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { messages: ChatMessage[], modelId: string, temperature?: number, - maxTokens?: number + maxTokens?: number, + options?: { signal?: AbortSignal } ): Promise { const provider = this.getProviderInstance(providerId) let response = '' + const signal = options?.signal + + if (signal?.aborted) { + throw createAbortError() + } + + const completionPromise = provider.completions(messages, modelId, temperature, maxTokens) + const abortPromise = + signal && + new Promise((_, reject) => { + const onAbort = () => reject(createAbortError()) + signal.addEventListener('abort', onAbort, { once: true }) + completionPromise.finally(() => signal.removeEventListener('abort', onAbort)) + }) + try { - const llmResponse = await provider.completions(messages, modelId, temperature, maxTokens) + const llmResponse = await (abortPromise + ? Promise.race([completionPromise, abortPromise]) + : completionPromise) response = llmResponse.content return response } catch (error) { + if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { + throw error + } console.error('Stream error:', error) return '' } diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts index 9a02e88fb..62272a788 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts @@ -2,21 +2,19 @@ import { ArtifactsServer } from './artifactsServer' // FileSystemServer has been removed - filesystem capabilities are now provided via Agent tools import { BochaSearchServer } from './bochaSearchServer' import { BraveSearchServer } from './braveSearchServer' -import { ImageServer } from './imageServer' import { DifyKnowledgeServer } from './difyKnowledgeServer' import { RagflowKnowledgeServer } from './ragflowKnowledgeServer' import { FastGptKnowledgeServer } from './fastGptKnowledgeServer' import { DeepResearchServer } from './deepResearchServer' import { AutoPromptingServer } from './autoPromptingServer' import { ConversationSearchServer } from './conversationSearchServer' -import { MeetingServer } from './meetingServer' import { BuiltinKnowledgeServer } from './builtinKnowledgeServer' import { BuiltinKnowledgeConfig } from '@shared/presenter' import { AppleServer } from './appleServer' export function getInMemoryServer( serverName: string, - args: string[], + _args: string[], env?: Record ) { switch (serverName) { @@ -29,8 +27,6 @@ export function getInMemoryServer( return new BraveSearchServer(env) case 'deepResearch': return new DeepResearchServer(env) - case 'imageServer': - return new ImageServer(args[0] || undefined, args[1] || undefined) case 'difyKnowledge': return new DifyKnowledgeServer( env as { @@ -79,8 +75,6 @@ export function getInMemoryServer( return new AutoPromptingServer() case 'deepchat-inmemory/conversation-search-server': return new ConversationSearchServer() - case 'deepchat-inmemory/meeting-server': - return new MeetingServer() case 'deepchat/apple-server': // 只在 macOS 上创建 AppleServer if (process.platform !== 'darwin') { diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts deleted file mode 100644 index 370acb6eb..000000000 --- a/src/main/presenter/mcpPresenter/inMemoryServers/imageServer.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import fs from 'fs/promises' -import path from 'path' -import { z } from 'zod' -import { zodToJsonSchema } from 'zod-to-json-schema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import { presenter } from '@/presenter' -import { ChatMessage, ChatMessageContent } from '@shared/presenter' -// import { GenerateCompletionOptions } from '@/presenter/llmProviderPresenter' // Assuming this path and type exist - using any for now - -// --- Zod Schemas for Tool Arguments --- - -const ReadImageBase64ArgsSchema = z.object({ - path: z.string().describe('Path to the image file.') -}) - -const UploadImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file to upload.') -}) - -const ReadMultipleImagesBase64ArgsSchema = z.object({ - paths: z.array(z.string()).describe('List of paths to the image files.') -}) - -const UploadMultipleImagesArgsSchema = z.object({ - paths: z.array(z.string()).describe('List of paths to the image files to upload.') -}) - -const QueryImageWithPromptArgsSchema = z.object({ - path: z.string().describe('Path to the image file to query.'), - prompt: z - .string() - .describe('The prompt to use when querying the image with the multimodal model.') -}) - -const DescribeImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file to do simple describe.') -}) - -const OcrImageArgsSchema = z.object({ - path: z.string().describe('Path to the image file for OCR text extraction.') -}) - -// --- Image Server Implementation --- - -export class ImageServer { - private server: Server - private provider: string - private model: string - - constructor(provider?: string, model?: string) { - const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() - this.provider = provider || defaultVisionModel?.providerId || 'openai' - this.model = model || defaultVisionModel?.modelId || 'gpt-4o' - this.server = new Server( - { - name: 'image-processing-server', - version: '0.1.0' - }, - { - capabilities: { - tools: {} - } - } - ) - this.setupRequestHandlers() - } - - // No specific initialization needed for now, but can be added for upload service config - // public async initialize(): Promise { - // // Initialization logic, e.g., configure upload service client - // } - - private getEffectiveModel(): { provider: string; model: string } { - if (this.provider && this.model) { - return { provider: this.provider, model: this.model } - } - - const defaultVisionModel = presenter.configPresenter.getDefaultVisionModel() - if (defaultVisionModel?.providerId && defaultVisionModel?.modelId) { - return { provider: defaultVisionModel.providerId, model: defaultVisionModel.modelId } - } - - throw new Error( - 'No vision model configured. Please set a default vision model in Settings > Common > Default Model.' - ) - } - - public startServer(transport: Transport): void { - this.server.connect(transport) - } - - // --- Placeholder for Image Upload Logic --- - private async uploadImageToService(filePath: string, fileBuffer: Buffer): Promise { - // TODO: Implement actual image upload logic here - // This might involve using a library like 'axios' or a specific SDK - // for services like Imgur, AWS S3, Cloudinary, etc. - console.log(`Uploading ${filePath} (size: ${fileBuffer.length} bytes)...`) - // Replace with actual upload call - await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate network delay - const fakeUrl = `https://fake-upload-service.com/uploads/${path.basename(filePath)}_${Date.now()}` - console.log(`Upload complete: ${fakeUrl}`) - return fakeUrl - } - - // --- Placeholder for Multimodal Model Interaction --- - private async queryImageWithModel( - filePath: string, - fileBuffer: Buffer, - prompt: string - ): Promise { - const { provider, model } = this.getEffectiveModel() - // TODO: Implement actual API call to a multimodal model (e.g., GPT-4o, Gemini) - console.log( - `Querying ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model} with prompt: "${prompt}"...` - ) - - // Construct the messages array for the multimodal model - const base64Image = fileBuffer.toString('base64') - // TODO: Dynamically determine mime type if possible, otherwise assume common type like jpeg - const dataUrl = `data:image/jpeg;base64,${base64Image}` - - const messages: ChatMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: prompt }, // Use the provided prompt - { - type: 'image_url', - image_url: { url: dataUrl } - } - ] as ChatMessageContent[] // Type assertion might be needed depending on ChatMessageContent definition - } - ] - - const modelConfig = presenter.configPresenter.getModelConfig(model, provider) - - try { - const response = await presenter.llmproviderPresenter.generateCompletionStandalone( - provider, - messages, - model, - modelConfig?.temperature ?? 0.6, - modelConfig?.maxTokens || 1000 - ) - console.log(`Model response received: ${response}`) - return response ?? 'No response generated.' // Handle potential null/undefined response - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error querying image: ${errorMessage}`) - // Re-throw or return an error message - throw new Error(`Failed to query image: ${errorMessage}`) - // Or return `Error generating response: ${errorMessage}`; - } - } - - private async ocrImageWithModel(filePath: string, fileBuffer: Buffer): Promise { - const { provider, model } = this.getEffectiveModel() - // TODO: Implement actual API call to an OCR service or a multimodal model capable of OCR - console.log( - `Requesting OCR for ${filePath} (size: ${fileBuffer.length} bytes) using ${provider}/${model}...` - ) - - // Construct the messages array for the multimodal model - const base64Image = fileBuffer.toString('base64') - // TODO: Dynamically determine mime type if possible - const dataUrl = `data:image/jpeg;base64,${base64Image}` - - const messages: ChatMessage[] = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Perform OCR on this image and return the extracted text.' }, - { - type: 'image_url', - image_url: { url: dataUrl } - } - ] as ChatMessageContent[] // Type assertion - } - ] - - console.log(messages) - - const modelConfig = presenter.configPresenter.getModelConfig(model, provider) - - try { - const ocrText = await presenter.llmproviderPresenter.generateCompletionStandalone( - provider, - messages, - model, - modelConfig?.temperature ?? 0.6, - modelConfig?.maxTokens || 1000 - ) - console.log(`OCR text received: ${ocrText}`) - return ocrText ?? 'No text extracted.' // Handle potential null/undefined response - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - console.error(`Error performing OCR: ${errorMessage}`) - // Re-throw or return an error message - throw new Error(`Failed to perform OCR: ${errorMessage}`) - // Or return `Error performing OCR: ${errorMessage}`; - } - } - - // --- Request Handlers --- - - private setupRequestHandlers(): void { - // List Tools Handler - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'read_image_base64', - description: - 'Reads an image file from the specified path and returns its base64 encoded content.', - inputSchema: zodToJsonSchema(ReadImageBase64ArgsSchema), - annotations: { - title: 'Read Image Base64', - readOnlyHint: true - } - }, - { - name: 'upload_image', - description: - 'Uploads an image file from the specified path to a hosting service and returns the public URL.', - inputSchema: zodToJsonSchema(UploadImageArgsSchema), - annotations: { - title: 'Upload Image', - destructiveHint: false, - openWorldHint: true - } - }, - { - name: 'read_multiple_images_base64', - description: - 'Reads multiple image files from the specified paths and returns their base64 encoded content.', - inputSchema: zodToJsonSchema(ReadMultipleImagesBase64ArgsSchema), - annotations: { - title: 'Read Multiple Images Base64', - readOnlyHint: true - } - }, - { - name: 'upload_multiple_images', - description: - 'Uploads multiple image files from the specified paths to a hosting service and returns their public URLs.', - inputSchema: zodToJsonSchema(UploadMultipleImagesArgsSchema), - annotations: { - title: 'Upload Multiple Images', - destructiveHint: false, - openWorldHint: true - } - }, - { - name: 'describe_image', - description: - 'Uses a multimodal model to simply describe the image at the specified path.', - inputSchema: zodToJsonSchema(DescribeImageArgsSchema), - annotations: { - title: 'Describe Image', - readOnlyHint: true, - openWorldHint: true - } - }, - { - name: 'query_image_with_prompt', - description: - 'Uses a multimodal model to answer a query (prompt) about the image at the specified path.', - inputSchema: zodToJsonSchema(QueryImageWithPromptArgsSchema), - annotations: { - title: 'Query Image with Prompt', - readOnlyHint: true, - openWorldHint: true - } - }, - { - name: 'ocr_image', - description: - 'Performs Optical Character Recognition (OCR) on the image at the specified path and returns the extracted text.', - inputSchema: zodToJsonSchema(OcrImageArgsSchema), - annotations: { - title: 'OCR Image', - readOnlyHint: true, - openWorldHint: true - } - } - ] - } - }) - - // Call Tool Handler - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - const { name, arguments: args } = request.params - - switch (name) { - case 'read_image_base64': { - const parsed = ReadImageBase64ArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary (similar to FileSystemServer) - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const base64Content = fileBuffer.toString('base64') - // Determine mime type (optional but good practice) - // const mimeType = lookup(filePath) || 'application/octet-stream'; - // const dataUri = `data:${mimeType};base64,${base64Content}`; - return { - content: [{ type: 'text', text: base64Content }] // Or return dataUri - } - } - - case 'upload_image': { - const parsed = UploadImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const imageUrl = await this.uploadImageToService(filePath, fileBuffer) - return { - content: [{ type: 'text', text: imageUrl }] - } - } - - case 'read_multiple_images_base64': { - const parsed = ReadMultipleImagesBase64ArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - const results = await Promise.allSettled( - parsed.data.paths.map(async (filePath: string) => { - try { - // TODO: Implement path validation if necessary - const fileBuffer = await fs.readFile(filePath) - return { - path: filePath, - base64: fileBuffer.toString('base64'), - status: 'fulfilled' - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Ensure the structure includes path and error for rejected promises - return Promise.reject({ path: filePath, error: errorMessage }) - } - }) - ) - - // Format output: [{path: string, base64?: string, error?: string}] - const formattedResults = results.map((result) => { - if (result.status === 'fulfilled') { - return { path: result.value.path, base64: result.value.base64 } - } else { - // Access reason directly as it contains the rejected structure - return { path: result.reason.path, error: result.reason.error } - } - }) - - return { - content: [{ type: 'text', text: JSON.stringify(formattedResults, null, 2) }] - } - } - - case 'upload_multiple_images': { - const parsed = UploadMultipleImagesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - - const results = await Promise.allSettled( - parsed.data.paths.map(async (filePath: string) => { - try { - // TODO: Implement path validation if necessary - const fileBuffer = await fs.readFile(filePath) - const url = await this.uploadImageToService(filePath, fileBuffer) - return { path: filePath, url: url, status: 'fulfilled' } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Ensure the structure includes path and error for rejected promises - return Promise.reject({ path: filePath, error: errorMessage }) - } - }) - ) - - // Format output: [{path: string, url?: string, error?: string}] - const formattedResults = results.map((result) => { - if (result.status === 'fulfilled') { - return { path: result.value.path, url: result.value.url } - } else { - // Access reason directly as it contains the rejected structure - return { path: result.reason.path, error: result.reason.error } - } - }) - - return { - content: [{ type: 'text', text: JSON.stringify(formattedResults, null, 2) }] - } - } - - case 'describe_image': { - const parsed = DescribeImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const description = await this.queryImageWithModel( - filePath, - fileBuffer, - 'Describe this image.' - ) - return { - content: [{ type: 'text', text: description }] - } - } - - case 'query_image_with_prompt': { - const parsed = QueryImageWithPromptArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const prompt = parsed.data.prompt // Get the prompt - const fileBuffer = await fs.readFile(filePath) - // Call the renamed function with the prompt - const response = await this.queryImageWithModel(filePath, fileBuffer, prompt) - return { - content: [{ type: 'text', text: response }] - } - } - - case 'ocr_image': { - const parsed = OcrImageArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for ${name}: ${parsed.error}`) - } - // TODO: Implement path validation if necessary - const filePath = parsed.data.path - const fileBuffer = await fs.readFile(filePath) - const ocrText = await this.ocrImageWithModel(filePath, fileBuffer) - return { - content: [{ type: 'text', text: ocrText }] - } - } - - default: - throw new Error(`Unknown tool: ${name}`) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - // Consider logging the error server-side - console.error(`Error processing tool call: ${errorMessage}`) - // Ensure the error response structure matches expected format - return { - content: [{ type: 'text', text: `Error: ${errorMessage}` }], - isError: true // Indicate this is an error response - } - } - }) - } -} - -// --- Usage Example (similar to FileSystemServer) --- -// import { WebSocketServerTransport } from '@modelcontextprotocol/sdk/transport/node'; -// -// const imageServer = new ImageServer('your-llm-provider', 'your-multimodal-model'); -// // await imageServer.initialize(); // If initialization is added -// -// // Example using WebSocket transport -// const transport = new WebSocketServerTransport({ port: 8081 }); // Choose a different port -// imageServer.startServer(transport); -// console.log('ImageServer started on port 8081'); - -// You would need a client to connect to this server and call the tools. diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 06583badb..8426c987f 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -20,6 +20,7 @@ import { } from './chatSettingsTools' import type { AgentToolRuntimePort } from '../runtimePorts' import { YO_BROWSER_TOOL_NAMES } from '../../browser/YoBrowserToolDefinitions' +import { resolveSessionVisionTarget } from '../../vision/sessionVisionResolver' // Consider moving to a shared handlers location in future refactoring import { @@ -433,7 +434,7 @@ export class AgentToolManager { function: { name: 'read', description: - "Read the contents of a file. Supports pagination via offset/limit for large files (auto-truncated at 4500 chars if not specified). When invoked from a skill context with relative paths, provide base_directory as the skill's root directory.", + "Read the contents of a file. Supports pagination via offset/limit for large files (auto-truncated at 4500 chars if not specified). For image files, returns an English description of visible content instead of raw pixels. When invoked from a skill context with relative paths, provide base_directory as the skill's root directory.", parameters: zodToJsonSchema(schemas.read) as { type: string properties: Record @@ -721,7 +722,7 @@ export class AgentToolManager { if (this.isImageMimeType(mimeType)) { return { - content: await this.readImageWithVisionFallback(validPath, mimeType) + content: await this.readImageWithVisionFallback(validPath, mimeType, conversationId) } } @@ -1063,13 +1064,28 @@ export class AgentToolManager { return lines.join('\n') } - private async readImageWithVisionFallback(filePath: string, mimeType: string): Promise { + private async readImageWithVisionFallback( + filePath: string, + mimeType: string, + conversationId?: string + ): Promise { const fileBuffer = await fs.promises.readFile(filePath) const metadata = this.buildImageMetadataBlock(filePath, mimeType, fileBuffer.length) - const defaultVisionModel = this.configPresenter.getDefaultVisionModel?.() + let visionTarget: Awaited> + + try { + visionTarget = await this.resolveVisionTargetForConversation(conversationId) + } catch (error) { + logger.warn('[AgentToolManager] Failed to resolve vision target for image read:', { + conversationId, + filePath, + error + }) + throw error + } - if (!defaultVisionModel?.providerId || !defaultVisionModel?.modelId) { - return `${metadata}\n\nNo defaultVisionModel configured, downgraded to metadata.` + if (!visionTarget) { + return `${metadata}\n\nImage analysis unavailable because neither the current session model nor the agent vision model can analyze images.` } try { @@ -1080,12 +1096,7 @@ export class AgentToolManager { content: [ { type: 'text', - text: [ - 'Analyze this image and return exactly two sections.', - 'Section 1 title: OCR', - 'Section 2 title: Summary', - 'Keep OCR as faithful extracted text and Summary concise.' - ].join('\n') + text: this.buildImageAnalysisPrompt() }, { type: 'image_url', @@ -1096,28 +1107,61 @@ export class AgentToolManager { ] const modelConfig = this.configPresenter.getModelConfig( - defaultVisionModel.modelId, - defaultVisionModel.providerId + visionTarget.modelId, + visionTarget.providerId ) const response = await this.getLlmProviderPresenter().generateCompletionStandalone( - defaultVisionModel.providerId, + visionTarget.providerId, messages, - defaultVisionModel.modelId, + visionTarget.modelId, modelConfig?.temperature ?? 0.2, modelConfig?.maxTokens ?? 1200 ) const normalized = (response || '').trim() if (!normalized) { - return `${metadata}\n\nOCR:\n\nSummary:\nNo result returned by vision model.` + return `${metadata}\n\nImage analysis returned no usable description.` } - return normalized.startsWith('OCR:') ? normalized : `OCR:\n\nSummary:\n${normalized}` + return normalized } catch (error) { const message = error instanceof Error ? error.message : String(error) return `${metadata}\n\nVision analysis failed, downgraded to metadata.\nerror: ${message}` } } + private async resolveVisionTargetForConversation(conversationId?: string) { + if (!conversationId) { + return null + } + + try { + const sessionInfo = await this.runtimePort.resolveConversationSessionInfo(conversationId) + return await resolveSessionVisionTarget({ + providerId: sessionInfo?.providerId, + modelId: sessionInfo?.modelId, + agentId: sessionInfo?.agentId, + configPresenter: this.configPresenter, + logLabel: `read:${conversationId}` + }) + } catch (error) { + if (this.isConversationNotFoundError(error)) { + return null + } + + throw error + } + } + + private buildImageAnalysisPrompt(): string { + return [ + 'Analyze this image and respond in English only.', + 'Describe only what is clearly visible.', + 'Include the main subject, scene or layout, any legible text, UI elements if present, status indicators, warnings, errors, and any detail that matters for understanding the image.', + 'Do not speculate about hidden or unreadable content.', + 'Return detailed plain text in a single paragraph.' + ].join('\n') + } + private assertWritePermission( toolName: string, args: Record, diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 71dcf250c..fc03ca324 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -377,6 +377,11 @@ export class ToolPresenter implements IToolPresenter { 'Use `background: true` when you know a command should detach immediately; otherwise a foreground `exec` may yield a running `sessionId` after `yieldMs`.' ) } + if (toolNames.has('read')) { + lines.push( + 'When `read` targets an image file, it returns an English description of the visible content and any legible text.' + ) + } if (toolNames.has('exec') && toolNames.has('read') && toolNames.has('edit')) { lines.push( 'Recommended file task flow: `exec` for discovery/search -> `read` -> `edit`/`write`.' diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index 899540811..806b436c2 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -6,8 +6,15 @@ import type { } from '@shared/presenter' import type { ISkillPresenter } from '@shared/types/skill' +export interface ConversationSessionInfo { + agentId: string + providerId: string + modelId: string +} + export interface AgentToolRuntimePort { resolveConversationWorkdir(conversationId: string): Promise + resolveConversationSessionInfo(conversationId: string): Promise getSkillPresenter(): ISkillPresenter getYoBrowserToolHandler(): IYoBrowserPresenter['toolHandler'] getFilePresenter(): Pick diff --git a/src/main/presenter/vision/sessionVisionResolver.ts b/src/main/presenter/vision/sessionVisionResolver.ts new file mode 100644 index 000000000..3dd075b3e --- /dev/null +++ b/src/main/presenter/vision/sessionVisionResolver.ts @@ -0,0 +1,91 @@ +import type { IConfigPresenter } from '@shared/presenter' + +export type SessionVisionTarget = { + providerId: string + modelId: string + source: 'session-model' | 'agent-vision-model' +} + +type SessionVisionResolverParams = { + providerId?: string | null + modelId?: string | null + agentId?: string | null + signal?: AbortSignal + configPresenter: Pick< + IConfigPresenter, + 'getModelConfig' | 'resolveDeepChatAgentConfig' | 'isKnownModel' + > + logLabel?: string +} + +const createAbortError = (): Error => { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError') + } + + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + +const throwIfAbortRequested = (signal?: AbortSignal): void => { + if (signal?.aborted) { + throw createAbortError() + } +} + +export async function resolveSessionVisionTarget( + params: SessionVisionResolverParams +): Promise { + throwIfAbortRequested(params.signal) + const sessionProviderId = params.providerId?.trim() + const sessionModelId = params.modelId?.trim() + const sessionModelConfig = + sessionProviderId && sessionModelId + ? params.configPresenter.getModelConfig(sessionModelId, sessionProviderId) + : null + + if ( + sessionProviderId && + sessionModelId && + params.configPresenter.isKnownModel?.(sessionProviderId, sessionModelId) === true && + sessionModelConfig?.vision + ) { + return { + providerId: sessionProviderId, + modelId: sessionModelId, + source: 'session-model' + } + } + + const agentId = params.agentId?.trim() + if (!agentId) { + return null + } + + try { + throwIfAbortRequested(params.signal) + const agentConfig = await params.configPresenter.resolveDeepChatAgentConfig(agentId) + throwIfAbortRequested(params.signal) + const providerId = agentConfig.visionModel?.providerId?.trim() + const modelId = agentConfig.visionModel?.modelId?.trim() + if (providerId && modelId) { + return { + providerId, + modelId, + source: 'agent-vision-model' + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + console.warn('[Vision] Failed to resolve agent vision model:', { + agentId, + context: params.logLabel ?? 'unknown', + error + }) + } + + return null +} diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index b90851bb4..80a11e9fe 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -75,14 +75,9 @@ {{ t('settings.acp.installedSectionDescription') }}

-
- - {{ t('settings.acp.installedCount', { count: installedRegistryAgents.length }) }} - - -
+ + {{ t('settings.acp.installedCount', { count: installedRegistryAgents.length }) }} +
{{ t('settings.acp.installedEmptyDescription') }}

-
diff --git a/src/renderer/settings/components/common/DefaultModelSettingsSection.vue b/src/renderer/settings/components/common/DefaultModelSettingsSection.vue index cef7bd378..7a2ef692e 100644 --- a/src/renderer/settings/components/common/DefaultModelSettingsSection.vue +++ b/src/renderer/settings/components/common/DefaultModelSettingsSection.vue @@ -68,42 +68,6 @@
- -
- {{ - t('settings.common.defaultModel.visionModel') - }} -
- - - - - - - - -
-
@@ -119,7 +83,6 @@ import ModelIcon from '@/components/icons/ModelIcon.vue' import { useThemeStore } from '@/stores/theme' import { useModelStore } from '@/stores/modelStore' import { usePresenter } from '@/composables/usePresenter' -import { ModelType } from '@shared/model' import type { RENDERER_MODEL_META } from '@shared/presenter' const { t } = useI18n() @@ -129,7 +92,6 @@ const configPresenter = usePresenter('configPresenter') const assistantModelSelectOpen = ref(false) const chatModelSelectOpen = ref(false) -const visionModelSelectOpen = ref(false) interface SelectedModel { providerId: string @@ -138,7 +100,6 @@ interface SelectedModel { const selectedAssistantModel = ref(null) const selectedChatModel = ref(null) -const selectedVisionModel = ref(null) let isSyncingModelDefaults = false const selectBySetting = ( @@ -164,7 +125,7 @@ const selectBySetting = ( } const persistModelSetting = async ( - key: 'assistantModel' | 'defaultModel' | 'defaultVisionModel', + key: 'assistantModel' | 'defaultModel', previous: { providerId: string; modelId: string } | undefined, current: SelectedModel | null ): Promise => { @@ -198,15 +159,6 @@ const handleChatModelSelect = async ( chatModelSelectOpen.value = false } -const handleVisionModelSelect = async ( - model: RENDERER_MODEL_META, - providerId: string -): Promise => { - selectedVisionModel.value = { providerId, model } - await configPresenter.setSetting('defaultVisionModel', { providerId, modelId: model.id }) - visionModelSelectOpen.value = false -} - const syncModelSelections = async (): Promise => { if (isSyncingModelDefaults) { return @@ -219,9 +171,6 @@ const syncModelSelections = async (): Promise => { const defaultModelSetting = (await configPresenter.getSetting('defaultModel')) as | { providerId: string; modelId: string } | undefined - const defaultVisionModelSetting = (await configPresenter.getSetting('defaultVisionModel')) as - | { providerId: string; modelId: string } - | undefined const chatSelection = selectBySetting( defaultModelSetting, @@ -233,21 +182,11 @@ const syncModelSelections = async (): Promise => { (_model, providerId) => providerId !== 'acp' ) - const visionSelection = selectBySetting( - defaultVisionModelSetting, - (model, providerId) => - providerId !== 'acp' && - Boolean(model.vision) && - (model.type === ModelType.Chat || model.type === ModelType.ImageGeneration) - ) - selectedChatModel.value = chatSelection selectedAssistantModel.value = assistantSelection - selectedVisionModel.value = visionSelection await persistModelSetting('defaultModel', defaultModelSetting, chatSelection) await persistModelSetting('assistantModel', assistantModelSetting, assistantSelection) - await persistModelSetting('defaultVisionModel', defaultVisionModelSetting, visionSelection) } catch (error) { console.error('Failed to sync model selections:', error) } finally { diff --git a/src/renderer/src/components/mcp-config/mcpServerForm.vue b/src/renderer/src/components/mcp-config/mcpServerForm.vue index 8b1ca56bb..8c5d32923 100644 --- a/src/renderer/src/components/mcp-config/mcpServerForm.vue +++ b/src/renderer/src/components/mcp-config/mcpServerForm.vue @@ -18,16 +18,12 @@ import { EmojiPicker } from '@/components/emoji-picker' import { useToast } from '@/components/use-toast' import { Icon } from '@iconify/vue' import { X } from 'lucide-vue-next' -import ModelIcon from '@/components/icons/ModelIcon.vue' -import { useModelStore } from '@/stores/modelStore' import { usePresenter } from '@/composables/usePresenter' import { nanoid } from 'nanoid' const { t } = useI18n() const { toast } = useToast() -const modelStore = useModelStore() const devicePresenter = usePresenter('devicePresenter') -const configPresenter = usePresenter('configPresenter') const props = defineProps<{ serverName?: string initialConfig?: MCPServerConfig @@ -57,14 +53,8 @@ const customHeadersFocused = ref(false) const customHeadersDisplayValue = ref('') const npmRegistry = ref(props.initialConfig?.customNpmRegistry || '') -// imageServer 展示用(只读,来源于 defaultVisionModel) -const selectedImageModelName = ref('') -const selectedImageModelProvider = ref('') - // 判断是否是inmemory类型 const isInMemoryType = computed(() => type.value === 'inmemory') -// 判断是否是imageServer -const isImageServer = computed(() => isInMemoryType.value && name.value === 'imageServer') // 判断是否是buildInFileSystem const isBuildInFileSystem = computed( () => isInMemoryType.value && name.value === 'buildInFileSystem' @@ -80,32 +70,6 @@ const formatJsonHeaders = (headers: Record): string => { .map(([key, value]) => `${key}=${value}`) .join('\n') } -const refreshImageServerDefaultModelDisplay = async (): Promise => { - if (!isImageServer.value) { - selectedImageModelName.value = '' - selectedImageModelProvider.value = '' - return - } - - const defaultVisionModel = (await configPresenter.getSetting('defaultVisionModel')) as - | { providerId: string; modelId: string } - | undefined - if (!defaultVisionModel?.providerId || !defaultVisionModel?.modelId) { - selectedImageModelName.value = '' - selectedImageModelProvider.value = '' - return - } - - selectedImageModelProvider.value = defaultVisionModel.providerId - const providerEntry = modelStore.enabledModels.find( - (entry) => entry.providerId === defaultVisionModel.providerId - ) - const resolvedModel = providerEntry?.models.find( - (model) => model.id === defaultVisionModel.modelId - ) - selectedImageModelName.value = - resolvedModel?.name || `${defaultVisionModel.providerId}/${defaultVisionModel.modelId}` -} // 获取内置服务器的本地化名称和描述 const getLocalizedName = computed(() => { @@ -144,11 +108,9 @@ const jsonConfig = ref('') const showBaseUrl = computed(() => isRemoteType.value) // 添加计算属性来控制命令相关字段的显示 const showCommandFields = computed(() => type.value === 'stdio') -// 控制参数输入框的显示 (stdio 或 非imageServer且非buildInFileSystem的inmemory) +// 控制参数输入框的显示 (stdio 或 非buildInFileSystem的inmemory) const showArgsInput = computed( - () => - showCommandFields.value || - (isInMemoryType.value && !isImageServer.value && !isBuildInFileSystem.value) + () => showCommandFields.value || (isInMemoryType.value && !isBuildInFileSystem.value) ) // 控制文件夹选择界面的显示 (仅针对 buildInFileSystem) @@ -253,11 +215,11 @@ const isNameValid = computed(() => name.value.trim().length > 0) const isCommandValid = computed(() => { // 对于SSE类型,命令不是必需的 if (isRemoteType.value) return true - // 对于STDIO 或 inmemory 类型,命令是必需的 (排除内置 server) - if (type.value === 'stdio' || (isInMemoryType.value && !isImageServer.value)) { + // 对于STDIO 或 inmemory 类型,命令是必需的 + if (type.value === 'stdio' || isInMemoryType.value) { return command.value.trim().length > 0 } - return true // 其他情况(如 imageServer)默认有效 + return true }) const isEnvValid = computed(() => { try { @@ -473,11 +435,9 @@ const handleSubmit = (): void => { } } else { // STDIO 或 inmemory 类型的服务器 - const normalizedArgs = isImageServer.value - ? [] - : isBuildInFileSystem.value - ? foldersList.value.filter((folder) => folder.trim().length > 0) - : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) + const normalizedArgs = isBuildInFileSystem.value + ? foldersList.value.filter((folder) => folder.trim().length > 0) + : argsRows.value.map((row) => row.value.trim()).filter((value) => value.length > 0) serverConfig = { ...baseConfig, command: command.value.trim(), @@ -592,15 +552,6 @@ watch( { immediate: true } ) -// imageServer 仅展示默认视觉模型,不再通过 args 配置 -watch( - [() => name.value, () => type.value, () => modelStore.enabledModels], - () => { - void refreshImageServerDefaultModelDisplay() - }, - { immediate: true, deep: true } -) - // Watch for initial config changes (primarily for edit mode) watch( () => props.initialConfig, @@ -793,25 +744,6 @@ HTTP-Referer=deepchatai.cn` />
- -
- -
- - {{ - selectedImageModelName || t('settings.mcp.serverForm.imageModel') - }} -
-
-