diff --git a/docs/specs/multi-window-cleanup/plan.md b/docs/specs/multi-window-cleanup/plan.md new file mode 100644 index 000000000..3b945c77a --- /dev/null +++ b/docs/specs/multi-window-cleanup/plan.md @@ -0,0 +1,7 @@ +# Multi-Window Cleanup Plan + +1. Rename renderer entry from `shell` to `browser` and delete tooltip overlay. +2. Convert YoBrowser from single-window multi-tab to multi-window single-page. +3. Remove tab shortcuts and tab UI, keep only multi-window behavior. +4. Short-circuit tab-dependent legacy MCP helpers. +5. Run format, i18n, lint, typecheck, targeted tests, and build. diff --git a/docs/specs/multi-window-cleanup/spec.md b/docs/specs/multi-window-cleanup/spec.md new file mode 100644 index 000000000..fa9ef873f --- /dev/null +++ b/docs/specs/multi-window-cleanup/spec.md @@ -0,0 +1,48 @@ +# Multi-Window Cleanup Spec + +## Goal + +Remove the last shell and multi-tab browser architecture remnants and converge on a clean +multi-window model: + +- app windows render the chat UI directly +- browser windows render dedicated browser chrome directly +- each browser window owns exactly one page `WebContentsView` +- tooltip overlay is removed completely + +## Decisions + +### Window model + +- `WindowPresenter` exposes explicit app-window and browser-window creation APIs. +- `createShellWindow` remains only as a deprecated compatibility wrapper. +- Browser windows load `src/renderer/browser/index.html`. +- Chat windows load `src/renderer/index.html#/chat`. + +### YoBrowser model + +- `YoBrowserPresenter` manages multiple browser windows. +- Each browser window has one browser chrome renderer and one page view. +- There is no tab list, no tab activation, no tab reordering, and no tab shortcuts. +- Browser APIs use `windowId` for addressing. + +### Renderer model + +- The old `src/renderer/shell` entry is renamed to `src/renderer/browser`. +- Browser chrome keeps only: + - window controls + - address bar + - navigation controls + - create-new-browser-window action +- Tooltip overlay entry/runtime is deleted. + +### Legacy handling + +- `conversationSearchServer` and `meetingServer` are intentionally disabled until they are rebuilt + against the window-native architecture. +- Deprecated browser tool names remain as thin aliases only in the handler. + +## Non-Goals + +- Rebuilding every legacy session/thread abstraction in this pass. +- Introducing new UI entities beyond the required browser-window state. diff --git a/docs/specs/multi-window-cleanup/tasks.md b/docs/specs/multi-window-cleanup/tasks.md new file mode 100644 index 000000000..4740a93c1 --- /dev/null +++ b/docs/specs/multi-window-cleanup/tasks.md @@ -0,0 +1,11 @@ +# Multi-Window Cleanup Tasks + +- [x] Move renderer entry from `shell` to `browser` +- [x] Delete tooltip overlay entry from renderer tree +- [ ] Update build aliases and inputs +- [ ] Simplify browser renderer chrome +- [ ] Rewrite YoBrowser window model +- [ ] Split app-window and browser-window creation +- [ ] Remove tab shortcuts from main/settings +- [ ] Disable legacy MCP tab helpers +- [ ] Verify format, i18n, lint, typecheck, tests, build diff --git a/electron.vite.config.ts b/electron.vite.config.ts index effef1451..6d793be81 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -64,7 +64,7 @@ export default defineConfig({ resolve: { alias: { '@': resolve('src/renderer/src'), - '@shell': resolve('src/renderer/shell'), + '@browser': resolve('src/renderer/browser'), '@shared': resolve('src/shared'), "@shadcn": resolve('src/shadcn'), vue: 'vue/dist/vue.esm-bundler.js' @@ -92,7 +92,7 @@ export default defineConfig({ vueDevTools( { appendTo:'src/renderer/src/main.ts' - // appendTo:'src/renderer/shell/main.ts' + // appendTo:'src/renderer/browser/main.ts' } ) ], @@ -107,8 +107,7 @@ export default defineConfig({ cssCodeSplit: false, rollupOptions: { input: { - shell: resolve('src/renderer/shell/index.html'), - shellTooltipOverlay: resolve('src/renderer/shell/tooltip-overlay/index.html'), + browser: resolve('src/renderer/browser/index.html'), index: resolve('src/renderer/index.html'), floating: resolve('src/renderer/floating/index.html'), splash: resolve('src/renderer/splash/index.html'), diff --git a/resources/model-db/providers.json b/resources/model-db/providers.json index 2a9e7e3aa..c75869c5f 100644 --- a/resources/model-db/providers.json +++ b/resources/model-db/providers.json @@ -42933,6 +42933,40 @@ }, "type": "chat" }, + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "display_name": "GPT-5.4", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 400000, + "output": 128000 + }, + "temperature": false, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "attachment": false, + "open_weights": false, + "knowledge": "2025-08-31", + "release_date": "2026-03-05", + "last_updated": "2026-03-05", + "cost": { + "input": 0, + "output": 0 + }, + "type": "chat" + }, { "id": "gpt-5.1-codex", "name": "GPT-5.1-Codex", @@ -131721,8 +131755,8 @@ ] }, "limit": { - "context": 131072, - "output": 131072 + "context": 262144, + "output": 262144 }, "tool_call": true, "reasoning": { @@ -134896,6 +134930,29 @@ }, "type": "chat" }, + { + "id": "gpt-5.3-chat-latest", + "name": "gpt-5.3-chat-latest", + "display_name": "gpt-5.3-chat-latest", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 128000, + "output": 16000 + }, + "tool_call": true, + "reasoning": { + "supported": false + }, + "type": "chat" + }, { "id": "gpt-5.3-codex", "name": "gpt-5.3-codex", @@ -134920,6 +134977,54 @@ }, "type": "chat" }, + { + "id": "gpt-5.4", + "name": "gpt-5.4", + "display_name": "gpt-5.4", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, + { + "id": "gpt-5.4-pro", + "name": "gpt-5.4-pro", + "display_name": "gpt-5.4-pro", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 1050000, + "output": 128000 + }, + "tool_call": true, + "reasoning": { + "supported": true, + "default": true + }, + "type": "chat" + }, { "id": "grok-3", "name": "grok-3", @@ -139268,6 +139373,63 @@ }, "type": "chat" }, + { + "id": "x-ai/grok-4.2-fast", + "name": "xAI: Grok 4.2 Fast", + "display_name": "xAI: Grok 4.2 Fast", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": false, + "reasoning": { + "supported": true, + "default": true + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, + { + "id": "x-ai/grok-4.2-fast-non-reasoning", + "name": "xAI: Grok 4.2 Fast Non Reasoning", + "display_name": "xAI: Grok 4.2 Fast Non Reasoning", + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "limit": { + "context": 2000000, + "output": 2000000 + }, + "tool_call": false, + "reasoning": { + "supported": false + }, + "cost": { + "input": 2, + "output": 6, + "cache_read": 0.2 + }, + "type": "chat" + }, { "id": "x-ai/grok-code-fast-1", "name": "xAI: Grok Code Fast 1", diff --git a/src/main/eventbus.ts b/src/main/eventbus.ts index 38ae8c8b3..d0b22ce65 100644 --- a/src/main/eventbus.ts +++ b/src/main/eventbus.ts @@ -3,12 +3,12 @@ import EventEmitter from 'events' export enum SendTarget { ALL_WINDOWS = 'all_windows', + DEFAULT_WINDOW = 'default_window', DEFAULT_TAB = 'default_tab' } export class EventBus extends EventEmitter { private windowPresenter: IWindowPresenter | null = null - private tabPresenter: ITabPresenter | null = null constructor() { super() @@ -30,7 +30,7 @@ export class EventBus extends EventEmitter { /** * 向渲染进程发送事件 * @param eventName 事件名称 - * @param target 发送目标:所有窗口或默认标签页 + * @param target 发送目标:所有窗口或默认窗口 * @param args 事件参数 */ sendToRenderer( @@ -47,8 +47,13 @@ export class EventBus extends EventEmitter { case SendTarget.ALL_WINDOWS: this.windowPresenter.sendToAllWindows(eventName, ...args) break + case SendTarget.DEFAULT_WINDOW: case SendTarget.DEFAULT_TAB: - this.windowPresenter.sendToDefaultTab(eventName, true, ...args) + if (typeof this.windowPresenter.sendToDefaultWindow === 'function') { + this.windowPresenter.sendToDefaultWindow(eventName, true, ...args) + } else { + this.windowPresenter.sendToDefaultTab(eventName, true, ...args) + } break default: this.windowPresenter.sendToAllWindows(eventName, ...args) @@ -77,73 +82,80 @@ export class EventBus extends EventEmitter { } /** - * 设置Tab展示器(用于精确的tab路由) + * 设置Tab展示器(用于兼容旧的 BrowserView 路由) */ - setTabPresenter(tabPresenter: ITabPresenter) { - this.tabPresenter = tabPresenter + setTabPresenter(_tabPresenter: ITabPresenter) { + // Intentionally kept as a compatibility hook for legacy initialization paths. } /** - * 向指定Tab发送事件 - * @param tabId Tab ID + * 向指定 webContents 发送事件 + * @param webContentsId webContents ID * @param eventName 事件名称 * @param args 事件参数 */ - sendToTab(tabId: number, eventName: string, ...args: unknown[]) { - if (!this.tabPresenter) { - console.warn('TabPresenter not available, cannot send to specific tab') + sendToWebContents(webContentsId: number, eventName: string, ...args: unknown[]) { + if (!this.windowPresenter) { + console.warn('WindowPresenter not available, cannot send to specific webContents') return } - // 获取Tab实例并发送事件 - this.tabPresenter - .getTab(tabId) - .then((tabView) => { - if (tabView && !tabView.webContents.isDestroyed()) { - tabView.webContents.send(eventName, ...args) - } else { - console.warn(`Tab ${tabId} not found or destroyed, cannot send event ${eventName}`) + this.windowPresenter + .sendToWebContents(webContentsId, eventName, ...args) + .then((sent) => { + if (!sent) { + console.warn( + `webContents ${webContentsId} not found or destroyed, cannot send event ${eventName}` + ) } }) .catch((error) => { - console.error(`Error sending event ${eventName} to tab ${tabId}:`, error) + console.error(`Error sending event ${eventName} to webContents ${webContentsId}:`, error) }) } /** - * 向指定窗口的活跃Tab发送事件 + * Deprecated alias for webContents routing. * @param windowId 窗口ID * @param eventName 事件名称 * @param args 事件参数 */ sendToActiveTab(windowId: number, eventName: string, ...args: unknown[]) { - if (!this.tabPresenter) { - console.warn('TabPresenter not available, cannot send to active tab') + if (!this.windowPresenter) { + console.warn('WindowPresenter not available, cannot send to active window content') return } - this.tabPresenter - .getActiveTabId(windowId) - .then((activeTabId) => { - if (activeTabId) { - this.sendToTab(activeTabId, eventName, ...args) - } else { - console.warn(`No active tab found for window ${windowId}`) + this.windowPresenter + .sendToActiveTab(windowId, eventName, ...args) + .then((sent) => { + if (!sent) { + console.warn(`No active content found for window ${windowId}`) } }) .catch((error) => { - console.error(`Error getting active tab for window ${windowId}:`, error) + console.error(`Error getting active content for window ${windowId}:`, error) }) } /** - * 向多个Tab广播事件 - * @param tabIds Tab ID数组 + * 向多个 webContents 广播事件 + * @param webContentsIds webContents ID数组 * @param eventName 事件名称 * @param args 事件参数 */ + broadcastToWebContents(webContentsIds: number[], eventName: string, ...args: unknown[]) { + webContentsIds.forEach((webContentsId) => + this.sendToWebContents(webContentsId, eventName, ...args) + ) + } + + sendToTab(tabId: number, eventName: string, ...args: unknown[]) { + this.sendToWebContents(tabId, eventName, ...args) + } + broadcastToTabs(tabIds: number[], eventName: string, ...args: unknown[]) { - tabIds.forEach((tabId) => this.sendToTab(tabId, eventName, ...args)) + this.broadcastToWebContents(tabIds, eventName, ...args) } } diff --git a/src/main/events.ts b/src/main/events.ts index 0f45a16d7..aff6bf737 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -181,15 +181,9 @@ export const SHORTCUT_EVENTS = { ZOOM_RESUME: 'shortcut:zoom-resume', CREATE_NEW_WINDOW: 'shortcut:create-new-window', CREATE_NEW_CONVERSATION: 'shortcut:create-new-conversation', - CREATE_NEW_TAB: 'shortcut:create-new-tab', - CLOSE_CURRENT_TAB: 'shortcut:close-current-tab', GO_SETTINGS: 'shortcut:go-settings', CLEAN_CHAT_HISTORY: 'shortcut:clean-chat-history', - DELETE_CONVERSATION: 'shortcut:delete-conversation', - SWITCH_TO_NEXT_TAB: 'shortcut:switch-to-next-tab', - SWITCH_TO_PREVIOUS_TAB: 'shortcut:switch-to-previous-tab', - SWITCH_TO_SPECIFIC_TAB: 'shortcut:switch-to-specific-tab', - SWITCH_TO_LAST_TAB: 'shortcut:switch-to-last-tab' + DELETE_CONVERSATION: 'shortcut:delete-conversation' } // 标签页相关事件 @@ -205,12 +199,11 @@ export const TAB_EVENTS = { // Yo Browser 相关事件 export const YO_BROWSER_EVENTS = { - TAB_CREATED: 'yo-browser:tab-created', - TAB_CLOSED: 'yo-browser:tab-closed', - TAB_ACTIVATED: 'yo-browser:tab-activated', - TAB_NAVIGATED: 'yo-browser:tab-navigated', - TAB_UPDATED: 'yo-browser:tab-updated', - TAB_COUNT_CHANGED: 'yo-browser:tab-count-changed', + WINDOW_CREATED: 'yo-browser:window-created', + WINDOW_UPDATED: 'yo-browser:window-updated', + WINDOW_CLOSED: 'yo-browser:window-closed', + WINDOW_FOCUSED: 'yo-browser:window-focused', + WINDOW_COUNT_CHANGED: 'yo-browser:window-count-changed', WINDOW_VISIBILITY_CHANGED: 'yo-browser:window-visibility-changed' } diff --git a/src/main/presenter/agentPresenter/index.ts b/src/main/presenter/agentPresenter/index.ts index fef9ad64e..cf027cbc5 100644 --- a/src/main/presenter/agentPresenter/index.ts +++ b/src/main/presenter/agentPresenter/index.ts @@ -108,10 +108,12 @@ export class AgentPresenter implements IAgentPresenter { }) this.utilityHandler = new UtilityHandler(handlerContext, { - getActiveConversation: (tabId) => this.sessionPresenter.getActiveConversation(tabId), - getActiveConversationId: (tabId) => this.sessionPresenter.getActiveConversationId(tabId), - createConversation: (title, settings, tabId) => - this.sessionPresenter.createConversation(title, settings, tabId) + getActiveConversation: (webContentsId) => + this.sessionPresenter.getActiveConversation(webContentsId), + getActiveConversationId: (webContentsId) => + this.sessionPresenter.getActiveConversationId(webContentsId), + createConversation: (title, settings, webContentsId) => + this.sessionPresenter.createConversation(title, settings, webContentsId) }) // Legacy IPC surface: dynamic proxy for ISessionPresenter methods. @@ -121,7 +123,7 @@ export class AgentPresenter implements IAgentPresenter { async sendMessage( agentId: string, content: string, - tabId?: number, + webContentsId?: number, selectedVariantsMap?: Record ): Promise { await this.logResolvedIfEnabled(agentId) @@ -159,7 +161,7 @@ export class AgentPresenter implements IAgentPresenter { userMessage.id ) - this.trackGeneratingMessage(assistantMessage, agentId, tabId) + this.trackGeneratingMessage(assistantMessage, agentId, webContentsId) await this.updateConversationAfterUserMessage(agentId) // Normal flow: skip lock acquisition (lock is only for permission resume) await this.sessionManager.startLoop(agentId, assistantMessage.id, { skipLockAcquisition: true }) @@ -292,12 +294,12 @@ export class AgentPresenter implements IAgentPresenter { ) } - async translateText(text: string, tabId: number): Promise { - return this.utilityHandler.translateText(text, tabId) + async translateText(text: string, webContentsId: number): Promise { + return this.utilityHandler.translateText(text, webContentsId) } - async askAI(text: string, tabId: number): Promise { - return this.utilityHandler.askAI(text, tabId) + async askAI(text: string, webContentsId: number): Promise { + return this.utilityHandler.askAI(text, webContentsId) } async handlePermissionResponse( @@ -465,7 +467,7 @@ export class AgentPresenter implements IAgentPresenter { private trackGeneratingMessage( message: AssistantMessage, conversationId: string, - tabId?: number + webContentsId?: number ): void { this.generatingMessages.set(message.id, { message, @@ -476,7 +478,7 @@ export class AgentPresenter implements IAgentPresenter { reasoningStartTime: null, reasoningEndTime: null, lastReasoningTime: null, - tabId + webContentsId }) } diff --git a/src/main/presenter/agentPresenter/loop/toolCallHandler.ts b/src/main/presenter/agentPresenter/loop/toolCallHandler.ts index 4b31d246f..835c6c32e 100644 --- a/src/main/presenter/agentPresenter/loop/toolCallHandler.ts +++ b/src/main/presenter/agentPresenter/loop/toolCallHandler.ts @@ -339,7 +339,7 @@ export class ToolCallHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, {}, state.message.content ) @@ -465,7 +465,7 @@ export class ToolCallHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, {}, state.message.content ) diff --git a/src/main/presenter/agentPresenter/streaming/contentBufferHandler.ts b/src/main/presenter/agentPresenter/streaming/contentBufferHandler.ts index 8d657d7a9..386c46b63 100644 --- a/src/main/presenter/agentPresenter/streaming/contentBufferHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/contentBufferHandler.ts @@ -109,7 +109,7 @@ export class ContentBufferHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, { content: batchContent }, state.message.content ) @@ -152,7 +152,7 @@ export class ContentBufferHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, { content }, state.message.content ) diff --git a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts index 7d53c0892..780823d70 100644 --- a/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts +++ b/src/main/presenter/agentPresenter/streaming/llmEventHandler.ts @@ -107,7 +107,7 @@ export class LLMEventHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, {}, state.message.content ) @@ -380,7 +380,7 @@ export class LLMEventHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, delta, state.message.content ) @@ -487,7 +487,7 @@ export class LLMEventHandler { state.conversationId, state.message.parentId, Boolean(state.message.is_variant), - state.tabId, + state.webContentsId, {}, state.message.content ) diff --git a/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts b/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts index 0b9963345..cbcf4e646 100644 --- a/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts +++ b/src/main/presenter/agentPresenter/streaming/streamUpdateScheduler.ts @@ -32,7 +32,7 @@ interface SchedulerState { conversationId: string parentId?: string isVariant: boolean - tabId?: number + webContentsId?: number pendingDelta: PendingDelta contentSnapshot?: unknown seq: number @@ -58,7 +58,7 @@ export class StreamUpdateScheduler { conversationId: string parentId?: string isVariant: boolean - tabId?: number + webContentsId?: number }): SchedulerState { let state = this.states.get(options.eventId) if (!state) { @@ -67,7 +67,7 @@ export class StreamUpdateScheduler { conversationId: options.conversationId, parentId: options.parentId, isVariant: options.isVariant, - tabId: options.tabId, + webContentsId: options.webContentsId, pendingDelta: {}, seq: 0, hasSentInit: false, @@ -84,7 +84,7 @@ export class StreamUpdateScheduler { conversationId: string, parentId: string | undefined, isVariant: boolean, - tabId: number | undefined, + webContentsId: number | undefined, delta: Partial, contentSnapshot?: unknown ): void { @@ -95,7 +95,7 @@ export class StreamUpdateScheduler { conversationId, parentId, isVariant, - tabId, + webContentsId, { ...rest, content @@ -107,7 +107,7 @@ export class StreamUpdateScheduler { conversationId, parentId, isVariant, - tabId, + webContentsId, { reasoning_content }, @@ -121,7 +121,7 @@ export class StreamUpdateScheduler { conversationId, parentId, isVariant, - tabId + webContentsId }) if (contentSnapshot !== undefined) { diff --git a/src/main/presenter/agentPresenter/streaming/types.ts b/src/main/presenter/agentPresenter/streaming/types.ts index f63ee1131..e960eedf5 100644 --- a/src/main/presenter/agentPresenter/streaming/types.ts +++ b/src/main/presenter/agentPresenter/streaming/types.ts @@ -32,7 +32,7 @@ export interface GeneratingMessageState { flushTimeout?: NodeJS.Timeout throttleTimeout?: NodeJS.Timeout lastRendererUpdateTime?: number - tabId?: number + webContentsId?: number } export type { StreamUpdateScheduler } from './streamUpdateScheduler' diff --git a/src/main/presenter/agentPresenter/utility/utilityHandler.ts b/src/main/presenter/agentPresenter/utility/utilityHandler.ts index 12f26ba31..39cc2ed69 100644 --- a/src/main/presenter/agentPresenter/utility/utilityHandler.ts +++ b/src/main/presenter/agentPresenter/utility/utilityHandler.ts @@ -21,22 +21,22 @@ const TRANSLATION_TIMEOUT_MS = 1000 const DEFAULT_MESSAGE_LENGTH = 300 export interface UtilityHandlerOptions { - getActiveConversation: (tabId: number) => Promise - getActiveConversationId: (tabId: number) => Promise + getActiveConversation: (webContentsId: number) => Promise + getActiveConversationId: (webContentsId: number) => Promise createConversation: ( title: string, settings: Partial, - tabId: number + webContentsId: number ) => Promise } export class UtilityHandler extends BaseHandler { - private readonly getActiveConversation: (tabId: number) => Promise - private readonly getActiveConversationId: (tabId: number) => Promise + private readonly getActiveConversation: (webContentsId: number) => Promise + private readonly getActiveConversationId: (webContentsId: number) => Promise private readonly createConversation: ( title: string, settings: Partial, - tabId: number + webContentsId: number ) => Promise constructor(context: ThreadHandlerContext, options: UtilityHandlerOptions) { @@ -46,9 +46,9 @@ export class UtilityHandler extends BaseHandler { this.createConversation = options.createConversation } - async translateText(text: string, tabId: number): Promise { + async translateText(text: string, webContentsId: number): Promise { try { - let conversation = await this.getActiveConversation(tabId) + let conversation = await this.getActiveConversation(webContentsId) if (!conversation) { // Create a temporary conversation for translation const defaultProvider = this.ctx.configPresenter.getDefaultProviders()[0] @@ -60,7 +60,7 @@ export class UtilityHandler extends BaseHandler { modelId: defaultModel.id, providerId: defaultProvider.id }, - tabId + webContentsId ) conversation = await this.getConversation(conversationId) } @@ -111,9 +111,9 @@ export class UtilityHandler extends BaseHandler { } } - async askAI(text: string, tabId: number): Promise { + async askAI(text: string, webContentsId: number): Promise { try { - let conversation = await this.getActiveConversation(tabId) + let conversation = await this.getActiveConversation(webContentsId) if (!conversation) { // Create a temporary conversation for AI query const defaultProvider = this.ctx.configPresenter.getDefaultProviders()[0] @@ -125,7 +125,7 @@ export class UtilityHandler extends BaseHandler { modelId: defaultModel.id, providerId: defaultProvider.id }, - tabId + webContentsId ) conversation = await this.getConversation(conversationId) } @@ -207,8 +207,9 @@ export class UtilityHandler extends BaseHandler { } } - async summaryTitles(tabId?: number, conversationId?: string): Promise { - const activeId = tabId !== undefined ? await this.getActiveConversationId(tabId) : null + async summaryTitles(webContentsId?: number, conversationId?: string): Promise { + const activeId = + webContentsId !== undefined ? await this.getActiveConversationId(webContentsId) : null const targetConversationId = conversationId ?? activeId ?? undefined if (!targetConversationId) { throw new Error('Conversation not found') diff --git a/src/main/presenter/browser/BrowserContextBuilder.ts b/src/main/presenter/browser/BrowserContextBuilder.ts index c6a346ce2..b7e19ce71 100644 --- a/src/main/presenter/browser/BrowserContextBuilder.ts +++ b/src/main/presenter/browser/BrowserContextBuilder.ts @@ -1,21 +1,21 @@ -import type { BrowserTabInfo, BrowserToolDefinition } from '@shared/types/browser' +import type { BrowserToolDefinition, BrowserWindowInfo } from '@shared/types/browser' export class BrowserContextBuilder { - static buildSystemPrompt(tabs: BrowserTabInfo[], activeTabId: string | null): string { - const activeTab = tabs.find((tab) => tab.id === activeTabId) - const tabLines = - tabs.length === 0 - ? ['- No tabs open.'] - : tabs.map((tab) => { - const marker = tab.id === activeTabId ? '*' : ' ' - const title = tab.title || tab.url || 'Untitled' - return `${marker} ${title} (${tab.url || 'about:blank'})` + static buildSystemPrompt(windows: BrowserWindowInfo[], activeWindowId: number | null): string { + const activeWindow = windows.find((browserWindow) => browserWindow.id === activeWindowId) + const windowLines = + windows.length === 0 + ? ['- No browser windows open.'] + : windows.map((browserWindow) => { + const marker = browserWindow.id === activeWindowId ? '*' : ' ' + const title = browserWindow.page.title || browserWindow.page.url || 'Untitled' + return `${marker} ${title} (${browserWindow.page.url || 'about:blank'})` }) return [ 'Yo Browser is available for web exploration.', - `Active tab: ${activeTab ? `${activeTab.title || activeTab.url} (${activeTab.id})` : 'none'}`, - 'Open tabs:', - ...tabLines, + `Active window: ${activeWindow ? `${activeWindow.page.title || activeWindow.page.url} (${activeWindow.id})` : 'none'}`, + 'Open browser windows:', + ...windowLines, 'Use Yo Browser to browse, extract DOM, run scripts, capture screenshots, and download files.' ].join('\n') } diff --git a/src/main/presenter/browser/BrowserTab.ts b/src/main/presenter/browser/BrowserTab.ts index 348b7703e..1721cef20 100644 --- a/src/main/presenter/browser/BrowserTab.ts +++ b/src/main/presenter/browser/BrowserTab.ts @@ -1,16 +1,20 @@ import { WebContents } from 'electron' import { nanoid } from 'nanoid' -import { BrowserTabStatus, type ScreenshotOptions } from '@shared/types/browser' +import { + BrowserPageStatus, + type BrowserPageInfo, + type ScreenshotOptions +} from '@shared/types/browser' import { CDPManager } from './CDPManager' import { ScreenshotManager } from './ScreenshotManager' export class BrowserTab { - readonly tabId: string + readonly pageId: string readonly createdAt: number url = 'about:blank' title = '' favicon = '' - status: BrowserTabStatus = BrowserTabStatus.Idle + status: BrowserPageStatus = BrowserPageStatus.Idle updatedAt: number private readonly webContents: WebContents private readonly cdpManager: CDPManager @@ -22,7 +26,7 @@ export class BrowserTab { cdpManager: CDPManager, screenshotManager: ScreenshotManager ) { - this.tabId = nanoid(12) + this.pageId = nanoid(12) this.createdAt = Date.now() this.updatedAt = this.createdAt this.webContents = webContents @@ -36,8 +40,12 @@ export class BrowserTab { return this.webContents } + get tabId(): string { + return this.pageId + } + async navigate(url: string, timeoutMs?: number): Promise { - this.status = BrowserTabStatus.Loading + this.status = BrowserPageStatus.Loading this.url = url this.updatedAt = Date.now() await this.ensureSession() @@ -45,11 +53,11 @@ export class BrowserTab { await this.webContents.loadURL(url) await this.waitForLoad(timeoutMs) this.title = this.webContents.getTitle() || url - this.status = BrowserTabStatus.Ready + this.status = BrowserPageStatus.Ready this.updatedAt = Date.now() } catch (error) { - this.status = BrowserTabStatus.Error - console.error(`[YoBrowser][${this.tabId}] navigate failed:`, error) + this.status = BrowserPageStatus.Error + console.error(`[YoBrowser][${this.pageId}] navigate failed:`, error) throw error } } @@ -622,7 +630,7 @@ export class BrowserTab { this.webContents.debugger.detach() } } catch (error) { - console.warn(`[YoBrowser][${this.tabId}] failed to detach debugger:`, error) + console.warn(`[YoBrowser][${this.pageId}] failed to detach debugger:`, error) } finally { this.isAttached = false } @@ -644,10 +652,22 @@ export class BrowserTab { await this.cdpManager.createSession(this.webContents) this.isAttached = true } catch (error) { - console.error(`[YoBrowser][${this.tabId}] failed to create CDP session`, error) + console.error(`[YoBrowser][${this.pageId}] failed to create CDP session`, error) throw error } } return this.webContents.debugger } + + toPageInfo(): BrowserPageInfo { + return { + id: this.pageId, + url: this.url, + title: this.title, + favicon: this.favicon, + status: this.status, + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } } diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 5ffb46c4c..fb4fa2b79 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -1,28 +1,40 @@ import { BrowserWindow, WebContents, screen } from 'electron' import type { Rectangle } from 'electron' import { eventBus, SendTarget } from '@/eventbus' -import { TAB_EVENTS, YO_BROWSER_EVENTS } from '@/events' -import { BrowserTabInfo, BrowserContextSnapshot, ScreenshotOptions } from '@shared/types/browser' -import { - IYoBrowserPresenter, +import { YO_BROWSER_EVENTS } from '@/events' +import type { + BrowserContextSnapshot, + BrowserTabInfo, + BrowserWindowInfo, + ScreenshotOptions +} from '@shared/types/browser' +import type { DownloadInfo, + ITabPresenter, IWindowPresenter, - ITabPresenter + IYoBrowserPresenter } from '@shared/presenter' -import { BrowserTab } from './BrowserTab' +import { BrowserTab as BrowserPage } from './BrowserTab' import { CDPManager } from './CDPManager' import { ScreenshotManager } from './ScreenshotManager' import { DownloadManager } from './DownloadManager' import { clearYoBrowserSessionData } from './yoBrowserSession' import { YoBrowserToolHandler } from './YoBrowserToolHandler' +type BrowserWindowState = { + id: number + viewId: number + page: BrowserPage + createdAt: number + updatedAt: number +} + export class YoBrowserPresenter implements IYoBrowserPresenter { - private windowId: number | null = null - private readonly tabIds: Map = new Map() - private readonly viewIdToTabId: Map = new Map() - private readonly tabIdToBrowserTab: Map = new Map() - private activeTabId: string | null = null - private readonly maxTabs = 5 + private readonly browserWindows = new Map() + private readonly viewIdToWindowId = new Map() + private readonly pageIdToWindowId = new Map() + private readonly attachedWindowIds = new Set() + private activeWindowId: number | null = null private readonly cdpManager = new CDPManager() private readonly screenshotManager = new ScreenshotManager(this.cdpManager) private readonly downloadManager = new DownloadManager() @@ -34,340 +46,496 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { this.windowPresenter = windowPresenter this.tabPresenter = tabPresenter this.toolHandler = new YoBrowserToolHandler(this) - eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => this.handleTabClosed(tabId)) } async initialize(): Promise { - // Lazy initialization: only create browser window/tabs when explicitly requested. + // Lazy initialization only. } - async ensureWindow(options?: { x?: number; y?: number }): Promise { - const window = this.getWindow() - if (window) return window.id + async ensureWindow(): Promise { + const existing = this.getResolvedWindowState() + if (existing) { + return existing.id + } - this.windowId = await this.windowPresenter.createShellWindow({ - windowType: 'browser', - x: options?.x, - y: options?.y - }) + const created = await this.createWindowState('about:blank') + return created?.id ?? null + } - const created = this.getWindow() - if (created) { - created.on('closed', () => this.handleWindowClosed()) - this.emitVisibility(created.isVisible()) + async openWindow(url?: string): Promise { + const referenceBounds = this.getReferenceBounds() + const defaultBounds: Rectangle = { + x: 0, + y: 0, + width: 600, + height: 620 + } + const position = this.calculateWindowPosition(defaultBounds, referenceBounds) + const created = await this.createWindowState(url ?? 'about:blank', position) + if (!created) { + return null } - return this.windowId + this.windowPresenter.show(created.id, true) + this.setActiveWindowId(created.id) + this.emitWindowVisibility(created.id, true) + this.emitWindowUpdated(created) + return this.toWindowInfo(created) } - async hasWindow(): Promise { - return this.windowId !== null && this.getWindow() !== null + async focusWindow(windowId: number): Promise { + const state = this.browserWindows.get(windowId) + if (!state) return + this.windowPresenter.show(windowId, true) + this.setActiveWindowId(windowId) + this.emitWindowVisibility(windowId, true) + this.emitWindowUpdated(state) } - async show(shouldFocus: boolean = true): Promise { - const existingWindow = this.getWindow() - const referenceBounds = existingWindow - ? this.getReferenceBounds(existingWindow.id) - : this.getReferenceBounds() - - // Calculate position before creating window if it doesn't exist - let initialPosition: { x: number; y: number } | undefined - if (!existingWindow && referenceBounds) { - // Use default window size for calculation (browser window is 600px wide) - const defaultBounds: Rectangle = { - x: 0, - y: 0, - width: 600, - height: 620 - } - initialPosition = this.calculateWindowPosition(defaultBounds, referenceBounds) - } + async closeWindow(windowId: number): Promise { + if (!this.browserWindows.has(windowId)) return + await this.windowPresenter.closeWindow(windowId, true) + } - await this.ensureWindow({ - x: initialPosition?.x, - y: initialPosition?.y - }) + async listWindows(): Promise { + return Array.from(this.browserWindows.values()) + .sort((left, right) => right.updatedAt - left.updatedAt) + .map((state) => this.toWindowInfo(state)) + } - if (this.tabIdToBrowserTab.size === 0) { - await this.createTab('about:blank') - } + async getActiveWindow(): Promise { + const state = this.getResolvedWindowState() + return state ? this.toWindowInfo(state) : null + } - const window = this.getWindow() - if (window && !window.isDestroyed()) { - // If window already existed, recalculate position based on actual bounds - if (existingWindow) { - const currentReferenceBounds = this.getReferenceBounds(window.id) - const position = this.calculateWindowPosition(window.getBounds(), currentReferenceBounds) - window.setPosition(position.x, position.y) - } + async getWindowById(windowId: number): Promise { + const state = this.browserWindows.get(windowId) + return state ? this.toWindowInfo(state) : null + } - // For existing windows, directly show them (they're already ready) - // For new windows, wait for ready-to-show event - if (existingWindow) { - // Window already exists, just show it directly - this.windowPresenter.show(window.id, shouldFocus) - this.emitVisibility(true) - } else { - // New window, wait for ready-to-show - const reveal = () => { - if (!window.isDestroyed()) { - this.windowPresenter.show(window.id, shouldFocus) - this.emitVisibility(true) - } - } - if (window.isVisible()) { - reveal() - } else { - window.once('ready-to-show', reveal) - } + async hasWindow(): Promise { + return this.browserWindows.size > 0 + } + + async show(shouldFocus: boolean = true): Promise { + const existing = this.getResolvedWindowState() + if (existing) { + this.windowPresenter.show(existing.id, shouldFocus) + if (shouldFocus) { + this.setActiveWindowId(existing.id) } + this.emitWindowVisibility(existing.id, true) + return } + + await this.openWindow('about:blank') } async hide(): Promise { - const window = this.getWindow() - if (window) { - this.windowPresenter.hide(window.id) - this.emitVisibility(false) - } + const state = this.getResolvedWindowState() + if (!state) return + this.windowPresenter.hide(state.id) + this.emitWindowVisibility(state.id, false) } async toggleVisibility(): Promise { - await this.ensureWindow() - const window = this.getWindow() - if (!window) return false + const state = this.getResolvedWindowState() + if (!state) { + await this.openWindow('about:blank') + return true + } + + const window = BrowserWindow.fromId(state.id) + if (!window || window.isDestroyed()) { + await this.openWindow('about:blank') + return true + } + if (window.isVisible()) { await this.hide() return false } - await this.show() + + await this.focusWindow(state.id) return true } async isVisible(): Promise { - const window = this.getWindow() - return Boolean(window?.isVisible()) + const state = this.getResolvedWindowState() + if (!state) return false + const window = BrowserWindow.fromId(state.id) + return Boolean(window && !window.isDestroyed() && window.isVisible()) } - async listTabs(): Promise { - await this.syncActiveTabId() - return Array.from(this.tabIdToBrowserTab.values()).map((tab) => this.toTabInfo(tab)) + async navigateWindow(windowId: number, url: string, timeoutMs?: number): Promise { + const state = this.browserWindows.get(windowId) + if (!state) { + const created = await this.openWindow(url) + if (!created) { + throw new Error(`Browser window ${windowId} not found`) + } + return + } + + await state.page.navigate(url, timeoutMs) + state.updatedAt = Date.now() + this.emitWindowUpdated(state) } - async getActiveTab(): Promise { - await this.syncActiveTabId() - if (!this.activeTabId) return null - const tab = this.tabIdToBrowserTab.get(this.activeTabId) - const result = tab ? this.toTabInfo(tab) : null - return result + async goBack(target?: number | string): Promise { + const state = this.getResolvedWindowState(target) + if (!state) return + await state.page.goBack() + state.updatedAt = Date.now() + this.emitWindowUpdated(state) } - async getTabById(tabId: string): Promise { - const tab = this.tabIdToBrowserTab.get(tabId) - if (!tab || tab.contents.isDestroyed()) return null - return this.toTabInfo(tab) + async goForward(target?: number | string): Promise { + const state = this.getResolvedWindowState(target) + if (!state) return + await state.page.goForward() + state.updatedAt = Date.now() + this.emitWindowUpdated(state) } - async goBack(tabId?: string): Promise { - const tab = await this.resolveTab(tabId) - if (tab?.contents.canGoBack()) { - tab.contents.goBack() + async reload(target?: number | string): Promise { + const state = this.getResolvedWindowState(target) + if (!state) return + await state.page.reload() + state.updatedAt = Date.now() + this.emitWindowUpdated(state) + } + + async getNavigationState(target?: number | string): Promise<{ + canGoBack: boolean + canGoForward: boolean + }> { + const state = this.getResolvedWindowState(target) + if (!state || state.page.contents.isDestroyed()) { + return { + canGoBack: false, + canGoForward: false + } + } + + return { + canGoBack: state.page.contents.canGoBack(), + canGoForward: state.page.contents.canGoForward() } } - async goForward(tabId?: string): Promise { - const tab = await this.resolveTab(tabId) - if (tab?.contents.canGoForward()) { - tab.contents.goForward() + async getBrowserContext(): Promise { + return { + activeWindowId: this.getResolvedWindowState()?.id ?? null, + windows: await this.listWindows() } } - async reload(tabId?: string): Promise { - const tab = await this.resolveTab(tabId) - if (tab && !tab.contents.isDestroyed()) { - tab.contents.reload() + async captureScreenshot(target: string | number, options?: ScreenshotOptions): Promise { + const state = this.getResolvedWindowState(target) + if (!state) { + throw new Error(`Browser target ${String(target)} not found`) } + return await state.page.takeScreenshot(options) } - async createTab(url?: string): Promise { - await this.ensureWindow() - const windowId = this.windowId - if (!windowId) return null - - if (this.tabIdToBrowserTab.size >= this.maxTabs) { - const reusable = this.findReusableTab(url || '') - if (reusable) { - await reusable.navigate(url || reusable.url) - await this.activateTab(reusable.tabId) - return this.toTabInfo(reusable) - } + async extractDom(target: string | number, selector?: string): Promise { + const state = this.getResolvedWindowState(target) + if (!state) { + throw new Error(`Browser target ${String(target)} not found`) + } + return await state.page.extractDOM(selector) + } - const oldest = this.findOldestTab() - if (oldest) { - await this.closeTab(oldest.tabId) - } + async evaluateScript(target: string | number, script: string): Promise { + const state = this.getResolvedWindowState(target) + if (!state) { + throw new Error(`Browser target ${String(target)} not found`) } + return await state.page.evaluateScript(script) + } - const targetUrl = url || 'about:blank' - const viewId = await this.tabPresenter.createTab(windowId, targetUrl, { active: true }) - if (viewId === null) return null - const view = await this.tabPresenter.getTab(viewId as number) - if (!view) return null + async startDownload(url: string, savePath?: string): Promise { + const state = this.getResolvedWindowState() + if (!state || state.page.contents.isDestroyed()) { + throw new Error('No active browser window available') + } + return await this.downloadManager.downloadFile(url, savePath, state.page.contents) + } - const browserTab = new BrowserTab(view.webContents, this.cdpManager, this.screenshotManager) - const tabKey = browserTab.tabId - this.tabIds.set(tabKey, viewId as number) - this.viewIdToTabId.set(view.webContents.id, tabKey) - this.tabIdToBrowserTab.set(tabKey, browserTab) - this.tabPresenter.setTabBrowserId(viewId as number, tabKey) - this.activeTabId = tabKey + async clearSandboxData(): Promise { + await clearYoBrowserSessionData() + for (const state of this.browserWindows.values()) { + if (!state.page.contents.isDestroyed()) { + state.page.contents.reloadIgnoringCache() + } + } + } - this.setupTabListeners(tabKey, viewId as number, view.webContents) - this.emitTabCreated(browserTab) - this.emitTabCount() + async shutdown(): Promise { + const windowIds = Array.from(this.browserWindows.keys()) + for (const windowId of windowIds) { + await this.windowPresenter.closeWindow(windowId, true) + } + } - const result = this.toTabInfo(browserTab) - return result + // Deprecated wrappers kept temporarily while callers migrate to window semantics. + async listTabs(): Promise { + return (await this.listWindows()).map((browserWindow) => ({ + ...browserWindow.page, + isActive: browserWindow.id === this.activeWindowId + })) } - async navigateTab(tabId: string, url: string, timeoutMs?: number): Promise { - let tab = this.tabIdToBrowserTab.get(tabId) - if (!tab || tab.contents.isDestroyed()) { - const created = await this.createTab(url) - if (!created) { - throw new Error('Failed to create tab for navigation') - } - tab = this.tabIdToBrowserTab.get(created.id) ?? undefined - this.activeTabId = created.id + async getActiveTab(): Promise { + const activeWindow = await this.getActiveWindow() + if (!activeWindow) { + return null } - if (!tab) { - throw new Error(`Tab ${tabId} not found`) + return { + ...activeWindow.page, + isActive: true } - if (tab.contents.isDestroyed()) { - throw new Error(`Tab ${tab.tabId} is destroyed`) + } + + async getTabById(pageId: string): Promise { + const state = this.getResolvedWindowState(pageId) + if (!state) { + return null + } + return { + ...state.page.toPageInfo(), + isActive: state.id === this.activeWindowId } - await tab.navigate(url, timeoutMs) - this.emitTabNavigated(tab.tabId, url) } - async activateTab(tabId: string): Promise { - const viewId = this.tabIds.get(tabId) - if (viewId === undefined) return - await this.tabPresenter.switchTab(viewId) - this.activeTabId = tabId - this.emitTabActivated(tabId) + async createTab(url?: string): Promise { + const browserWindow = await this.openWindow(url ?? 'about:blank') + if (!browserWindow) { + return null + } + return { + ...browserWindow.page, + isActive: true + } } - async closeTab(tabId: string): Promise { - const viewId = this.tabIds.get(tabId) - if (viewId !== undefined) { - await this.tabPresenter.closeTab(viewId) + async navigateTab(pageId: string, url: string, timeoutMs?: number): Promise { + const state = this.getResolvedWindowState(pageId) + if (!state) { + throw new Error(`Browser page ${pageId} not found`) } - this.cleanupTab(tabId) + await this.navigateWindow(state.id, url, timeoutMs) + } + + async activateTab(pageId: string): Promise { + const state = this.getResolvedWindowState(pageId) + if (!state) return + await this.focusWindow(state.id) + } + + async closeTab(pageId: string): Promise { + const state = this.getResolvedWindowState(pageId) + if (!state) return + await this.closeWindow(state.id) } async reuseTab(url: string): Promise { - const reusable = this.findReusableTab(url) - if (reusable) { - await reusable.navigate(url) - await this.activateTab(reusable.tabId) - return this.toTabInfo(reusable) - } - - if (this.tabIdToBrowserTab.size >= this.maxTabs) { - const oldest = this.findOldestTab() - if (oldest) { - await this.closeTab(oldest.tabId) - return this.createTab(url) + const existing = this.findReusableWindow(url) + if (existing) { + await this.navigateWindow(existing.id, url) + await this.focusWindow(existing.id) + return { + ...existing.page.toPageInfo(), + isActive: true } } - return await this.createTab(url) } - async getBrowserContext(): Promise { - return { - activeTabId: this.activeTabId, - tabs: await this.listTabs() + async getTabIdByViewId(viewId: number): Promise { + const windowId = this.viewIdToWindowId.get(viewId) + if (windowId == null) { + return null } + const state = this.browserWindows.get(windowId) + return state?.page.pageId ?? null } - async getNavigationState(tabId?: string): Promise<{ - canGoBack: boolean - canGoForward: boolean - }> { - const tab = await this.resolveTab(tabId) - if (!tab || tab.contents.isDestroyed()) { - return { canGoBack: false, canGoForward: false } + async getBrowserTab(target?: string | number): Promise { + return this.getResolvedWindowState(target)?.page ?? null + } + + private async createWindowState( + url: string, + position?: { x: number; y: number } + ): Promise { + const browserWindowId = await this.createBrowserWindow(position) + if (!browserWindowId) { + return null } - return { - canGoBack: tab.contents.canGoBack(), - canGoForward: tab.contents.canGoForward() + + const viewId = await this.tabPresenter.createTab(browserWindowId, url, { active: true }) + if (viewId == null) { + await this.windowPresenter.closeWindow(browserWindowId, true) + return null } - } - async getTabIdByViewId(viewId: number): Promise { - return this.viewIdToTabId.get(viewId) ?? null - } + const view = await this.tabPresenter.getTab(viewId) + if (!view) { + await this.windowPresenter.closeWindow(browserWindowId, true) + return null + } - async captureScreenshot(tabId: string, options?: ScreenshotOptions): Promise { - const tab = await this.resolveTab(tabId) - if (!tab) { - throw new Error(`Tab ${tabId} not found`) + const page = new BrowserPage(view.webContents, this.cdpManager, this.screenshotManager) + const now = Date.now() + const state: BrowserWindowState = { + id: browserWindowId, + viewId, + page, + createdAt: now, + updatedAt: now } - return await tab.takeScreenshot(options) + + this.browserWindows.set(browserWindowId, state) + this.viewIdToWindowId.set(viewId, browserWindowId) + this.pageIdToWindowId.set(page.pageId, browserWindowId) + this.attachWindowListeners(browserWindowId) + this.setupPageListeners(browserWindowId, page, view.webContents) + this.emitWindowCreated(state) + this.emitWindowCount() + return state } - async extractDom(tabId: string, selector?: string): Promise { - const tab = await this.resolveTab(tabId) - if (!tab) { - throw new Error(`Tab ${tabId} not found`) - } - return await tab.extractDOM(selector) + private async createBrowserWindow(position?: { x: number; y: number }): Promise { + return await this.windowPresenter.createBrowserWindow(position) } - async evaluateScript(tabId: string, script: string): Promise { - const tab = await this.resolveTab(tabId) - if (!tab) { - throw new Error(`Tab ${tabId} not found`) + private attachWindowListeners(windowId: number): void { + if (this.attachedWindowIds.has(windowId)) { + return } - return await tab.evaluateScript(script) - } - async startDownload(url: string, savePath?: string): Promise { - const active = await this.resolveTab() - if (active?.contents?.isDestroyed()) { - throw new Error('Active tab is destroyed') + const window = BrowserWindow.fromId(windowId) + if (!window || window.isDestroyed()) { + return } - return await this.downloadManager.downloadFile(url, savePath, active?.contents) + + this.attachedWindowIds.add(windowId) + + window.on('focus', () => { + this.setActiveWindowId(windowId) + const state = this.browserWindows.get(windowId) + if (state) { + state.updatedAt = Date.now() + this.emitWindowUpdated(state) + } + }) + + window.on('show', () => { + this.emitWindowVisibility(windowId, true) + }) + + window.on('hide', () => { + this.emitWindowVisibility(windowId, false) + }) + + window.on('closed', () => { + this.cleanupWindow(windowId, true) + }) } - async clearSandboxData(): Promise { - await clearYoBrowserSessionData() - for (const tab of this.tabIdToBrowserTab.values()) { - if (!tab.contents.isDestroyed()) { - tab.contents.reloadIgnoringCache() + private setupPageListeners(windowId: number, page: BrowserPage, contents: WebContents): void { + contents.on('did-navigate', (_event, url) => { + const state = this.browserWindows.get(windowId) + if (!state) return + page.url = url + state.updatedAt = Date.now() + this.emitWindowUpdated(state) + }) + + contents.on('page-title-updated', (_event, title) => { + const state = this.browserWindows.get(windowId) + if (!state) return + page.title = title || page.url + state.updatedAt = Date.now() + this.emitWindowUpdated(state) + }) + + contents.on('page-favicon-updated', (_event, favicons) => { + const state = this.browserWindows.get(windowId) + if (!state || favicons.length === 0) return + if (page.favicon !== favicons[0]) { + page.favicon = favicons[0] + state.updatedAt = Date.now() + this.emitWindowUpdated(state) } + }) + + contents.on('destroyed', () => { + this.cleanupWindow(windowId, false) + }) + } + + private cleanupWindow(windowId: number, emitClosed: boolean): void { + const state = this.browserWindows.get(windowId) + if (!state) { + return } + + state.page.destroy() + this.browserWindows.delete(windowId) + this.viewIdToWindowId.delete(state.viewId) + this.pageIdToWindowId.delete(state.page.pageId) + this.attachedWindowIds.delete(windowId) + + if (this.activeWindowId === windowId) { + this.activeWindowId = this.getResolvedWindowState()?.id ?? null + this.emitWindowFocused(this.activeWindowId) + } + + if (emitClosed) { + this.emitWindowClosed(windowId) + } + + this.emitWindowCount() } - async shutdown(): Promise { - if (this.windowId) { - await this.windowPresenter.closeWindow(this.windowId, true) + private getResolvedWindowState(target?: number | string): BrowserWindowState | null { + if (typeof target === 'number') { + return this.browserWindows.get(target) ?? null } - this.cleanup() - this.emitTabCount() - this.emitVisibility(false) + + if (typeof target === 'string' && target.trim()) { + const windowId = this.pageIdToWindowId.get(target) + return windowId != null ? (this.browserWindows.get(windowId) ?? null) : null + } + + const activeFromFocused = this.findFocusedBrowserWindow() + if (activeFromFocused) { + this.activeWindowId = activeFromFocused.id + return activeFromFocused + } + + if (this.activeWindowId != null) { + const activeState = this.browserWindows.get(this.activeWindowId) + if (activeState) { + return activeState + } + } + + const [latest] = Array.from(this.browserWindows.values()).sort( + (left, right) => right.updatedAt - left.updatedAt + ) + return latest ?? null } - private getWindow(): BrowserWindow | null { - if (!this.windowId) return null - const window = BrowserWindow.fromId(this.windowId) - if (!window || window.isDestroyed()) { - this.windowId = null + private findFocusedBrowserWindow(): BrowserWindowState | null { + const focusedWindow = this.windowPresenter.getFocusedWindow() + if (!focusedWindow || focusedWindow.isDestroyed()) { return null } - return window + return this.browserWindows.get(focusedWindow.id) ?? null } private getReferenceBounds(excludeWindowId?: number): Rectangle | undefined { @@ -375,10 +543,11 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { if (focused && !focused.isDestroyed() && focused.id !== excludeWindowId) { return focused.getBounds() } - const fallback = this.windowPresenter + + return this.windowPresenter .getAllWindows() .find((candidate) => candidate.id !== excludeWindowId) - return fallback?.getBounds() + ?.getBounds() } private calculateWindowPosition( @@ -386,7 +555,6 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { referenceBounds?: Rectangle ): { x: number; y: number } { if (!referenceBounds) { - // 如果没有参考窗口,使用默认位置 const display = screen.getDisplayMatching(windowBounds) const { workArea } = display return { @@ -399,11 +567,9 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { const display = screen.getDisplayMatching(referenceBounds) const { workArea } = display - // Browser 窗口尺寸 const browserWidth = windowBounds.width const browserHeight = windowBounds.height - // 计算主窗口右侧和左侧的空间 const spaceOnRight = workArea.x + workArea.width - (referenceBounds.x + referenceBounds.width) const spaceOnLeft = referenceBounds.x - workArea.x @@ -411,26 +577,20 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { let targetY: number if (spaceOnRight >= browserWidth + gap) { - // 显示在主窗口右侧 targetX = referenceBounds.x + referenceBounds.width + gap targetY = referenceBounds.y + (referenceBounds.height - browserHeight) / 2 } else if (spaceOnLeft >= browserWidth + gap) { - // 显示在主窗口左侧 targetX = referenceBounds.x - browserWidth - gap targetY = referenceBounds.y + (referenceBounds.height - browserHeight) / 2 } else { - // 空间不够,显示在主窗口下方 targetX = referenceBounds.x const spaceBelow = workArea.y + workArea.height - (referenceBounds.y + referenceBounds.height) - if (spaceBelow >= browserHeight + gap) { - targetY = referenceBounds.y + referenceBounds.height + gap - } else { - // 下方空间也不够,显示在主窗口上方 - targetY = referenceBounds.y - browserHeight - gap - } + targetY = + spaceBelow >= browserHeight + gap + ? referenceBounds.y + referenceBounds.height + gap + : referenceBounds.y - browserHeight - gap } - // 确保窗口在屏幕范围内 const clampedX = Math.max( workArea.x, Math.min(targetX, workArea.x + workArea.width - browserWidth) @@ -440,202 +600,86 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { Math.min(targetY, workArea.y + workArea.height - browserHeight) ) - return { x: Math.round(clampedX), y: Math.round(clampedY) } - } - - private handleWindowClosed(): void { - this.cleanup() - this.emitVisibility(false) - this.emitTabCount() + return { + x: Math.round(clampedX), + y: Math.round(clampedY) + } } - private setupTabListeners(tabId: string, viewId: number, contents: WebContents): void { - contents.on('did-navigate', (_event, url) => { - const tab = this.tabIdToBrowserTab.get(tabId) - if (!tab) return - tab.url = url - tab.updatedAt = Date.now() - this.emitTabNavigated(tabId, url) - }) - - contents.on('page-title-updated', (_event, title) => { - const tab = this.tabIdToBrowserTab.get(tabId) - if (!tab) return - tab.title = title || tab.url - tab.updatedAt = Date.now() - this.emitTabUpdated(tab) - }) - - contents.on('page-favicon-updated', (_event, favicons) => { - if (favicons.length > 0) { - const tab = this.tabIdToBrowserTab.get(tabId) - if (!tab) return - if (tab.favicon !== favicons[0]) { - tab.favicon = favicons[0] - tab.updatedAt = Date.now() - this.emitTabUpdated(tab) - } - } - }) - - contents.on('destroyed', () => { - const mappedId = this.viewIdToTabId.get(viewId) - if (mappedId) { - this.cleanupTab(mappedId) - } - }) - } + private findReusableWindow(url: string): BrowserWindowState | null { + if (!url) { + return this.getResolvedWindowState() + } - private findReusableTab(url: string): BrowserTab | null { - if (!url) return this.findOldestTab() try { const targetHost = new URL(url).hostname - const sameHost = Array.from(this.tabIdToBrowserTab.values()).find((tab) => { + for (const state of this.browserWindows.values()) { try { - return new URL(tab.url).hostname === targetHost + if (new URL(state.page.url).hostname === targetHost) { + return state + } } catch { - return false - } - }) - if (sameHost) return sameHost - } catch { - // ignore parse errors - } - return this.findOldestTab() - } - - private findOldestTab(): BrowserTab | null { - const sorted = Array.from(this.tabIdToBrowserTab.values()).sort( - (a, b) => a.createdAt - b.createdAt - ) - return sorted[0] || null - } - - private async resolveTab(tabId?: string): Promise { - if (tabId) { - const target = this.tabIdToBrowserTab.get(tabId) - if (target && !target.contents.isDestroyed()) return target - } - await this.syncActiveTabId() - if (this.activeTabId) { - const active = this.tabIdToBrowserTab.get(this.activeTabId) - if (active && !active.contents.isDestroyed()) return active - } - const first = this.tabIdToBrowserTab.values().next().value as BrowserTab | undefined - if (first && !first.contents.isDestroyed()) return first - return null - } - - private async syncActiveTabId(): Promise { - if (!this.windowId) return - try { - const activeViewId = await this.tabPresenter.getActiveTabId(this.windowId) - if (activeViewId !== undefined) { - const mapped = this.viewIdToTabId.get(activeViewId) - if (mapped) { - this.activeTabId = mapped + // Ignore invalid URL parsing for existing pages. } } - } catch (error) { - console.warn('[YoBrowser] Failed to sync active tab id', error) + } catch { + // Ignore invalid URL parsing for requested URL. } - } - private handleTabClosed(tabId: number): void { - const mapped = this.viewIdToTabId.get(tabId) - if (mapped) { - this.cleanupTab(mapped) - } + return this.getResolvedWindowState() } - private cleanupTab(tabId: string): void { - if (!this.tabIdToBrowserTab.has(tabId)) { - return - } - const browserTab = this.tabIdToBrowserTab.get(tabId) - const viewId = this.tabIds.get(tabId) - if (browserTab) { - browserTab.destroy() - } - if (viewId !== undefined) { - this.viewIdToTabId.delete(viewId) - } - this.tabIds.delete(tabId) - this.tabIdToBrowserTab.delete(tabId) - if (this.activeTabId === tabId) { - const fallback = Array.from(this.tabIdToBrowserTab.keys()).find((id) => id !== tabId) - this.activeTabId = fallback ?? null - } - this.emitTabClosed(tabId) - this.emitTabCount() - } - - private toTabInfo(tab: BrowserTab): BrowserTabInfo { + private toWindowInfo(state: BrowserWindowState): BrowserWindowInfo { + const window = BrowserWindow.fromId(state.id) return { - id: tab.tabId, - url: tab.url, - title: tab.title, - favicon: tab.favicon, - isActive: tab.tabId === this.activeTabId, - status: tab.status, - createdAt: tab.createdAt, - updatedAt: tab.updatedAt + id: state.id, + page: state.page.toPageInfo(), + isFocused: Boolean(window && !window.isDestroyed() && window.isFocused()), + isVisible: Boolean(window && !window.isDestroyed() && window.isVisible()), + createdAt: state.createdAt, + updatedAt: state.updatedAt } } - private emitTabCreated(tab: BrowserTab) { - const info = this.toTabInfo(tab) - eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_CREATED, SendTarget.ALL_WINDOWS, info) + private setActiveWindowId(windowId: number | null): void { + this.activeWindowId = windowId + this.emitWindowFocused(windowId) } - private emitTabClosed(tabId: string) { - eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_CLOSED, SendTarget.ALL_WINDOWS, tabId) + private emitWindowCreated(state: BrowserWindowState): void { + eventBus.sendToRenderer(YO_BROWSER_EVENTS.WINDOW_CREATED, SendTarget.ALL_WINDOWS, { + window: this.toWindowInfo(state) + }) } - async getBrowserTab(tabId?: string): Promise { - return await this.resolveTab(tabId) + private emitWindowUpdated(state: BrowserWindowState): void { + eventBus.sendToRenderer(YO_BROWSER_EVENTS.WINDOW_UPDATED, SendTarget.ALL_WINDOWS, { + window: this.toWindowInfo(state) + }) } - private emitTabActivated(tabId: string) { - eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_ACTIVATED, SendTarget.ALL_WINDOWS, tabId) + private emitWindowClosed(windowId: number): void { + eventBus.sendToRenderer(YO_BROWSER_EVENTS.WINDOW_CLOSED, SendTarget.ALL_WINDOWS, { windowId }) } - private emitTabNavigated(tabId: string, url: string) { - eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_NAVIGATED, SendTarget.ALL_WINDOWS, { - tabId, - url + private emitWindowFocused(windowId: number | null): void { + eventBus.sendToRenderer(YO_BROWSER_EVENTS.WINDOW_FOCUSED, SendTarget.ALL_WINDOWS, { + windowId }) } - private emitTabUpdated(tab: BrowserTab) { - const info = this.toTabInfo(tab) - eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_UPDATED, SendTarget.ALL_WINDOWS, info) - } - - private emitTabCount() { + private emitWindowCount(): void { eventBus.sendToRenderer( - YO_BROWSER_EVENTS.TAB_COUNT_CHANGED, + YO_BROWSER_EVENTS.WINDOW_COUNT_CHANGED, SendTarget.ALL_WINDOWS, - this.tabIdToBrowserTab.size + this.browserWindows.size ) } - private emitVisibility(visible: boolean) { - eventBus.sendToRenderer( - YO_BROWSER_EVENTS.WINDOW_VISIBILITY_CHANGED, - SendTarget.ALL_WINDOWS, + private emitWindowVisibility(windowId: number, visible: boolean): void { + eventBus.sendToRenderer(YO_BROWSER_EVENTS.WINDOW_VISIBILITY_CHANGED, SendTarget.ALL_WINDOWS, { + windowId, visible - ) - } - - private cleanup() { - for (const tab of this.tabIdToBrowserTab.values()) { - tab.destroy() - } - this.tabIdToBrowserTab.clear() - this.tabIds.clear() - this.viewIdToTabId.clear() - this.activeTabId = null - this.windowId = null + }) } } diff --git a/src/main/presenter/browser/YoBrowserToolDefinitions.ts b/src/main/presenter/browser/YoBrowserToolDefinitions.ts index d8fbceff2..0c7bf1c6c 100644 --- a/src/main/presenter/browser/YoBrowserToolDefinitions.ts +++ b/src/main/presenter/browser/YoBrowserToolDefinitions.ts @@ -3,18 +3,18 @@ import { zodToJsonSchema } from 'zod-to-json-schema' import type { MCPToolDefinition } from '@shared/presenter' const yoBrowserSchemas = { - tab_list: z.object({}), - tab_new: z.object({ - url: z.string().url().optional().describe('Optional URL to navigate to when creating the tab') + window_list: z.object({}), + window_open: z.object({ + url: z.string().url().optional().describe('Optional URL to open in the new browser window') }), - tab_activate: z.object({ - tabId: z.string().min(1).describe('ID of the tab to activate') + window_focus: z.object({ + windowId: z.number().int().positive().describe('Browser window ID') }), - tab_close: z.object({ - tabId: z.string().min(1).describe('ID of the tab to close') + window_close: z.object({ + windowId: z.number().int().positive().describe('Browser window ID') }), cdp_send: z.object({ - tabId: z.string().optional().describe('Optional tab ID. If omitted, uses the active tab'), + windowId: z.number().int().positive().optional().describe('Optional browser window ID'), method: z .enum([ 'Page.navigate', @@ -30,187 +30,62 @@ const yoBrowserSchemas = { ]) .describe('Common CDP method name'), params: z - .union([ - z - .object({ - url: z.string().url().describe('Example: "https://example.com"') - }) - .describe('For Page.navigate. Example: {"url":"https://example.com"}'), - z - .object({ - ignoreCache: z.boolean().optional().describe('Example: true'), - scriptToEvaluateOnLoad: z - .string() - .optional() - .describe('Example: "console.log(document.title)"') - }) - .describe('For Page.reload. Example: {"ignoreCache":true}'), - z - .object({ - format: z.enum(['png', 'jpeg']).optional().describe('Example: "png"'), - quality: z.number().int().min(0).max(100).optional().describe('Example: 80'), - clip: z - .object({ - x: z.number().describe('Example: 0'), - y: z.number().describe('Example: 0'), - width: z.number().positive().describe('Example: 800'), - height: z.number().positive().describe('Example: 600'), - scale: z.number().positive().optional().describe('Example: 1') - }) - .optional() - .describe('Example: {"x":0,"y":0,"width":800,"height":600,"scale":1}') - }) - .describe('For Page.captureScreenshot. Example: {"format":"png"}'), - z - .object({ - expression: z.string().min(1).describe('Example: "document.title"'), - returnByValue: z.boolean().optional().describe('Example: true'), - awaitPromise: z.boolean().optional().describe('Example: true') - }) - .describe( - 'For Runtime.evaluate. Example: {"expression":"document.title","returnByValue":true}' - ), - z - .object({ - depth: z.number().int().min(0).optional().describe('Example: 1'), - pierce: z.boolean().optional().describe('Example: true') - }) - .describe('For DOM.getDocument. Example: {"depth":1,"pierce":true}'), - z - .object({ - nodeId: z.number().int().positive().describe('Example: 1'), - selector: z.string().min(1).describe('Example: "body"') - }) - .describe('For DOM.querySelector. Example: {"nodeId":1,"selector":"body"}'), - z - .object({ - nodeId: z.number().int().positive().describe('Example: 1'), - selector: z.string().min(1).describe('Example: "a"') - }) - .describe('For DOM.querySelectorAll. Example: {"nodeId":1,"selector":"a"}'), - z - .object({ - nodeId: z.number().int().positive().describe('Example: 1') - }) - .describe('For DOM.getOuterHTML. Example: {"nodeId":1}'), - z - .object({ - type: z - .enum(['mousePressed', 'mouseReleased', 'mouseMoved']) - .describe('Example: "mousePressed"'), - x: z.number().describe('Example: 120'), - y: z.number().describe('Example: 240'), - button: z - .enum(['none', 'left', 'middle', 'right']) - .optional() - .describe('Example: "left"'), - clickCount: z.number().int().min(1).optional().describe('Example: 1') - }) - .describe( - 'For Input.dispatchMouseEvent. Example: {"type":"mousePressed","x":120,"y":240,"button":"left","clickCount":1}' - ), - z - .object({ - type: z.enum(['keyDown', 'keyUp', 'rawKeyDown', 'char']).describe('Example: "keyDown"'), - key: z.string().optional().describe('Example: "a"'), - code: z.string().optional().describe('Example: "KeyA"'), - text: z.string().optional().describe('Example: "a"') - }) - .describe( - 'For Input.dispatchKeyEvent. Example: {"type":"keyDown","key":"a","code":"KeyA","text":"a"}' - ) - ]) - .describe('Parameters for the selected CDP method. Must be an object, not a JSON string') + .record(z.string(), z.unknown()) + .optional() + .describe('Parameters for the selected CDP method') }) } -export function getYoBrowserToolDefinitions(): MCPToolDefinition[] { - return [ - { - type: 'function', - function: { - name: 'yo_browser_tab_list', - description: 'List all browser tabs and identify the active tab', - parameters: zodToJsonSchema(yoBrowserSchemas.tab_list) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'yobrowser', - icons: '🌐', - description: 'YoBrowser CDP automation' - } - }, - { - type: 'function', - function: { - name: 'yo_browser_tab_new', - description: 'Create a new browser tab with an optional URL', - parameters: zodToJsonSchema(yoBrowserSchemas.tab_new) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'yobrowser', - icons: '🌐', - description: 'YoBrowser CDP automation' - } - }, - { - type: 'function', - function: { - name: 'yo_browser_tab_activate', - description: 'Make a specific tab the active tab', - parameters: zodToJsonSchema(yoBrowserSchemas.tab_activate) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'yobrowser', - icons: '🌐', - description: 'YoBrowser CDP automation' - } - }, - { - type: 'function', - function: { - name: 'yo_browser_tab_close', - description: 'Close a specific browser tab', - parameters: zodToJsonSchema(yoBrowserSchemas.tab_close) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'yobrowser', - icons: '🌐', - description: 'YoBrowser CDP automation' - } +function asParameters(schema: z.ZodTypeAny) { + return zodToJsonSchema(schema) as { + type: string + properties: Record + required?: string[] + } +} + +function toDefinition(name: string, description: string, schema: z.ZodTypeAny): MCPToolDefinition { + return { + type: 'function', + function: { + name, + description, + parameters: asParameters(schema) }, - { - type: 'function', - function: { - name: 'yo_browser_cdp_send', - description: - 'Send a Chrome DevTools Protocol (CDP) command to a browser tab. Use this for navigation, content extraction, and DOM interaction', - parameters: zodToJsonSchema(yoBrowserSchemas.cdp_send) as { - type: string - properties: Record - required?: string[] - } - }, - server: { - name: 'yobrowser', - icons: '🌐', - description: 'YoBrowser CDP automation' - } + server: { + name: 'yobrowser', + icons: '🌐', + description: 'YoBrowser CDP automation' } + } +} + +export function getYoBrowserToolDefinitions(): MCPToolDefinition[] { + return [ + toDefinition( + 'yo_browser_window_list', + 'List all browser windows and identify the active window', + yoBrowserSchemas.window_list + ), + toDefinition( + 'yo_browser_window_open', + 'Open a new browser window with an optional URL', + yoBrowserSchemas.window_open + ), + toDefinition( + 'yo_browser_window_focus', + 'Focus an existing browser window', + yoBrowserSchemas.window_focus + ), + toDefinition( + 'yo_browser_window_close', + 'Close an existing browser window', + yoBrowserSchemas.window_close + ), + toDefinition( + 'yo_browser_cdp_send', + 'Send a Chrome DevTools Protocol (CDP) command to a browser window page', + yoBrowserSchemas.cdp_send + ) ] } diff --git a/src/main/presenter/browser/YoBrowserToolHandler.ts b/src/main/presenter/browser/YoBrowserToolHandler.ts index ffc1f07a9..794a31313 100644 --- a/src/main/presenter/browser/YoBrowserToolHandler.ts +++ b/src/main/presenter/browser/YoBrowserToolHandler.ts @@ -16,32 +16,65 @@ export class YoBrowserToolHandler { async callTool(toolName: string, args: Record): Promise { try { switch (toolName) { - case 'yo_browser_tab_list': { - return await this.handleTabList() - } + case 'yo_browser_window_list': + case 'yo_browser_tab_list': + return await this.handleWindowList() + case 'yo_browser_window_open': case 'yo_browser_tab_new': { const url = typeof args.url === 'string' ? args.url : undefined - return await this.handleTabNew(url) + return await this.handleWindowOpen(url) + } + case 'yo_browser_window_focus': { + const windowId = typeof args.windowId === 'number' ? args.windowId : null + if (windowId == null) { + throw new Error('windowId is required') + } + return await this.handleWindowFocus(windowId) + } + case 'yo_browser_window_close': { + const windowId = typeof args.windowId === 'number' ? args.windowId : null + if (windowId == null) { + throw new Error('windowId is required') + } + return await this.handleWindowClose(windowId) } case 'yo_browser_tab_activate': { - const tabId = typeof args.tabId === 'string' ? args.tabId : '' - if (!tabId) { - throw new Error('tabId is required') + const pageId = + typeof args.pageId === 'string' + ? args.pageId + : typeof args.tabId === 'string' + ? args.tabId + : '' + if (!pageId) { + throw new Error('pageId is required') } - return await this.handleTabActivate(tabId) + await this.presenter.activateTab(pageId) + return JSON.stringify({ success: true, pageId }) } case 'yo_browser_tab_close': { - const tabId = typeof args.tabId === 'string' ? args.tabId : '' - if (!tabId) { - throw new Error('tabId is required') + const pageId = + typeof args.pageId === 'string' + ? args.pageId + : typeof args.tabId === 'string' + ? args.tabId + : '' + if (!pageId) { + throw new Error('pageId is required') } - return await this.handleTabClose(tabId) + await this.presenter.closeTab(pageId) + return JSON.stringify({ success: true, pageId }) } case 'yo_browser_cdp_send': { - const tabId = typeof args.tabId === 'string' ? args.tabId : undefined + const windowId = typeof args.windowId === 'number' ? args.windowId : undefined + const pageId = + typeof args.pageId === 'string' + ? args.pageId + : typeof args.tabId === 'string' + ? args.tabId + : undefined const method = typeof args.method === 'string' ? args.method : '' const params = this.normalizeCdpParams(args.params) - return await this.handleCdpSend(tabId, method, params) + return await this.handleCdpSend(windowId ?? pageId, method, params) } default: throw new Error(`Unknown YoBrowser tool: ${toolName}`) @@ -52,63 +85,44 @@ export class YoBrowserToolHandler { } } - private async handleTabList(): Promise { - const tabs = await this.presenter.listTabs() - const activeTab = await this.presenter.getActiveTab() - return JSON.stringify({ - activeTabId: activeTab?.id ?? null, - tabs: tabs.map((tab: any) => ({ - id: tab.id, - url: tab.url, - title: tab.title, - isActive: tab.id === activeTab?.id - })) - }) + private async handleWindowList(): Promise { + const snapshot = await this.presenter.getBrowserContext() + return JSON.stringify(snapshot) } - private async handleTabNew(url?: string): Promise { - const tab = await this.presenter.createTab(url) - if (!tab) { - throw new Error('Failed to create new tab') + private async handleWindowOpen(url?: string): Promise { + const browserWindow = await this.presenter.openWindow(url) + if (!browserWindow) { + throw new Error('Failed to open browser window') } - return JSON.stringify({ - id: tab.id, - url: tab.url, - title: tab.title - }) + return JSON.stringify(browserWindow) } - private async handleTabActivate(tabId: string): Promise { - await this.presenter.activateTab(tabId) - return JSON.stringify({ success: true, tabId }) + private async handleWindowFocus(windowId: number): Promise { + await this.presenter.focusWindow(windowId) + return JSON.stringify({ success: true, windowId }) } - private async handleTabClose(tabId: string): Promise { - await this.presenter.closeTab(tabId) - return JSON.stringify({ success: true, tabId }) + private async handleWindowClose(windowId: number): Promise { + await this.presenter.closeWindow(windowId) + return JSON.stringify({ success: true, windowId }) } private async handleCdpSend( - tabId: string | undefined, + target: number | string | undefined, method: string, params: Record ): Promise { if (!method) { throw new Error('CDP method is required') } - const browserTab = await this.presenter.getBrowserTab(tabId) - if (!browserTab) { - throw new Error(tabId ? `Tab ${tabId} not found` : 'No active tab available') - } - if (tabId) { - const resolvedTabId = - (browserTab as { tabId?: string; id?: string }).tabId ?? (browserTab as { id?: string }).id - if (resolvedTabId !== tabId) { - throw new Error(`Tab ${tabId} not found`) - } + + const browserPage = await this.presenter.getBrowserTab(target) + if (!browserPage) { + throw new Error(`Browser target ${String(target)} not found`) } - const response = await browserTab.sendCdpCommand(method, params) + const response = await browserPage.sendCdpCommand(method, params) return JSON.stringify(response ?? {}) } @@ -116,6 +130,7 @@ export class YoBrowserToolHandler { if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return value as Record } + if (typeof value === 'string' && value.trim()) { try { const parsed = JSON.parse(value) @@ -126,6 +141,7 @@ export class YoBrowserToolHandler { return {} } } + return {} } } diff --git a/src/main/presenter/configPresenter/shortcutKeySettings.ts b/src/main/presenter/configPresenter/shortcutKeySettings.ts index 985691d9e..769b776e3 100644 --- a/src/main/presenter/configPresenter/shortcutKeySettings.ts +++ b/src/main/presenter/configPresenter/shortcutKeySettings.ts @@ -7,18 +7,13 @@ const ShiftKey = 'Shift' export const rendererShortcutKey = { NewConversation: `${CommandKey}+N`, NewWindow: `${CommandKey}+${ShiftKey}+N`, - NewTab: `${CommandKey}+T`, - CloseTab: `${CommandKey}+W`, + CloseWindow: `${CommandKey}+W`, ZoomIn: `${CommandKey}+=`, ZoomOut: `${CommandKey}+-`, ZoomResume: `${CommandKey}+0`, GoSettings: `${CommandKey}+,`, CleanChatHistory: `${CommandKey}+L`, - DeleteConversation: `${CommandKey}+D`, - SwitchNextTab: `${CommandKey}+Tab`, - SwitchPrevTab: `${CommandKey}+${ShiftKey}+Tab`, - SwtichToLastTab: `${CommandKey}+9`, - NumberTabs: `${CommandKey}+1...8` + DeleteConversation: `${CommandKey}+D` } // System-level shortcut keys diff --git a/src/main/presenter/deeplinkPresenter/index.ts b/src/main/presenter/deeplinkPresenter/index.ts index 988dc9cb8..40fe04541 100644 --- a/src/main/presenter/deeplinkPresenter/index.ts +++ b/src/main/presenter/deeplinkPresenter/index.ts @@ -266,8 +266,8 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } const windowId = focusedWindow?.id || 1 - await this.ensureChatTabActive(windowId) - eventBus.sendToRenderer(DEEPLINK_EVENTS.START, SendTarget.DEFAULT_TAB, { + await this.ensureChatWindowReady(windowId) + eventBus.sendToRenderer(DEEPLINK_EVENTS.START, SendTarget.DEFAULT_WINDOW, { msg, modelId, systemPrompt, @@ -277,26 +277,23 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } /** - * 确保有一个活动的 chat 标签页 + * Ensure the active chat window is ready to receive the deeplink payload. * @param windowId 窗口ID */ - private async ensureChatTabActive(windowId: number): Promise { + private async ensureChatWindowReady(windowId: number): Promise { try { - const tabPresenter = presenter.tabPresenter - const tabsData = await tabPresenter.getWindowTabsData(windowId) - const chatTab = tabsData.find( - (tab) => - tab.url === 'local://chat' || tab.url.includes('#/chat') || tab.url.endsWith('/chat') - ) - if (chatTab) { - if (!chatTab.isActive) { - await tabPresenter.switchTab(chatTab.id) - await new Promise((resolve) => setTimeout(resolve, 100)) - } + const targetWindow = BrowserWindow.fromId(windowId) + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + if (targetWindow.webContents.isLoadingMainFrame()) { + await new Promise((resolve) => { + targetWindow.webContents.once('did-finish-load', () => resolve()) + }) } - // Shell windows no longer create chat tabs } catch (error) { - console.error('Error ensuring chat tab active:', error) + console.error('Error ensuring chat window ready:', error) } } @@ -455,9 +452,8 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } } } else { - // 应用程序未启动,将配置保存到第一个 shell 窗口的 localStorage - console.log('App not fully started yet, saving MCP config for shell window') - await this.saveMcpConfigToShellWindow(completeMcpConfig) + console.log('App not fully started yet, saving MCP config for first app window') + await this.saveMcpConfigToAppWindow(completeMcpConfig) } console.log('All MCP servers processing completed') @@ -467,42 +463,39 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { } /** - * 将 MCP 配置保存到第一个 shell 窗口的 localStorage + * Store MCP config in the first available app window localStorage. * @param mcpConfig MCP 配置对象 */ - private async saveMcpConfigToShellWindow(mcpConfig: { + private async saveMcpConfigToAppWindow(mcpConfig: { mcpServers: Record }): Promise { try { - // 等待第一个 shell 窗口创建并准备就绪 - const shellWindow = await this.waitForFirstShellWindow() - if (!shellWindow) { - console.error('No shell window available to store MCP configuration') + const appWindow = await this.waitForFirstAppWindow() + if (!appWindow) { + console.error('No app window available to store MCP configuration') return } - // 确保 webContents 已准备就绪 - if (shellWindow.webContents.isLoading()) { + if (appWindow.webContents.isLoading()) { await new Promise((resolve) => { - shellWindow.webContents.once('dom-ready', () => resolve()) + appWindow.webContents.once('dom-ready', () => resolve()) }) } - // 存储到 localStorage - await shellWindow.webContents.executeJavaScript(` + await appWindow.webContents.executeJavaScript(` localStorage.setItem('pending-mcp-install', '${JSON.stringify(mcpConfig).replace(/'/g, "\\'")}'); `) - console.log('MCP configuration stored in shell window localStorage for cold start') + console.log('MCP configuration stored in app window localStorage for cold start') } catch (error) { - console.error('Failed to store MCP configuration in shell window localStorage:', error) + console.error('Failed to store MCP configuration in app window localStorage:', error) } } /** - * 等待第一个 shell 窗口创建并返回 + * Wait for the first app window to become available. * @returns Promise */ - private async waitForFirstShellWindow(): Promise { + private async waitForFirstAppWindow(): Promise { return new Promise((resolve) => { // 先检查是否已经有窗口 const existingWindows = presenter.windowPresenter.getAllWindows() @@ -525,7 +518,7 @@ export class DeeplinkPresenter implements IDeeplinkPresenter { // 设置超时,避免无限等待 setTimeout(() => { eventBus.off(WINDOW_EVENTS.WINDOW_CREATED, checkForWindow) - console.warn('Timeout waiting for shell window creation') + console.warn('Timeout waiting for app window creation') resolve(null) }, 10000) // 10秒超时 }) diff --git a/src/main/presenter/dialogPresenter/index.ts b/src/main/presenter/dialogPresenter/index.ts index ce3d66e18..e2cd0cd89 100644 --- a/src/main/presenter/dialogPresenter/index.ts +++ b/src/main/presenter/dialogPresenter/index.ts @@ -1,8 +1,8 @@ /** * Message dialog implemented via the renderer process - * The dialog is displayed on the currently active tab. If the tab is in the background, it will automatically switch to the foreground. + * The dialog is displayed on the current default window content. If it is in the background, it will automatically switch to the foreground. * Only one message dialog can exist within a single active window. Repeated calls will trigger the callback of the previous dialog with null. - * @see {@link SendTarget.DEFAULT_TAB} + * @see {@link SendTarget.DEFAULT_WINDOW} */ import { DialogRequest, @@ -24,7 +24,7 @@ export class DialogPresenter implements IDialogPresenter { >() /** - * show dialog in default active tab + * show dialog in the default active window * @param request Dialog Parameters * @returns Promise click result */ @@ -49,7 +49,7 @@ export class DialogPresenter implements IDialogPresenter { this.pendingDialogs.set(finalRequest.id, { resolve, reject }) try { // send dialog request to renderer - eventBus.sendToRenderer(DIALOG_EVENTS.REQUEST, SendTarget.DEFAULT_TAB, finalRequest) + eventBus.sendToRenderer(DIALOG_EVENTS.REQUEST, SendTarget.DEFAULT_WINDOW, finalRequest) } catch (error) { // Clean up the pending dialog entry this.pendingDialogs.delete(finalRequest.id) diff --git a/src/main/presenter/floatingButtonPresenter/index.ts b/src/main/presenter/floatingButtonPresenter/index.ts index 5beb3687f..30b8b3039 100644 --- a/src/main/presenter/floatingButtonPresenter/index.ts +++ b/src/main/presenter/floatingButtonPresenter/index.ts @@ -373,7 +373,7 @@ export class FloatingButtonPresenter { mainWindow.focus() console.log('Main window opened from floating button context menu') } else { - windowPresenter.createShellWindow({ initialTab: { url: 'local://chat' } }) + windowPresenter.createAppWindow({ initialRoute: 'chat' }) console.log('Created new main window from floating button context menu') } } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index aad69975d..547d7b54e 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -1,6 +1,6 @@ import path from 'path' import { DialogPresenter } from './dialogPresenter/index' -import { ipcMain, IpcMainInvokeEvent, app } from 'electron' +import { BrowserWindow, ipcMain, IpcMainInvokeEvent, app } from 'electron' import { WindowPresenter } from './windowPresenter' import { ShortcutPresenter } from './shortcutPresenter' import { @@ -70,7 +70,6 @@ import { ProjectPresenter } from './projectPresenter' // IPC调用上下文接口 interface IPCCallContext { - tabId?: number windowId?: number webContentsId: number presenterName: string @@ -382,18 +381,16 @@ function isFunction(obj: any, prop: string): obj is { [key: string]: (...args: a return typeof obj[prop] === 'function' } -// IPC 主进程处理程序:动态调用 Presenter 的方法 (支持Tab上下文) +// IPC 主进程处理程序:动态调用 Presenter 的方法 (支持 window/webContents 上下文) ipcMain.handle( 'presenter:call', (event: IpcMainInvokeEvent, name: string, method: string, ...payloads: unknown[]) => { try { // 构建调用上下文 const webContentsId = event.sender.id - const tabId = presenter.tabPresenter.getTabIdByWebContentsId(webContentsId) - const windowId = presenter.tabPresenter.getWindowIdByWebContentsId(webContentsId) + const windowId = BrowserWindow.fromWebContents(event.sender)?.id const context: IPCCallContext = { - tabId, windowId, webContentsId, presenterName: name, @@ -401,10 +398,10 @@ ipcMain.handle( timestamp: Date.now() } - // 记录调用日志 (包含tab上下文) + // 记录调用日志 if (import.meta.env.VITE_LOG_IPC_CALL === '1') { console.log( - `[IPC Call] Tab:${context.tabId || 'unknown'} Window:${context.windowId || 'unknown'} -> ${context.presenterName}.${context.methodName}` + `[IPC Call] WebContents:${context.webContentsId} Window:${context.windowId || 'unknown'} -> ${context.presenterName}.${context.methodName}` ) } @@ -415,7 +412,9 @@ ipcMain.handle( let resolvedPayloads = payloads if (!calledPresenter) { - console.warn(`[IPC Warning] Tab:${context.tabId} calling wrong presenter: ${name}`) + console.warn( + `[IPC Warning] WebContents:${context.webContentsId} calling wrong presenter: ${name}` + ) return { error: `Presenter "${name}" not found` } } @@ -425,7 +424,7 @@ ipcMain.handle( return calledPresenter[resolvedMethod](...resolvedPayloads) } else { console.warn( - `[IPC Warning] Tab:${context.tabId} called method is not a function or does not exist: ${name}.${method}` + `[IPC Warning] WebContents:${context.webContentsId} called method is not a function or does not exist: ${name}.${method}` ) return { error: `Method "${method}" not found or not a function on "${name}"` } } @@ -435,9 +434,8 @@ ipcMain.handle( ) { // 尝试获取调用上下文以改进错误日志 const webContentsId = event.sender.id - const tabId = presenter.tabPresenter.getTabIdByWebContentsId(webContentsId) - console.error(`[IPC Error] Tab:${tabId || 'unknown'} ${name}.${method}:`, e) + console.error(`[IPC Error] WebContents:${webContentsId} ${name}.${method}:`, e) return { error: e.message || String(e) } } } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/windowCreationHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/windowCreationHook.ts index d2102e7a4..ff610ff8a 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/after-start/windowCreationHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/windowCreationHook.ts @@ -22,30 +22,24 @@ export const windowCreationHook: LifecycleHook = { // If no windows exist, create main window (first app startup) if (presenter.windowPresenter.getAllWindows().length === 0) { - console.log('windowCreationHook: Creating initial shell window on app startup') + console.log('windowCreationHook: Creating initial app window on app startup') try { - const windowId = await presenter.windowPresenter.createShellWindow({ - initialTab: { - url: 'local://chat' - } + const windowId = await presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' }) if (windowId) { console.log( - `windowCreationHook: Initial shell window created successfully with ID: ${windowId}` + `windowCreationHook: Initial app window created successfully with ID: ${windowId}` ) } else { - throw new Error( - 'windowCreationHook: Failed to create initial shell window - returned null' - ) + throw new Error('windowCreationHook: Failed to create initial app window - returned null') } } catch (error) { - console.error('windowCreationHook: Error creating initial shell window:', error) + console.error('windowCreationHook: Error creating initial app window:', error) throw error } } else { - console.log( - 'windowCreationHook: Shell windows already exist, skipping initial window creation' - ) + console.log('windowCreationHook: App windows already exist, skipping initial window creation') } // Register global shortcuts diff --git a/src/main/presenter/lifecyclePresenter/hooks/ready/eventListenerSetupHook.ts b/src/main/presenter/lifecyclePresenter/hooks/ready/eventListenerSetupHook.ts index 6617aaa4a..aa76524b9 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/ready/eventListenerSetupHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/ready/eventListenerSetupHook.ts @@ -36,10 +36,8 @@ export const eventListenerSetupHook: LifecycleHook = { // Also handle showing hidden windows const allWindows = presenter.windowPresenter.getAllWindows() if (allWindows.length === 0) { - presenter.windowPresenter.createShellWindow({ - initialTab: { - url: 'local://chat' - } + presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' }) } else { // Try to show the most recently focused window, otherwise show the first window @@ -51,9 +49,8 @@ export const eventListenerSetupHook: LifecycleHook = { console.warn( 'eventListenerSetupHook: App activated but target window is destroyed, creating new window.' ) - presenter.windowPresenter.createShellWindow({ - // If target window is destroyed, create new window - initialTab: { url: 'local://chat' } + presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' }) } } diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts index a1e115353..85a445624 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts @@ -5,8 +5,6 @@ import { z } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { presenter } from '@/presenter' // 导入全局的 presenter 对象 -import { eventBus } from '@/eventbus' // 引入 eventBus -import { TAB_EVENTS } from '@/events' import { isSafeRegexPattern } from '@shared/regexValidator' // Schema definitions @@ -45,20 +43,6 @@ const GetConversationStatsArgsSchema = z.object({ days: z.number().optional().default(30).describe('Statistics period in days (default 30 days)') }) -const CreateNewTabArgsSchema = z.object({ - url: z - .enum(['local://chat']) - .default('local://chat') // 默认 URL 为 local://chat - .describe('URL for the new tab. Defaults to local://chat.'), - active: z - .boolean() - .optional() - .default(true) - .describe('Whether the new tab should be active. Defaults to true.'), - position: z.number().optional().describe('Optional position for the new tab in the tab bar.'), - userInput: z.string().optional().describe('Optional initial user input for the new chat tab.') -}) - interface SearchResult { conversations?: Array<{ id: string @@ -80,26 +64,6 @@ interface SearchResult { total: number } -// 等待 Tab 内容就绪的辅助函数 -function awaitTabReady(webContentsId: number, timeout = 10000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - eventBus.removeListener(TAB_EVENTS.RENDERER_TAB_READY, listener) - reject(new Error(`Timed out waiting for tab ${webContentsId} to be ready.`)) - }, timeout) - - const listener = (readyTabId: number) => { - if (readyTabId === webContentsId) { - clearTimeout(timer) - eventBus.removeListener(TAB_EVENTS.RENDERER_TAB_READY, listener) - resolve() - } - } - - eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, listener) - }) -} - export class ConversationSearchServer { private server: Server @@ -526,16 +490,6 @@ export class ConversationSearchServer { title: 'Get Conversation Stats', readOnlyHint: true } - }, - { - name: 'create_new_tab', - description: - 'Creates a new tab. If userInput is provided, it also creates a new chat session and sends the input as the first message, then returns tabId and threadId.', - inputSchema: zodToJsonSchema(CreateNewTabArgsSchema), - annotations: { - title: 'Create New Tab', - destructiveHint: false - } } ] } @@ -603,60 +557,6 @@ export class ConversationSearchServer { ] } } - case 'create_new_tab': { - const { url, active, position, userInput } = CreateNewTabArgsSchema.parse(args) - - const mainWindowId = presenter.windowPresenter.mainWindow?.id - if (!mainWindowId) { - throw new Error('Main application window not found to create a new tab.') - } - - const newTabId = await presenter.tabPresenter.createTab(mainWindowId, url, { - active, - position - }) - - if (!newTabId) { - throw new Error('Failed to create new tab.') - } - - const normalizedInput = userInput?.trim() - if (!normalizedInput) { - return { - content: [{ type: 'text', text: JSON.stringify({ tabId: newTabId }) }] - } - } - - const newTabView = await presenter.tabPresenter.getTab(newTabId) - if (!newTabView) { - throw new Error(`Could not find view for new tab ${newTabId}`) - } - - const newWebContentsId = newTabView.webContents.id - try { - await awaitTabReady(newWebContentsId) - } catch (error) { - console.error(error) - throw new Error("Failed to communicate with the new tab's renderer process.") - } - - const session = await presenter.newAgentPresenter.createSession( - { - agentId: 'deepchat', - message: normalizedInput - }, - newWebContentsId - ) - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ tabId: newTabId, threadId: session.id }) - } - ] - } - } default: throw new Error(`Unknown tool: ${name}`) } diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts index 879172422..991bc4f38 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts @@ -1,73 +1,15 @@ -// src/main/presenter/mcpPresenter/inMemoryServers/meetingServer.ts -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { z } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import { presenter } from '@/presenter' -import type { ChatMessageRecord } from '@shared/types/agent-interface' - -const PARTICIPANT_NAMES = [ - 'Alice', - 'Brian', - 'Chris', - 'David', - 'Emma', - 'Frank', - 'Grace', - 'Henry', - 'Ian', - 'Jack', - 'Kate', - 'Lily', - 'Mike', - 'Nick', - 'Oliver', - 'Peter', - 'Quinn', - 'Ryan', - 'Sarah', - 'Tom', - 'Uriel', - 'Victor', - 'Wendy', - 'Xavier', - 'Yolanda', - 'Zoe' -] const ParticipantSchema = z .object({ - tab_id: z - .number() - .optional() - .describe( - '通过Tab的【唯一标识】来精确指定参会者。' + - '这是一个内部ID,通常通过create_new_tab等工具获得。' + - '仅当你可以明确获得参会者Tab的唯一标识时,才应使用此字段。' + - '这是最精确的定位方式。如果使用此字段,则不应填写 tab_title。' - ), - tab_title: z - .string() - .optional() - .describe( - '通过Tab的【当前显示标题】来指定参会者。' + - '当用户的指令中明确提到了Tab的名称(例如 "让标题为\'AI讨论\'的Tab...")时,应优先使用此字段。' + - '请注意,标题可能不是唯一的,系统会选择第一个匹配的Tab。如果使用此字段,则不应填写 tab_id。' - ), - profile: z - .string() - .optional() - .describe( - '用于定义该参会者的完整画像,可包括且不限于其角色身份、观察视角、立场观点、表达方式、行为模式、发言约束及其他提示词,用于驱动其在会议中的一致性行为和语言风格。' - ) + tab_id: z.number().optional(), + tab_title: z.string().optional(), + profile: z.string().optional() }) - .describe( - '定义一位会议的参会者。' + - '你必须通过且只能通过 "tab_id" 或 "tab_title" 字段中的一个来指定该参会者。' + - '决策依据:如果用户的指令明确提到了Tab的标题,请优先使用 tab_title。仅当你可以明确获得参会者tab唯一数字标识时,才使用 tab_id。' - ) .refine( (data) => { const hasId = data.tab_id !== undefined && data.tab_id !== -1 @@ -89,27 +31,6 @@ const StartMeetingArgsSchema = z.object({ rounds: z.number().optional().default(3).describe('讨论的轮次数,默认为3轮。') }) -interface MeetingParticipant { - meetingName: string - tabId: number - webContentsId: number - conversationId: string - originalTitle: string - profile: string -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -/** - * MeetingServer - * - * Migrated to new-agent stack: - * - session lookup/creation uses newAgentPresenter - * - prompt dispatch uses newAgentPresenter.sendMessage directly - * - response waiting polls deepchat messages/status instead of legacy conversation events - */ export class MeetingServer { private server: Server @@ -131,7 +52,7 @@ export class MeetingServer { { name: 'start_meeting', description: - '启动并主持一个由多个Tab(参会者)参与的关于特定主题的讨论会议。如果你当前已经是某个会议的参与者,请勿调用!', + 'Legacy helper. Disabled while the app migrates to the window-native architecture.', inputSchema: zodToJsonSchema(StartMeetingArgsSchema), annotations: { title: 'Start Meeting', @@ -143,251 +64,35 @@ export class MeetingServer { this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params - if (name !== 'start_meeting') throw new Error(`未知的工具: ${name}`) + if (name !== 'start_meeting') { + throw new Error(`未知的工具: ${name}`) + } try { - const meetingArgs = StartMeetingArgsSchema.parse(args) - - ;(async () => { - try { - await this.organizeMeeting(meetingArgs) - console.log('会议流程已在后台成功完成。') - } catch (meetingError: any) { - console.error(`会议执行过程中发生错误: ${meetingError.message}`) - } - })() - - return { content: [{ type: 'text', text: '会议已成功启动,正在后台进行中...' }] } - } catch (error: any) { + StartMeetingArgsSchema.parse(args) return { - content: [{ type: 'text', text: `会议启动失败: ${error.message}` }], + content: [ + { + type: 'text', + text: [ + 'Legacy helper disabled.', + 'Reason: the old multi-tab meeting flow no longer matches the window-native architecture.' + ].join(' ') + } + ], isError: true } - } - }) - } - - private extractAssistantText(message: ChatMessageRecord): string { - try { - const parsed = JSON.parse(message.content) - if (Array.isArray(parsed)) { - const texts = parsed - .map((block: { content?: unknown }) => - typeof block?.content === 'string' ? block.content : '' - ) - .filter((text) => text.length > 0) - if (texts.length > 0) { - return texts.join('\n') - } - } - } catch { - // Keep raw fallback. - } - return message.content || '[无内容]' - } - - private async waitUntilSessionReady(conversationId: string, timeout = 120000): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < timeout) { - const session = await presenter.newAgentPresenter.getSession(conversationId) - if (!session) { - throw new Error(`会话不存在: ${conversationId}`) - } - if (session.status !== 'generating') { - return - } - await sleep(600) - } - - throw new Error(`等待会话 ${conversationId} 空闲超时。`) - } - - private async waitForResponse( - conversationId: string, - previousAssistantMessageId: string | null, - timeout = 180000 - ): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < timeout) { - const messages = await presenter.newAgentPresenter.getMessages(conversationId) - const assistants = messages.filter((msg) => msg.role === 'assistant') - const latest = assistants.length > 0 ? assistants[assistants.length - 1] : null - - if ( - latest && - latest.id !== previousAssistantMessageId && - (latest.status === 'sent' || latest.status === 'error') - ) { - return latest - } - - await sleep(700) - } - - throw new Error( - `超时: 等待会话 ${conversationId} 的回复超过 ${Math.floor(timeout / 1000)} 秒。` - ) - } - - private async sendPromptAndWait(conversationId: string, prompt: string): Promise { - await this.waitUntilSessionReady(conversationId) - - const messagesBefore = await presenter.newAgentPresenter.getMessages(conversationId) - const previousAssistant = [...messagesBefore].reverse().find((msg) => msg.role === 'assistant') - - await presenter.newAgentPresenter.sendMessage(conversationId, prompt) - const response = await this.waitForResponse(conversationId, previousAssistant?.id ?? null) - return this.extractAssistantText(response) - } - - private buildMeetingName(index: number): string { - return index < PARTICIPANT_NAMES.length ? PARTICIPANT_NAMES[index] : `Participant-${index + 1}` - } - - private async resolveParticipantSession(options: { - tabId: number - meetingName: string - topic: string - }): Promise<{ conversationId: string; webContentsId: number } | null> { - const tabView = await presenter.tabPresenter.getTab(options.tabId) - if (!tabView || tabView.webContents.isDestroyed()) { - return null - } - - const webContentsId = tabView.webContents.id - const activeSession = await presenter.newAgentPresenter.getActiveSession(webContentsId) - if (activeSession) { - return { - conversationId: activeSession.id, - webContentsId - } - } - - const bootstrapPrompt = `You are ${options.meetingName}. Topic: ${options.topic}. Reply briefly with "Ready".` - const created = await presenter.newAgentPresenter.createSession( - { - agentId: 'deepchat', - message: bootstrapPrompt - }, - webContentsId - ) - - await this.waitForResponse(created.id, null, 120000) - - return { - conversationId: created.id, - webContentsId - } - } - - private async organizeMeeting(args: z.infer): Promise { - const { participants, topic, rounds } = args - - const mainWindowId = presenter.windowPresenter.mainWindow?.id - if (!mainWindowId) throw new Error('主窗口未找到,无法开始会议。') - - const allChatTabs = await presenter.tabPresenter.getWindowTabsData(mainWindowId) - const meetingParticipants: MeetingParticipant[] = [] - let nameIndex = 0 - - for (const p of participants) { - let tabData = null as (typeof allChatTabs)[number] | null - - if (p.tab_id !== undefined) { - tabData = allChatTabs.find((t) => t.id === p.tab_id) ?? null - } - - if (!tabData && p.tab_title) { - tabData = allChatTabs.find((t) => t.title === p.tab_title) ?? null - } - - if (!tabData) { - continue - } - - const meetingName = this.buildMeetingName(nameIndex) - nameIndex += 1 - - const resolved = await this.resolveParticipantSession({ - tabId: tabData.id, - meetingName, - topic - }) - - if (!resolved) { - console.warn(`Tab ${tabData.id} 无法用于会议,将跳过。`) - continue - } - - meetingParticipants.push({ - meetingName, - tabId: tabData.id, - webContentsId: resolved.webContentsId, - conversationId: resolved.conversationId, - originalTitle: tabData.title, - profile: p.profile || `你可以就“${topic}”这个话题,自由发表你的看法和观点。` - }) - } - - if (meetingParticipants.length < 2) { - throw new Error( - `会议无法开始。只找到了 ${meetingParticipants.length} 位有效的参会者。请确保指定的Tab ID或Tab标题正确。` - ) - } - - await presenter.tabPresenter.switchTab(meetingParticipants[0].tabId) - - const participantNames = meetingParticipants.map((p) => p.meetingName).join('、') - - for (const p of meetingParticipants) { - const initPrompt = `您好,${p.meetingName}。 -我是Argus,是当前会议的组织者,很荣幸能邀请您参加会议: ---- -会议主题: ${topic} -所有参会者: ${participantNames} -你的会议名称: ${p.meetingName} -你的角色画像: ${p.profile} ---- -会议规则: -1. 请严格围绕你的角色和观点进行发言。 -2. 请等待主持人指示后方可发言。 -3. 发言时,请清晰地陈述你的论点。 -4. 你的发言将被转发给其他所有参会者。 -5. 在他人发言时,你会收到其发言内容,但请勿回复,轮到你再发言。 -6. 参会期间禁止调用会议相关的工具函数,如start_meeting等。 ---- -会议现在开始。请等待你的发言回合。` - - await this.sendPromptAndWait(p.conversationId, initPrompt) - } - - let _history = `会议记录\n主题: ${topic}\n` - - for (let round = 1; round <= rounds; round++) { - for (const speaker of meetingParticipants) { - const speakPrompt = `第 ${round}/${rounds} 轮。现在轮到您(${speaker.meetingName})发言。请陈述您的观点。` - const speechText = await this.sendPromptAndWait(speaker.conversationId, speakPrompt) - - _history += `\n[第${round}轮] ${speaker.meetingName}: ${speechText}` - - for (const listener of meetingParticipants) { - if (listener.tabId === speaker.tabId) { - continue - } - - const forwardPrompt = `来自 ${speaker.meetingName} 的发言如下:\n\n---\n${speechText}\n---\n\n以上信息仅供参考,请不要展开讨论。请回复“收到”,并等待下一步指示。` - await this.sendPromptAndWait(listener.conversationId, forwardPrompt) + } catch (error) { + return { + content: [ + { + type: 'text', + text: `会议启动失败: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true } } - } - - for (const p of meetingParticipants) { - const personalizedFinalPrompt = `讨论已结束。请您(${p.meetingName})根据整个对话过程,对您的观点进行最终总结。` - await this.sendPromptAndWait(p.conversationId, personalizedFinalPrompt) - } - - console.log(`关于“${topic}”的会议流程已在后台正常结束。`) + }) } } diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index ab971aa52..f3eac05b7 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -615,7 +615,7 @@ export class McpPresenter implements IMCPPresenter { return new Promise((resolve, reject) => { try { this.pendingSamplingRequests.set(request.requestId, { resolve, reject }) - eventBus.sendToRenderer(MCP_EVENTS.SAMPLING_REQUEST, SendTarget.DEFAULT_TAB, request) + eventBus.sendToRenderer(MCP_EVENTS.SAMPLING_REQUEST, SendTarget.DEFAULT_WINDOW, request) } catch (error) { this.pendingSamplingRequests.delete(request.requestId) reject(error instanceof Error ? error : new Error(String(error))) diff --git a/src/main/presenter/sessionPresenter/index.ts b/src/main/presenter/sessionPresenter/index.ts index 4f606a31d..9b1150377 100644 --- a/src/main/presenter/sessionPresenter/index.ts +++ b/src/main/presenter/sessionPresenter/index.ts @@ -15,6 +15,7 @@ import type { } from '@shared/presenter' import type { AssistantMessageBlock, Message, UserMessageContent } from '@shared/chat' import type { NowledgeMemThread, NowledgeMemExportSummary } from '@shared/types/nowledgeMem' +import { BrowserWindow, webContents as electronWebContents } from 'electron' import { promises as fs } from 'fs' import { presenter } from '@/presenter' import { eventBus } from '@/eventbus' @@ -37,7 +38,7 @@ export class SessionPresenter implements ISessionPresenter { private conversationManager: ConversationManager private exporter: IConversationExporter private commandPermissionService: CommandPermissionService - private activeConversationIds: Map = new Map() + private activeConversationBindings: Map = new Map() constructor(options: { messageManager?: MessageManager @@ -58,18 +59,20 @@ export class SessionPresenter implements ISessionPresenter { sqlitePresenter: options.sqlitePresenter, configPresenter: options.configPresenter, messageManager: this.messageManager, - activeConversationIds: this.activeConversationIds + activeConversationBindings: this.activeConversationBindings }) - // 监听Tab关闭事件,清理绑定关系 - eventBus.on(TAB_EVENTS.CLOSED, (tabId: number) => { - const activeConversationId = this.getActiveConversationIdSync(tabId) + // Clean up conversation bindings when a bound renderer is closed. + eventBus.on(TAB_EVENTS.CLOSED, (webContentsId: number) => { + const activeConversationId = this.getActiveConversationIdSync(webContentsId) if (activeConversationId) { void presenter.agentPresenter.cleanupConversation(activeConversationId) this.commandPermissionService.clearConversation(activeConversationId) presenter.filePermissionService?.clearConversation(activeConversationId) presenter.settingsPermissionService?.clearConversation(activeConversationId) - this.clearActiveConversation(tabId, { notify: true }) - console.log(`SessionPresenter: Cleaned up conversation binding for closed tab ${tabId}.`) + this.clearActiveConversationBindingInternal(webContentsId, { notify: true }) + console.log( + `SessionPresenter: Cleaned up conversation binding for closed webContents ${webContentsId}.` + ) } }) eventBus.on(TAB_EVENTS.RENDERER_TAB_READY, () => { @@ -92,16 +95,26 @@ export class SessionPresenter implements ISessionPresenter { } async createSession(params: CreateSessionParams): Promise { - const tabId = - typeof params.tabId === 'number' - ? params.tabId - : await presenter.tabPresenter.getActiveTabId( - presenter.windowPresenter.getFocusedWindow()?.id ?? 0 - ) - if (tabId == null) { - throw new Error('tabId is required to create a session') + const webContentsId = + typeof params.webContentsId === 'number' + ? params.webContentsId + : typeof params.tabId === 'number' + ? params.tabId + : typeof params.options?.webContentsId === 'number' + ? params.options.webContentsId + : typeof params.options?.tabId === 'number' + ? params.options.tabId + : (presenter.windowPresenter.getFocusedWindow()?.webContents.id ?? null) + + if (webContentsId == null) { + throw new Error('webContentsId is required to create a session') } - return this.createConversation(params.title, params.settings ?? {}, tabId, params.options ?? {}) + return this.createConversation( + params.title, + params.settings ?? {}, + webContentsId, + params.options ?? {} + ) } async getSession(sessionId: string): Promise { @@ -139,28 +152,43 @@ export class SessionPresenter implements ISessionPresenter { await this.updateConversationSettings(sessionId, settings as Partial) } + async bindToWebContents(sessionId: string, webContentsId: number): Promise { + await this.setActiveConversation(sessionId, webContentsId) + } + async bindToTab(sessionId: string, tabId: number): Promise { - await this.setActiveConversation(sessionId, tabId) + await this.bindToWebContents(sessionId, tabId) + } + + async unbindFromWebContents(webContentsId: number): Promise { + this.clearActiveConversationBindingInternal(webContentsId, { notify: true }) } async unbindFromTab(tabId: number): Promise { - this.clearActiveConversation(tabId, { notify: true }) + await this.unbindFromWebContents(tabId) } - async activateSession(tabId: number, sessionId: string): Promise { - await this.setActiveConversation(sessionId, tabId) + async activateSession(webContentsId: number, sessionId: string): Promise { + await this.setActiveConversation(sessionId, webContentsId) } - async getActiveSession(tabId: number): Promise { - const conversation = await this.getActiveConversation(tabId) + async getActiveSession(webContentsId: number): Promise { + const conversation = await this.getActiveConversation(webContentsId) return conversation ? this.toSession(conversation) : null } - async findTabForSession( + async findWebContentsForSession( sessionId: string, _preferredWindowType?: 'main' | 'floating' ): Promise { - return this.findTabForConversation(sessionId) + return this.findWebContentsForConversation(sessionId) + } + + async findTabForSession( + sessionId: string, + preferredWindowType?: 'main' | 'floating' + ): Promise { + return this.findWebContentsForSession(sessionId, preferredWindowType) } async getMessageThread( @@ -205,6 +233,8 @@ export class SessionPresenter implements ISessionPresenter { parentSelection: ParentSelection | string title: string settings?: Partial + webContentsId?: number + openInNewWindow?: boolean tabId?: number openInNewTab?: boolean }): Promise { @@ -214,6 +244,8 @@ export class SessionPresenter implements ISessionPresenter { parentSelection: params.parentSelection, title: params.title, settings: params.settings as Partial, + webContentsId: params.webContentsId ?? params.tabId, + openInNewWindow: params.openInNewWindow ?? params.openInNewTab, tabId: params.tabId, openInNewTab: params.openInNewTab }) @@ -314,31 +346,41 @@ export class SessionPresenter implements ISessionPresenter { return cleanedTitle } - /** - * 新增:查找指定会话ID所在的Tab ID - * @param conversationId 会话ID - * @returns 如果找到,返回tabId,否则返回null - */ + async findWebContentsForConversation(conversationId: string): Promise { + return this.conversationManager.findWebContentsForConversation(conversationId) + } + async findTabForConversation(conversationId: string): Promise { - return this.conversationManager.findTabForConversation(conversationId) + return this.findWebContentsForConversation(conversationId) } - getActiveConversationIdSync(tabId: number): string | null { - return this.conversationManager.getActiveConversationIdSync(tabId) + getActiveConversationIdSync(webContentsId: number): string | null { + return this.conversationManager.getActiveConversationIdSync(webContentsId) + } + + getWebContentsIdsByConversation(conversationId: string): number[] { + return this.conversationManager.getWebContentsIdsByConversation(conversationId) } getTabsByConversation(conversationId: string): number[] { - return this.conversationManager.getTabsByConversation(conversationId) + return this.getWebContentsIdsByConversation(conversationId) } - clearActiveConversation(tabId: number, options: { notify?: boolean } = {}): void { - const conversationId = this.getActiveConversationIdSync(tabId) + private clearActiveConversationBindingInternal( + webContentsId: number, + options: { notify?: boolean } = {} + ): void { + const conversationId = this.getActiveConversationIdSync(webContentsId) if (conversationId) { this.commandPermissionService.clearConversation(conversationId) presenter.filePermissionService?.clearConversation(conversationId) presenter.settingsPermissionService?.clearConversation(conversationId) } - this.conversationManager.clearActiveConversation(tabId, options) + this.conversationManager.clearActiveConversationBinding(webContentsId, options) + } + + clearActiveConversation(webContentsId: number, options: { notify?: boolean } = {}): void { + this.clearActiveConversationBindingInternal(webContentsId, options) } clearConversationBindings(conversationId: string): void { @@ -360,50 +402,133 @@ export class SessionPresenter implements ISessionPresenter { presenter.settingsPermissionService?.clearAll() } - async setActiveConversation(conversationId: string, tabId: number): Promise { - await this.conversationManager.setActiveConversation(conversationId, tabId) + private focusWindowForWebContents(webContentsId: number): void { + const targetContents = electronWebContents.fromId(webContentsId) + if (!targetContents || targetContents.isDestroyed()) { + return + } + + const targetWindow = BrowserWindow.fromWebContents(targetContents) + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + presenter.windowPresenter.show(targetWindow.id, true) + } + + async setActiveConversation(conversationId: string, webContentsId: number): Promise { + await this.conversationManager.setActiveConversation(conversationId, webContentsId) } - async openConversationInNewTab(payload: { + async openConversationInNewWindow(payload: { conversationId: string - tabId?: number + webContentsId?: number messageId?: string childConversationId?: string }): Promise { - const { conversationId, tabId, messageId, childConversationId } = payload + const { conversationId, messageId, childConversationId } = payload await this.sqlitePresenter.getConversation(conversationId) - const existingTabId = await this.conversationManager.findTabForConversation(conversationId) - if (existingTabId !== null) { - await presenter.tabPresenter.switchTab(existingTabId) + const existingWebContentsId = + await this.conversationManager.findWebContentsForConversation(conversationId) + if (existingWebContentsId !== null) { + this.focusWindowForWebContents(existingWebContentsId) if (messageId || childConversationId) { - eventBus.sendToTab(existingTabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { + await presenter.windowPresenter.sendToWebContents( + existingWebContentsId, + CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, + { + conversationId, + messageId, + childConversationId + } + ) + } + return existingWebContentsId + } + + const newWindowId = await presenter.windowPresenter.createAppWindow({ initialRoute: 'chat' }) + if (newWindowId == null) { + return null + } + + const targetWindow = + BrowserWindow.fromId(newWindowId) ?? + presenter.windowPresenter.getAllWindows().find((window) => window.id === newWindowId) + const targetWebContentsId = targetWindow?.webContents.id ?? null + if (targetWebContentsId == null) { + return null + } + + await this.conversationManager.setActiveConversation(conversationId, targetWebContentsId) + this.focusWindowForWebContents(targetWebContentsId) + if (messageId || childConversationId) { + await presenter.windowPresenter.sendToWebContents( + targetWebContentsId, + CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, + { conversationId, messageId, childConversationId - }) + } + ) + } + + return targetWebContentsId + } + + async openConversationInNewTab(payload: { + conversationId: string + tabId?: number + messageId?: string + childConversationId?: string + }): Promise { + const { conversationId, tabId, messageId, childConversationId } = payload + const existingWebContentsId = + await this.conversationManager.findWebContentsForConversation(conversationId) + + if (existingWebContentsId !== null) { + this.focusWindowForWebContents(existingWebContentsId) + if (messageId || childConversationId) { + await presenter.windowPresenter.sendToWebContents( + existingWebContentsId, + CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, + { + conversationId, + messageId, + childConversationId + } + ) } - return existingTabId + return existingWebContentsId } - // Shell windows no longer create chat tabs; just set active conversation on the current tab if (typeof tabId === 'number') { await this.conversationManager.setActiveConversation(conversationId, tabId) if (messageId || childConversationId) { - eventBus.sendToTab(tabId, CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, { - conversationId, - messageId, - childConversationId - }) + await presenter.windowPresenter.sendToWebContents( + tabId, + CONVERSATION_EVENTS.SCROLL_TO_MESSAGE, + { + conversationId, + messageId, + childConversationId + } + ) } return tabId } - return null + + return this.openConversationInNewWindow({ + conversationId, + messageId, + childConversationId + }) } - async getActiveConversation(tabId: number): Promise { - return this.conversationManager.getActiveConversation(tabId) + async getActiveConversation(webContentsId: number): Promise { + return this.conversationManager.getActiveConversation(webContentsId) } async getConversation(conversationId: string): Promise { @@ -413,13 +538,13 @@ export class SessionPresenter implements ISessionPresenter { async createConversation( title: string, settings: Partial = {}, - tabId: number, + webContentsId: number, options: CreateConversationOptions = {} ): Promise { const conversationId = await this.conversationManager.createConversation( title, settings, - tabId, + webContentsId, options ) @@ -597,12 +722,16 @@ export class SessionPresenter implements ISessionPresenter { await this.messageManager.markMessageAsContextEdge(messageId, isEdge) } - async getActiveConversationId(tabId: number): Promise { - return this.conversationManager.getActiveConversationIdSync(tabId) + async getActiveConversationId(webContentsId: number): Promise { + return this.conversationManager.getActiveConversationIdSync(webContentsId) + } + + async clearActiveConversationBinding(webContentsId: number): Promise { + this.clearActiveConversationBindingInternal(webContentsId, { notify: true }) } - async clearActiveThread(tabId: number): Promise { - this.clearActiveConversation(tabId, { notify: true }) + async clearActiveThread(webContentsId: number): Promise { + this.clearActiveConversationBindingInternal(webContentsId, { notify: true }) } async clearAllMessages(conversationId: string): Promise { @@ -687,6 +816,8 @@ export class SessionPresenter implements ISessionPresenter { parentSelection: ParentSelection | string title: string settings?: Partial + webContentsId?: number + openInNewWindow?: boolean tabId?: number openInNewTab?: boolean }): Promise { @@ -696,6 +827,8 @@ export class SessionPresenter implements ISessionPresenter { parentSelection, title, settings, + webContentsId, + openInNewWindow, tabId, openInNewTab } = payload @@ -731,19 +864,27 @@ export class SessionPresenter implements ISessionPresenter { parentSelection: resolvedParentSelection }) - const shouldOpenInNewTab = openInNewTab ?? true - if (shouldOpenInNewTab && typeof tabId === 'number') { - // Shell windows no longer create chat tabs; set active conversation on the current tab - await this.conversationManager.setActiveConversation(newConversationId, tabId) - await this.broadcastThreadListUpdate() + await this.broadcastThreadListUpdate() + + const targetWebContentsId = + typeof webContentsId === 'number' + ? webContentsId + : typeof tabId === 'number' + ? tabId + : undefined + const shouldOpenInNewChatWindow = openInNewWindow ?? openInNewTab ?? true + + if (shouldOpenInNewChatWindow) { + await this.openConversationInNewWindow({ + conversationId: newConversationId + }) return newConversationId } - if (typeof tabId === 'number') { - await this.conversationManager.setActiveConversation(newConversationId, tabId) + if (typeof targetWebContentsId === 'number') { + await this.conversationManager.setActiveConversation(newConversationId, targetWebContentsId) } - await this.broadcastThreadListUpdate() return newConversationId } @@ -870,11 +1011,26 @@ export class SessionPresenter implements ISessionPresenter { } private toSession(conversation: CONVERSATION): Session { - const tabs = this.conversationManager.getTabsByConversation(conversation.id) - const tabId = tabs.length > 0 ? tabs[0] : null - const windowId = - typeof tabId === 'number' ? presenter.tabPresenter.getWindowIdByWebContentsId(tabId) : null - const windowType = windowId ? 'main' : tabId !== null ? 'floating' : null + const boundWebContentsIds = this.conversationManager.getWebContentsIdsByConversation( + conversation.id + ) + const webContentsId = boundWebContentsIds.length > 0 ? boundWebContentsIds[0] : null + const targetContents = + typeof webContentsId === 'number' ? electronWebContents.fromId(webContentsId) : null + const targetWindow = + targetContents && !targetContents.isDestroyed() + ? BrowserWindow.fromWebContents(targetContents) + : null + const floatingWindow = presenter.windowPresenter.getFloatingChatWindow()?.getWindow() + const windowId = targetWindow?.id ?? null + const windowType = + targetWindow == null + ? webContentsId !== null + ? 'floating' + : null + : floatingWindow && floatingWindow.id === targetWindow.id + ? 'floating' + : 'main' const sessionContext = typeof presenter?.sessionManager?.getSessionSync === 'function' ? presenter.sessionManager.getSessionSync(conversation.id) @@ -894,7 +1050,7 @@ export class SessionPresenter implements ISessionPresenter { isPinned: conversation.is_pinned === 1 }, bindings: { - tabId: tabId ?? null, + webContentsId: webContentsId ?? null, windowId: windowId ?? null, windowType }, diff --git a/src/main/presenter/sessionPresenter/managers/conversationManager.ts b/src/main/presenter/sessionPresenter/managers/conversationManager.ts index 28c112955..64d7c56a5 100644 --- a/src/main/presenter/sessionPresenter/managers/conversationManager.ts +++ b/src/main/presenter/sessionPresenter/managers/conversationManager.ts @@ -6,9 +6,10 @@ import type { MESSAGE_METADATA } from '@shared/presenter' import type { Message } from '@shared/chat' +import { BrowserWindow, webContents as electronWebContents } from 'electron' import { presenter } from '@/presenter' import { eventBus, SendTarget } from '@/eventbus' -import { CONVERSATION_EVENTS, TAB_EVENTS } from '@/events' +import { CONVERSATION_EVENTS } from '@/events' import { DEFAULT_SETTINGS } from '../const' import type { MessageManager } from './messageManager' @@ -20,7 +21,7 @@ export class ConversationManager { private readonly sqlitePresenter: ISQLitePresenter private readonly configPresenter: IConfigPresenter private readonly messageManager: MessageManager - private readonly activeConversationIds: Map + private readonly activeConversationBindings: Map private fetchThreadLength = 300 private isLegacyTableMissingError(error: unknown): boolean { @@ -34,99 +35,129 @@ export class ConversationManager { sqlitePresenter: ISQLitePresenter configPresenter: IConfigPresenter messageManager: MessageManager - activeConversationIds: Map + activeConversationBindings: Map }) { this.sqlitePresenter = options.sqlitePresenter this.configPresenter = options.configPresenter this.messageManager = options.messageManager - this.activeConversationIds = options.activeConversationIds + this.activeConversationBindings = options.activeConversationBindings } - getActiveConversationIdSync(tabId: number): string | null { - return this.activeConversationIds.get(tabId) || null + getActiveConversationIdSync(webContentsId: number): string | null { + return this.activeConversationBindings.get(webContentsId) || null } - getTabsByConversation(conversationId: string): number[] { - return Array.from(this.activeConversationIds.entries()) + getWebContentsIdsByConversation(conversationId: string): number[] { + return Array.from(this.activeConversationBindings.entries()) .filter(([, id]) => id === conversationId) - .map(([tabId]) => tabId) + .map(([webContentsId]) => webContentsId) + } + + getTabsByConversation(conversationId: string): number[] { + return this.getWebContentsIdsByConversation(conversationId) } - clearActiveConversation(tabId: number, options: { notify?: boolean } = {}): void { - if (!this.activeConversationIds.has(tabId)) { + clearActiveConversationBinding(webContentsId: number, options: { notify?: boolean } = {}): void { + if (!this.activeConversationBindings.has(webContentsId)) { return } - this.activeConversationIds.delete(tabId) + this.activeConversationBindings.delete(webContentsId) if (options.notify) { - eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { tabId }) + eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { + webContentsId + }) } } clearConversationBindings(conversationId: string): void { - for (const [tabId, activeId] of this.activeConversationIds.entries()) { + for (const [webContentsId, activeId] of this.activeConversationBindings.entries()) { if (activeId === conversationId) { - this.activeConversationIds.delete(tabId) + this.activeConversationBindings.delete(webContentsId) eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { - tabId + webContentsId }) } } } - async findTabForConversation(conversationId: string): Promise { - for (const [tabId, activeId] of this.activeConversationIds.entries()) { + async findWebContentsForConversation(conversationId: string): Promise { + for (const [webContentsId, activeId] of this.activeConversationBindings.entries()) { if (activeId === conversationId) { try { - const tabView = await presenter.tabPresenter.getTab(tabId) - if (tabView && !tabView.webContents.isDestroyed()) { - return tabId + const targetContents = electronWebContents.fromId(webContentsId) + if (targetContents && !targetContents.isDestroyed()) { + return webContentsId } } catch (error) { - console.error('Error finding tab for conversation:', error) + console.error('Error finding bound webContents for conversation:', error) } } } return null } - private async getTabWindowType(tabId: number): Promise<'floating' | 'main' | 'unknown'> { + async findTabForConversation(conversationId: string): Promise { + return this.findWebContentsForConversation(conversationId) + } + + private getWindowTypeForWebContents(webContentsId: number): 'floating' | 'main' | 'unknown' { try { - const tabView = await presenter.tabPresenter.getTab(tabId) - if (!tabView) { + const targetContents = electronWebContents.fromId(webContentsId) + if (!targetContents || targetContents.isDestroyed()) { + return 'unknown' + } + + const targetWindow = BrowserWindow.fromWebContents(targetContents) + if (!targetWindow || targetWindow.isDestroyed()) { return 'unknown' } - const windowId = presenter.tabPresenter.getTabWindowId(tabId) - return windowId ? 'main' : 'floating' + + const floatingWindow = presenter.windowPresenter.getFloatingChatWindow()?.getWindow() + return floatingWindow && floatingWindow.id === targetWindow.id ? 'floating' : 'main' } catch (error) { - console.error('Error determining tab window type:', error) + console.error('Error determining webContents window type:', error) return 'unknown' } } - async setActiveConversation(conversationId: string, tabId: number): Promise { - const existingTabId = await this.findTabForConversation(conversationId) + private focusBoundWebContents(webContentsId: number): void { + const targetContents = electronWebContents.fromId(webContentsId) + if (!targetContents || targetContents.isDestroyed()) { + return + } - if (existingTabId !== null && existingTabId !== tabId) { + const targetWindow = BrowserWindow.fromWebContents(targetContents) + if (!targetWindow || targetWindow.isDestroyed()) { + return + } + + presenter.windowPresenter.show(targetWindow.id, true) + } + + async setActiveConversation(conversationId: string, webContentsId: number): Promise { + const existingWebContentsId = await this.findWebContentsForConversation(conversationId) + + if (existingWebContentsId !== null && existingWebContentsId !== webContentsId) { console.log( - `Conversation ${conversationId} is already open in tab ${existingTabId}. Switching to it.` + `Conversation ${conversationId} is already bound to webContents ${existingWebContentsId}. Focusing that window.` ) - const currentTabType = await this.getTabWindowType(tabId) - const existingTabType = await this.getTabWindowType(existingTabId) + const currentWindowType = this.getWindowTypeForWebContents(webContentsId) + const existingWindowType = this.getWindowTypeForWebContents(existingWebContentsId) - if (currentTabType !== existingTabType) { - this.activeConversationIds.delete(existingTabId) + if (currentWindowType !== existingWindowType) { + this.activeConversationBindings.delete(existingWebContentsId) eventBus.sendToRenderer(CONVERSATION_EVENTS.DEACTIVATED, SendTarget.ALL_WINDOWS, { - tabId: existingTabId + webContentsId: existingWebContentsId }) - this.activeConversationIds.set(tabId, conversationId) + this.activeConversationBindings.set(webContentsId, conversationId) eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { conversationId, - tabId + webContentsId }) return } - await presenter.tabPresenter.switchTab(existingTabId) + this.focusBoundWebContents(existingWebContentsId) return } @@ -135,19 +166,19 @@ export class ConversationManager { throw new Error(`Conversation ${conversationId} not found`) } - if (this.activeConversationIds.get(tabId) === conversationId) { + if (this.activeConversationBindings.get(webContentsId) === conversationId) { return } - this.activeConversationIds.set(tabId, conversationId) + this.activeConversationBindings.set(webContentsId, conversationId) eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { conversationId, - tabId + webContentsId }) } - async getActiveConversation(tabId: number): Promise { - const conversationId = this.activeConversationIds.get(tabId) + async getActiveConversation(webContentsId: number): Promise { + const conversationId = this.activeConversationBindings.get(webContentsId) if (!conversationId) { return null } @@ -161,7 +192,7 @@ export class ConversationManager { async createConversation( title: string, settings: Partial = {}, - tabId: number, + webContentsId: number, options: CreateConversationOptions = {} ): Promise { let latestConversation: CONVERSATION | null = null @@ -176,7 +207,7 @@ export class ConversationManager { 1 ) if (messages.length === 0) { - await this.setActiveConversation(latestConversation.id, tabId) + await this.setActiveConversation(latestConversation.id, webContentsId) return latestConversation.id } } @@ -247,13 +278,13 @@ export class ConversationManager { const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) if (options.forceNewAndActivate) { - this.activeConversationIds.set(tabId, conversationId) + this.activeConversationBindings.set(webContentsId, conversationId) eventBus.sendToRenderer(CONVERSATION_EVENTS.ACTIVATED, SendTarget.ALL_WINDOWS, { conversationId, - tabId + webContentsId }) } else { - await this.setActiveConversation(conversationId, tabId) + await this.setActiveConversation(conversationId, webContentsId) } await this.broadcastThreadListUpdate() @@ -261,7 +292,7 @@ export class ConversationManager { } catch (error) { console.error('ConversationManager: Failed to create conversation', { title, - tabId, + webContentsId, options, latestConversationId: latestConversation?.id, errorMessage: error instanceof Error ? error.message : String(error), @@ -274,28 +305,7 @@ export class ConversationManager { async renameConversation(conversationId: string, title: string): Promise { await this.sqlitePresenter.renameConversation(conversationId, title) await this.broadcastThreadListUpdate() - - const conversation = await this.getConversation(conversationId) - - let tabId: number | undefined - for (const [key, value] of this.activeConversationIds.entries()) { - if (value === conversationId) { - tabId = key - break - } - } - - if (tabId !== undefined) { - const windowId = presenter.tabPresenter.getTabWindowId(tabId) - eventBus.sendToRenderer(TAB_EVENTS.TITLE_UPDATED, SendTarget.ALL_WINDOWS, { - tabId, - conversationId, - title: conversation.title, - windowId - }) - } - - return conversation + return this.getConversation(conversationId) } async deleteConversation(conversationId: string): Promise { diff --git a/src/main/presenter/sessionPresenter/types.ts b/src/main/presenter/sessionPresenter/types.ts index 98c7f2285..c757b9f52 100644 --- a/src/main/presenter/sessionPresenter/types.ts +++ b/src/main/presenter/sessionPresenter/types.ts @@ -30,7 +30,7 @@ export type SessionConfig = { } export type SessionBindings = { - tabId: number | null + webContentsId: number | null windowId: number | null windowType: 'main' | 'floating' | 'browser' | null } @@ -53,12 +53,14 @@ export type Session = { export type CreateSessionOptions = { forceNewAndActivate?: boolean + webContentsId?: number tabId?: number } export type CreateSessionParams = { title: string settings?: Partial + webContentsId?: number tabId?: number options?: CreateSessionOptions } diff --git a/src/main/presenter/shortcutPresenter.ts b/src/main/presenter/shortcutPresenter.ts index 249695c73..23be2a1ff 100644 --- a/src/main/presenter/shortcutPresenter.ts +++ b/src/main/presenter/shortcutPresenter.ts @@ -3,11 +3,7 @@ import { app, globalShortcut } from 'electron' import { presenter } from '.' import { SHORTCUT_EVENTS, TRAY_EVENTS } from '../events' import { eventBus, SendTarget } from '../eventbus' -import { - CommandKey, - defaultShortcutKey, - ShortcutKeySetting -} from './configPresenter/shortcutKeySettings' +import { defaultShortcutKey, ShortcutKeySetting } from './configPresenter/shortcutKeySettings' import { IConfigPresenter, IShortcutPresenter } from '@shared/presenter' export class ShortcutPresenter implements IShortcutPresenter { @@ -39,8 +35,8 @@ export class ShortcutPresenter implements IShortcutPresenter { globalShortcut.register(this.shortcutKeys.NewConversation, async () => { const focusedWindow = presenter.windowPresenter.getFocusedWindow() if (focusedWindow?.isFocused()) { - presenter.windowPresenter.sendToActiveTab( - focusedWindow.id, + void presenter.windowPresenter.sendToWebContents( + focusedWindow.webContents.id, SHORTCUT_EVENTS.CREATE_NEW_CONVERSATION ) } @@ -57,27 +53,16 @@ export class ShortcutPresenter implements IShortcutPresenter { }) } - // Command+T 或 Ctrl+T 在当前窗口创建新标签页 - if (this.shortcutKeys.NewTab) { - globalShortcut.register(this.shortcutKeys.NewTab, () => { - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow?.isFocused()) { - eventBus.sendToMain(SHORTCUT_EVENTS.CREATE_NEW_TAB, focusedWindow.id) - } - }) - } - - // Command+W 或 Ctrl+W 关闭当前标签页 - if (this.shortcutKeys.CloseTab) { - globalShortcut.register(this.shortcutKeys.CloseTab, () => { + // Command+W 或 Ctrl+W 关闭当前窗口 + if (this.shortcutKeys.CloseWindow) { + globalShortcut.register(this.shortcutKeys.CloseWindow, () => { const focusedWindow = presenter.windowPresenter.getFocusedWindow() if (focusedWindow?.isFocused()) { if (focusedWindow.id === presenter.windowPresenter.getSettingsWindowId()) { - // 如果是设置窗口,直接关闭 presenter.windowPresenter.closeSettingsWindow() return } - eventBus.sendToMain(SHORTCUT_EVENTS.CLOSE_CURRENT_TAB, focusedWindow.id) + presenter.windowPresenter.close(focusedWindow.id) } }) } @@ -126,8 +111,8 @@ export class ShortcutPresenter implements IShortcutPresenter { const focusedWindow = presenter.windowPresenter.getFocusedWindow() console.log('clean chat history') if (focusedWindow?.isFocused()) { - presenter.windowPresenter.sendToActiveTab( - focusedWindow.id, + void presenter.windowPresenter.sendToWebContents( + focusedWindow.webContents.id, SHORTCUT_EVENTS.CLEAN_CHAT_HISTORY ) } @@ -140,75 +125,19 @@ export class ShortcutPresenter implements IShortcutPresenter { const focusedWindow = presenter.windowPresenter.getFocusedWindow() console.log('delete conversation') if (focusedWindow?.isFocused()) { - presenter.windowPresenter.sendToActiveTab( - focusedWindow.id, + void presenter.windowPresenter.sendToWebContents( + focusedWindow.webContents.id, SHORTCUT_EVENTS.DELETE_CONVERSATION ) } }) } - // 添加标签页切换相关快捷键 - - // Command+Tab 或 Ctrl+Tab 切换到下一个标签页 - if (this.shortcutKeys.SwitchNextTab) { - globalShortcut.register(this.shortcutKeys.SwitchNextTab, () => { - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow?.isFocused()) { - this.switchToNextTab(focusedWindow.id) - } - }) - } - - // Ctrl+Shift+Tab 切换到上一个标签页 - if (this.shortcutKeys.SwitchPrevTab) { - globalShortcut.register(this.shortcutKeys.SwitchPrevTab, () => { - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow?.isFocused()) { - this.switchToPreviousTab(focusedWindow.id) - } - }) - } - - // 注册标签页数字快捷键 (1-8) - if (this.shortcutKeys.NumberTabs) { - for (let i = 1; i <= 8; i++) { - globalShortcut.register(`${CommandKey}+${i}`, () => { - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow?.isFocused()) { - this.switchToTabByIndex(focusedWindow.id, i - 1) // 索引从0开始 - } - }) - } - } - - // Command+9 或 Ctrl+9 切换到最后一个标签页 - if (this.shortcutKeys.SwtichToLastTab) { - globalShortcut.register(this.shortcutKeys.SwtichToLastTab, () => { - const focusedWindow = presenter.windowPresenter.getFocusedWindow() - if (focusedWindow?.isFocused()) { - this.switchToLastTab(focusedWindow.id) - } - }) - } - this.showHideWindow() this.isActive = true } - // No-op: shell windows no longer manage chat tabs - private async switchToNextTab(_windowId: number): Promise {} - - // No-op: shell windows no longer manage chat tabs - private async switchToPreviousTab(_windowId: number): Promise {} - - // No-op: shell windows no longer manage chat tabs - private async switchToTabByIndex(_windowId: number, _index: number): Promise {} - - // No-op: shell windows no longer manage chat tabs - private async switchToLastTab(_windowId: number): Promise {} - // Command+O 或 Ctrl+O 显示/隐藏窗口 private async showHideWindow() { // Command+O 或 Ctrl+O 显示/隐藏窗口 diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index bb1780a38..8df500bb9 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -935,7 +935,17 @@ export class TabPresenter implements ITabPresenter { newWindowOptions.y = screenY } - const newWindowId = await this.windowPresenter.createShellWindow(newWindowOptions) + const newWindowId = + sourceWindowType === 'browser' + ? await this.windowPresenter.createBrowserWindow({ + x: newWindowOptions.x, + y: newWindowOptions.y + }) + : await this.windowPresenter.createAppWindow({ + initialRoute: 'chat', + x: newWindowOptions.x, + y: newWindowOptions.y + }) if (newWindowId === null) { console.error('moveTabToNewWindow: Failed to create a new window.') diff --git a/src/main/presenter/windowPresenter/FloatingChatWindow.ts b/src/main/presenter/windowPresenter/FloatingChatWindow.ts index 9c26ab121..5d54f66c4 100644 --- a/src/main/presenter/windowPresenter/FloatingChatWindow.ts +++ b/src/main/presenter/windowPresenter/FloatingChatWindow.ts @@ -100,7 +100,7 @@ export class FloatingChatWindow { this.window.setAlwaysOnTop(true, 'floating') this.window.setOpacity(this.config.opacity) this.setupWindowEvents() - this.registerVirtualTab() + this.registerWindowContent() logger.info('FloatingChatWindow created successfully') @@ -167,7 +167,7 @@ export class FloatingChatWindow { public destroy(): void { if (this.window) { - this.unregisterVirtualTab() + this.unregisterWindowContent() try { if (!this.window.isDestroyed()) { this.window.destroy() @@ -200,7 +200,7 @@ export class FloatingChatWindow { } } - private registerVirtualTab(): void { + private registerWindowContent(): void { if (!this.window || this.window.isDestroyed()) { return } @@ -209,15 +209,17 @@ export class FloatingChatWindow { const tabPresenter = presenter.tabPresenter if (tabPresenter) { const webContentsId = this.window.webContents.id - logger.info(`Registering virtual tab for floating window, WebContents ID: ${webContentsId}`) + logger.info( + `Registering floating window webContents bridge, WebContents ID: ${webContentsId}` + ) tabPresenter.registerFloatingWindow(webContentsId, this.window.webContents) } } catch (error) { - logger.error('Failed to register virtual tab for floating window:', error) + logger.error('Failed to register floating window webContents bridge:', error) } } - private unregisterVirtualTab(): void { + private unregisterWindowContent(): void { if (!this.window) { return } @@ -227,12 +229,12 @@ export class FloatingChatWindow { if (tabPresenter) { const webContentsId = this.window.webContents.id logger.info( - `Unregistering virtual tab for floating window, WebContents ID: ${webContentsId}` + `Unregistering floating window webContents bridge, WebContents ID: ${webContentsId}` ) tabPresenter.unregisterFloatingWindow(webContentsId) } } catch (error) { - logger.error('Failed to unregister virtual tab for floating window:', error) + logger.error('Failed to unregister floating window webContents bridge:', error) } } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 6fbdc6ea8..bae060e41 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -1,5 +1,12 @@ // src\main\presenter\windowPresenter\index.ts -import { BrowserWindow, shell, nativeImage, ipcMain, screen } from 'electron' +import { + BrowserWindow, + shell, + nativeImage, + ipcMain, + screen, + webContents as electronWebContents +} from 'electron' import { join } from 'path' import icon from '../../../../resources/icon.png?asset' // App icon (macOS/Linux) import iconWin from '../../../../resources/icon.ico?asset' // App icon (Windows) @@ -29,10 +36,6 @@ export class WindowPresenter implements IWindowPresenter { private mainWindowId: number | null = null private floatingChatWindow: FloatingChatWindow | null = null private settingsWindow: BrowserWindow | null = null - private tooltipOverlayWindows = new Map() - private pendingTooltipPayload = new Map() - // TEMP: Tooltip overlay window creation is disabled while renderer tooltip overlay is unstable. - private isTooltipOverlayEnabled = false constructor(configPresenter: IConfigPresenter) { this.windows = new Map() @@ -49,7 +52,7 @@ export class WindowPresenter implements IWindowPresenter { }) // Chrome height reporting from browser windows (TabPresenter uses this for view bounds) - ipcMain.on('shell:chrome-height', (event, payload: { height?: number } | number) => { + ipcMain.on('browser:chrome-height', (event, payload: { height?: number } | number) => { const window = BrowserWindow.fromWebContents(event.sender) if (!window || window.isDestroyed()) return const height = typeof payload === 'number' ? payload : payload?.height @@ -68,61 +71,10 @@ export class WindowPresenter implements IWindowPresenter { } }) - ipcMain.on( - 'shell-tooltip:show', - (event, payload: { x: number; y: number; text: string } | undefined) => { - if (!payload) return - - const parentWindow = BrowserWindow.fromWebContents(event.sender) - if (!parentWindow || parentWindow.isDestroyed()) return - // On macOS fullscreen, suppress tooltip overlay to keep system traffic lights reachable - if (process.platform === 'darwin' && parentWindow.isFullScreen()) { - return - } - - const overlay = this.getOrCreateTooltipOverlay(parentWindow) - if (!overlay) return - - this.pendingTooltipPayload.set(parentWindow.id, payload) - - if (!overlay.webContents.isLoadingMainFrame()) { - if (!overlay.isVisible()) { - overlay.showInactive() - } - overlay.webContents.send('shell-tooltip-overlay:show', payload) - return - } - - overlay.webContents.once('did-finish-load', () => { - const pending = this.pendingTooltipPayload.get(parentWindow.id) - if (!pending) return - if (overlay.isDestroyed()) return - if (!overlay.isVisible()) { - overlay.showInactive() - } - overlay.webContents.send('shell-tooltip-overlay:show', pending) - }) - } - ) - - ipcMain.on('shell-tooltip:hide', (event) => { - const parentWindow = BrowserWindow.fromWebContents(event.sender) - if (!parentWindow || parentWindow.isDestroyed()) return - - const overlay = this.tooltipOverlayWindows.get(parentWindow.id) - if (!overlay || overlay.isDestroyed()) return - - this.pendingTooltipPayload.delete(parentWindow.id) - overlay.webContents.send('shell-tooltip-overlay:hide') - if (overlay.isVisible()) { - overlay.hide() - } - }) - // Listen for shortcut event: create new window eventBus.on(SHORTCUT_EVENTS.CREATE_NEW_WINDOW, () => { - console.log('Creating new shell window via shortcut.') - this.createShellWindow() + console.log('Creating new app window via shortcut.') + this.createAppWindow() }) // Listen for shortcut event: go settings (now opens independent Settings Window) @@ -514,12 +466,87 @@ export class WindowPresenter implements IWindowPresenter { return false } + async sendToDefaultWindow( + channel: string, + switchToTarget: boolean = false, + ...args: unknown[] + ): Promise { + const targetWindow = this.getFocusedWindow() || this.getAllWindows()[0] + if (!targetWindow || targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) { + return false + } + + targetWindow.webContents.send(channel, ...args) + + if (switchToTarget) { + targetWindow.show() + targetWindow.focus() + } + + return true + } + + async sendToWebContents( + webContentsId: number, + channel: string, + ...args: unknown[] + ): Promise { + const target = electronWebContents.fromId(webContentsId) + if (!target || target.isDestroyed()) { + return false + } + + target.send(channel, ...args) + return true + } + + public async createAppWindow(options?: { + initialRoute?: string + x?: number + y?: number + }): Promise { + return await this.createManagedWindow({ + initialTab: { + url: + options?.initialRoute === 'chat' || !options?.initialRoute + ? 'local://chat' + : `local://${options.initialRoute}` + }, + windowType: 'chat', + x: options?.x, + y: options?.y + }) + } + + public async createBrowserWindow(options?: { x?: number; y?: number }): Promise { + return await this.createManagedWindow({ + windowType: 'browser', + x: options?.x, + y: options?.y + }) + } + + async createShellWindow(options?: { + activateTabId?: number + initialTab?: { + url: string + icon?: string + } + windowType?: 'chat' | 'browser' + forMovedTab?: boolean + x?: number + y?: number + }): Promise { + console.log('Creating window via deprecated createShellWindow wrapper.') + return await this.createManagedWindow(options) + } + /** - * 创建一个新的外壳窗口。 + * 创建一个新的兼容窗口包装器。 * @param options 窗口配置选项,包括初始标签页或激活现有标签页。 * @returns 创建的窗口 ID,如果创建失败则返回 null。 */ - async createShellWindow(options?: { + private async createManagedWindow(options?: { activateTabId?: number // 要关联并激活的现有标签页 ID initialTab?: { // 窗口创建时要创建的新标签页选项 @@ -531,7 +558,6 @@ export class WindowPresenter implements IWindowPresenter { x?: number // 初始 X 坐标 y?: number // 初始 Y 坐标 }): Promise { - console.log('Creating new shell window.') const windowType = options?.windowType ?? 'chat' // 根据平台选择图标 @@ -542,7 +568,7 @@ export class WindowPresenter implements IWindowPresenter { const defaultHeight = 620 // 使用窗口状态管理器恢复位置和尺寸 - const shellWindowState = windowStateManager({ + const managedWindowState = windowStateManager({ defaultWidth, defaultHeight }) @@ -552,24 +578,24 @@ export class WindowPresenter implements IWindowPresenter { options?.x !== undefined ? options.x : this.validateWindowPosition( - shellWindowState.x, - shellWindowState.width, - shellWindowState.y, - shellWindowState.height + managedWindowState.x, + managedWindowState.width, + managedWindowState.y, + managedWindowState.height ).x let initialY = options?.y !== undefined ? options?.y : this.validateWindowPosition( - shellWindowState.x, - shellWindowState.width, - shellWindowState.y, - shellWindowState.height + managedWindowState.x, + managedWindowState.width, + managedWindowState.y, + managedWindowState.height ).y - const shellWindow = new BrowserWindow({ - width: shellWindowState.width, - height: shellWindowState.height, + const appWindow = new BrowserWindow({ + width: managedWindowState.width, + height: managedWindowState.height, x: initialX, y: initialY, show: false, // 先隐藏窗口,等待 ready-to-show 以避免白屏 @@ -592,42 +618,42 @@ export class WindowPresenter implements IWindowPresenter { roundedCorners: true // Windows 11 圆角 }) - if (!shellWindow) { - console.error('Failed to create shell window.') + if (!appWindow) { + console.error('Failed to create application window.') return null } - const windowId = shellWindow.id - this.windows.set(windowId, shellWindow) // 将窗口实例存入 Map + const windowId = appWindow.id + this.windows.set(windowId, appWindow) // 将窗口实例存入 Map // For browser windows, register type with TabPresenter if (windowType === 'browser') { ;(presenter.tabPresenter as TabPresenter).setWindowType(windowId, windowType) } - shellWindowState.manage(shellWindow) // 管理窗口状态 + managedWindowState.manage(appWindow) // 管理窗口状态 // 应用内容保护设置 const contentProtectionEnabled = this.configPresenter.getContentProtectionEnabled() - this.updateContentProtection(shellWindow, contentProtectionEnabled) + this.updateContentProtection(appWindow, contentProtectionEnabled) // 开发模式下自动打开 DevTools if (is.dev) { - shellWindow.webContents.openDevTools() + appWindow.webContents.openDevTools() } // --- 窗口事件监听 --- // 窗口准备就绪时显示 - shellWindow.on('ready-to-show', () => { + appWindow.on('ready-to-show', () => { console.log(`Window ${windowId} is ready to show.`) - if (!shellWindow.isDestroyed()) { + if (!appWindow.isDestroyed()) { // For browser windows, don't auto-show/focus to prevent stealing focus from chat windows // Browser windows should only be shown when explicitly requested by user (e.g., clicking browser button) const shouldAutoShow = windowType !== 'browser' || options?.forMovedTab === true if (shouldAutoShow) { - shellWindow.show() - shellWindow.focus() + appWindow.show() + appWindow.focus() } eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, windowId) } else { @@ -636,33 +662,32 @@ export class WindowPresenter implements IWindowPresenter { }) // 窗口获得焦点 - shellWindow.on('focus', () => { + appWindow.on('focus', () => { console.log(`Window ${windowId} gained focus.`) this.focusedWindowId = windowId eventBus.sendToMain(WINDOW_EVENTS.WINDOW_FOCUSED, windowId) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send('window-focused', windowId) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send('window-focused', windowId) } }) // 窗口失去焦点 - shellWindow.on('blur', () => { + appWindow.on('blur', () => { console.log(`Window ${windowId} lost focus.`) if (this.focusedWindowId === windowId) { this.focusedWindowId = null // 仅当失去焦点的窗口是当前记录的焦点窗口时才清空 } eventBus.sendToMain(WINDOW_EVENTS.WINDOW_BLURRED, windowId) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send('window-blurred', windowId) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send('window-blurred', windowId) } - this.clearTooltipOverlay(windowId) }) // 窗口最大化 - shellWindow.on('maximize', () => { + appWindow.on('maximize', () => { console.log(`Window ${windowId} maximized.`) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_MAXIMIZED) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send(WINDOW_EVENTS.WINDOW_MAXIMIZED) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_MAXIMIZED, windowId) // 触发恢复逻辑更新标签页 bounds this.handleWindowRestore(windowId).catch((error) => { @@ -672,10 +697,10 @@ export class WindowPresenter implements IWindowPresenter { }) // 窗口取消最大化 - shellWindow.on('unmaximize', () => { + appWindow.on('unmaximize', () => { console.log(`Window ${windowId} unmaximized.`) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_UNMAXIMIZED) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send(WINDOW_EVENTS.WINDOW_UNMAXIMIZED) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_UNMAXIMIZED, windowId) // 触发恢复逻辑更新标签页 bounds this.handleWindowRestore(windowId).catch((error) => { @@ -693,18 +718,16 @@ export class WindowPresenter implements IWindowPresenter { this.handleWindowRestore(windowId).catch((error) => { console.error(`Error handling restore logic for window ${windowId}:`, error) }) - shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_UNMAXIMIZED) + appWindow.webContents.send(WINDOW_EVENTS.WINDOW_UNMAXIMIZED) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_RESTORED, windowId) } - shellWindow.on('restore', handleRestore) + appWindow.on('restore', handleRestore) // 窗口进入全屏 - shellWindow.on('enter-full-screen', () => { + appWindow.on('enter-full-screen', () => { console.log(`Window ${windowId} entered fullscreen.`) - // Destroy tooltip overlay while fullscreen so it never blocks system traffic lights - this.destroyTooltipOverlay(windowId) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_ENTER_FULL_SCREEN) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send(WINDOW_EVENTS.WINDOW_ENTER_FULL_SCREEN) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_ENTER_FULL_SCREEN, windowId) // 触发恢复逻辑更新标签页 bounds this.handleWindowRestore(windowId).catch((error) => { @@ -717,12 +740,10 @@ export class WindowPresenter implements IWindowPresenter { }) // 窗口退出全屏 - shellWindow.on('leave-full-screen', () => { + appWindow.on('leave-full-screen', () => { console.log(`Window ${windowId} left fullscreen.`) - // Recreate tooltip overlay after exiting fullscreen for normal behavior - this.getOrCreateTooltipOverlay(shellWindow) - if (!shellWindow.isDestroyed()) { - shellWindow.webContents.send(WINDOW_EVENTS.WINDOW_LEAVE_FULL_SCREEN) + if (!appWindow.isDestroyed()) { + appWindow.webContents.send(WINDOW_EVENTS.WINDOW_LEAVE_FULL_SCREEN) eventBus.sendToMain(WINDOW_EVENTS.WINDOW_LEAVE_FULL_SCREEN, windowId) // 触发恢复逻辑更新标签页 bounds this.handleWindowRestore(windowId).catch((error) => { @@ -735,13 +756,13 @@ export class WindowPresenter implements IWindowPresenter { }) // 窗口尺寸改变,通知 TabPresenter 更新所有视图 bounds - shellWindow.on('resize', () => { + appWindow.on('resize', () => { eventBus.sendToMain(WINDOW_EVENTS.WINDOW_RESIZE, windowId) }) // 'close' 事件:用户尝试关闭窗口 (点击关闭按钮等)。 // 此处理程序决定是隐藏窗口还是允许其关闭/销毁。 - shellWindow.on('close', (event) => { + appWindow.on('close', (event) => { console.log( `Window ${windowId} close event. isQuitting: ${this.isQuitting}, Platform: ${process.platform}.` ) @@ -760,24 +781,24 @@ export class WindowPresenter implements IWindowPresenter { event.preventDefault() // 阻止默认窗口关闭行为 // 处理全屏窗口隐藏时的黑屏问题 (同 hide 方法) - if (shellWindow.isFullScreen()) { + if (appWindow.isFullScreen()) { console.log( `Window ${windowId} is fullscreen, exiting fullscreen before hiding (close event).` ) - shellWindow.once('leave-full-screen', () => { + appWindow.once('leave-full-screen', () => { console.log(`Window ${windowId} left fullscreen, proceeding with hide (close event).`) - if (!shellWindow.isDestroyed()) { - shellWindow.hide() + if (!appWindow.isDestroyed()) { + appWindow.hide() } else { console.warn( `Window ${windowId} was destroyed after leaving fullscreen, cannot hide (close event).` ) } }) - shellWindow.setFullScreen(false) + appWindow.setFullScreen(false) } else { console.log(`Window ${windowId} is not fullscreen, hiding directly (close event).`) - shellWindow.hide() + appWindow.hide() } } else { // 允许默认关闭行为。这将触发 'closed' 事件。 @@ -792,19 +813,18 @@ export class WindowPresenter implements IWindowPresenter { }) // 'closed' 事件:窗口实际关闭并销毁时触发 (在 'close' 事件之后,如果未阻止默认行为) - shellWindow.on('closed', () => { + appWindow.on('closed', () => { console.log( `Window ${windowId} closed event triggered. isQuitting: ${this.isQuitting}, Map size BEFORE delete: ${this.windows.size}` ) const windowIdBeingClosed = windowId // 捕获 ID // 移除 restore 事件监听器,防止内存泄漏 (其他事件的清理根据需要添加) - shellWindow.removeListener('restore', handleRestore) + appWindow.removeListener('restore', handleRestore) this.windows.delete(windowIdBeingClosed) // 从 Map 中移除 - shellWindowState.unmanage() // 停止管理窗口状态 + managedWindowState.unmanage() // 停止管理窗口状态 eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CLOSED, windowIdBeingClosed) - this.destroyTooltipOverlay(windowIdBeingClosed) console.log( `Window ${windowIdBeingClosed} closed event handled. Map size AFTER delete: ${this.windows.size}` ) @@ -828,78 +848,65 @@ export class WindowPresenter implements IWindowPresenter { console.log( `Loading main renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}#/chat` ) - shellWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/chat') + appWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/chat') } else { console.log( `Loading packaged main renderer file: ${join(__dirname, '../renderer/index.html')}` ) - shellWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/chat' }) + appWindow.loadFile(join(__dirname, '../renderer/index.html'), { hash: '/chat' }) } } else { - // Browser windows load the shell renderer + // Browser windows load the dedicated browser renderer if (is.dev && process.env['ELECTRON_RENDERER_URL']) { console.log( - `Loading renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}/shell/index.html` + `Loading renderer URL in dev mode: ${process.env['ELECTRON_RENDERER_URL']}/browser/index.html` ) - shellWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/shell/index.html') + appWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/browser/index.html') } else { console.log( - `Loading packaged renderer file: ${join(__dirname, '../renderer/shell/index.html')}` + `Loading packaged renderer file: ${join(__dirname, '../renderer/browser/index.html')}` ) - shellWindow.loadFile(join(__dirname, '../renderer/shell/index.html')) + appWindow.loadFile(join(__dirname, '../renderer/browser/index.html')) } } - // Pre-create tooltip overlay so first hover is instant - shellWindow.webContents.once('did-finish-load', () => { - if (shellWindow.isDestroyed()) return - // Only send shell-window:type for browser windows (shell renderer listens for it) - if (windowType === 'browser') { - shellWindow.webContents.send('shell-window:type', windowType) - } - // Avoid pre-creating overlay if window already in fullscreen on macOS - if (!(process.platform === 'darwin' && shellWindow.isFullScreen())) { - this.getOrCreateTooltipOverlay(shellWindow) - } - }) - // --- 处理 browser 窗口的初始标签页创建或激活 --- // Only browser windows need initial tab / activateTab handling via TabPresenter if (windowType === 'browser') { if (options?.initialTab) { - shellWindow.webContents.once('did-finish-load', async () => { + appWindow.webContents.once('did-finish-load', async () => { console.log(`Window ${windowId} did-finish-load, checking for initial tab creation.`) - if (shellWindow.isDestroyed()) { + if (appWindow.isDestroyed()) { console.warn( `Window ${windowId} was destroyed before did-finish-load callback, cannot create initial tab.` ) return } - shellWindow.focus() + appWindow.focus() try { - console.log(`Creating initial tab, URL: ${options.initialTab!.url}`) - const tabId = await (presenter.tabPresenter as TabPresenter).createTab( + console.log(`Creating initial browser view, URL: ${options.initialTab!.url}`) + const viewId = await (presenter.tabPresenter as TabPresenter).createTab( windowId, options.initialTab!.url, { active: true } ) - if (tabId === null) { - console.error(`Failed to create initial tab in new window ${windowId}.`) + if (viewId === null) { + console.error(`Failed to create initial browser view in new window ${windowId}.`) } else { - console.log(`Created initial tab ${tabId} in window ${windowId}.`) + console.log(`Created initial browser view ${viewId} in window ${windowId}.`) } } catch (error) { - console.error(`Error creating initial tab:`, error) + console.error(`Error creating initial browser view:`, error) } }) } if (options?.activateTabId !== undefined && !options?.forMovedTab) { - shellWindow.webContents.once('did-finish-load', async () => { + appWindow.webContents.once('did-finish-load', async () => { console.log( `Window ${windowId} did-finish-load, attempting to activate tab ${options.activateTabId}.` ) - if (shellWindow.isDestroyed()) { + if (appWindow.isDestroyed()) { console.warn( `Window ${windowId} was destroyed before did-finish-load callback, cannot activate tab ${options.activateTabId}.` ) @@ -923,10 +930,10 @@ export class WindowPresenter implements IWindowPresenter { // DevTools 不再自动打开,需要手动通过菜单或快捷键打开 // 开发环境直接自动开启,方便排查 if (is.dev) { - shellWindow.webContents.openDevTools({ mode: 'detach' }) + appWindow.webContents.openDevTools({ mode: 'detach' }) } - console.log(`Shell window ${windowId} created successfully.`) + console.log(`Window ${windowId} created successfully.`) if (this.mainWindowId == null) { this.mainWindowId = windowId // 如果这是第一个窗口,设置为主窗口 ID @@ -934,137 +941,6 @@ export class WindowPresenter implements IWindowPresenter { return windowId // 返回新创建窗口的 ID } - private getOrCreateTooltipOverlay(parentWindow: BrowserWindow): BrowserWindow | null { - if (!this.isTooltipOverlayEnabled) return null - if (parentWindow.isDestroyed()) return null - // Do not create overlay on macOS fullscreen; it hides traffic lights - if (process.platform === 'darwin' && parentWindow.isFullScreen()) return null - - const existing = this.tooltipOverlayWindows.get(parentWindow.id) - if (existing && !existing.isDestroyed()) { - this.syncTooltipOverlayBounds(parentWindow, existing) - return existing - } - - const bounds = parentWindow.getContentBounds() - - const overlay = new BrowserWindow({ - x: bounds.x, - y: bounds.y, - width: bounds.width, - height: bounds.height, - parent: parentWindow, - show: false, - frame: false, - transparent: true, - backgroundColor: '#00000000', - resizable: false, - movable: false, - minimizable: false, - maximizable: false, - closable: false, - hasShadow: false, - focusable: false, - skipTaskbar: true, - autoHideMenuBar: true, - webPreferences: { - preload: join(__dirname, '../preload/index.mjs'), - sandbox: false, - devTools: is.dev - } - }) - - if (process.platform === 'darwin') { - overlay.setHiddenInMissionControl(true) - // Keep overlay off fullscreen spaces to avoid covering macOS traffic lights - overlay.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: false }) - } - overlay.setIgnoreMouseEvents(true, { forward: true }) - - const syncOnMoved = () => { - const current = this.tooltipOverlayWindows.get(parentWindow.id) - if (!current || current.isDestroyed() || parentWindow.isDestroyed()) return - this.syncTooltipOverlayBounds(parentWindow, current) - } - - // Debounce resize to avoid excessive sync during window resize. - let resizeSyncTimer: NodeJS.Timeout | null = null - const syncOnResize = () => { - const current = this.tooltipOverlayWindows.get(parentWindow.id) - if (!current || current.isDestroyed() || parentWindow.isDestroyed()) return - - if (resizeSyncTimer) { - clearTimeout(resizeSyncTimer) - } - - resizeSyncTimer = setTimeout(() => { - this.syncTooltipOverlayBounds(parentWindow, current) - resizeSyncTimer = null - }, 100) - } - - parentWindow.on('moved', syncOnMoved) - parentWindow.on('resize', syncOnResize) - parentWindow.on('hide', () => { - if (!overlay.isDestroyed()) overlay.hide() - }) - parentWindow.on('minimize', () => { - if (!overlay.isDestroyed()) overlay.hide() - }) - - overlay.on('closed', () => { - this.tooltipOverlayWindows.delete(parentWindow.id) - this.pendingTooltipPayload.delete(parentWindow.id) - }) - - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - overlay.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/shell/tooltip-overlay/index.html') - } else { - overlay.loadFile(join(__dirname, '../renderer/shell/tooltip-overlay/index.html')) - } - - overlay.webContents.once('did-finish-load', () => { - if (overlay.isDestroyed()) return - overlay.webContents.send('shell-tooltip-overlay:clear') - - const pending = this.pendingTooltipPayload.get(parentWindow.id) - if (pending) { - if (!overlay.isVisible()) { - overlay.showInactive() - } - overlay.webContents.send('shell-tooltip-overlay:show', pending) - } - }) - - this.tooltipOverlayWindows.set(parentWindow.id, overlay) - return overlay - } - - private syncTooltipOverlayBounds(parentWindow: BrowserWindow, overlay: BrowserWindow): void { - if (parentWindow.isDestroyed() || overlay.isDestroyed()) return - const bounds = parentWindow.getContentBounds() - overlay.setBounds(bounds) - } - - private clearTooltipOverlay(windowId: number): void { - const overlay = this.tooltipOverlayWindows.get(windowId) - if (!overlay || overlay.isDestroyed()) return - this.pendingTooltipPayload.delete(windowId) - overlay.webContents.send('shell-tooltip-overlay:hide') - if (overlay.isVisible()) { - overlay.hide() - } - } - - private destroyTooltipOverlay(windowId: number): void { - const overlay = this.tooltipOverlayWindows.get(windowId) - if (overlay && !overlay.isDestroyed()) { - overlay.destroy() - } - this.tooltipOverlayWindows.delete(windowId) - this.pendingTooltipPayload.delete(windowId) - } - /** * 更新指定窗口的内容保护设置。 * @param window BrowserWindow 实例。 diff --git a/src/main/utils/index.ts b/src/main/utils/index.ts index a0f451908..775aa50f6 100644 --- a/src/main/utils/index.ts +++ b/src/main/utils/index.ts @@ -3,10 +3,8 @@ import { presenter } from '@/presenter' export function handleShowHiddenWindow(mustShow: boolean) { const allWindows = presenter.windowPresenter.getAllWindows() if (allWindows.length === 0) { - presenter.windowPresenter.createShellWindow({ - initialTab: { - url: 'local://chat' - } + presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' }) } else { // 查找目标窗口 (焦点窗口或第一个窗口) @@ -23,10 +21,8 @@ export function handleShowHiddenWindow(mustShow: boolean) { } else { console.warn('Target window for SHOW_HIDDEN_WINDOW event is destroyed.') // 保持 warn // 如果目标窗口已销毁,创建新窗口 - presenter.windowPresenter.createShellWindow({ - initialTab: { - url: 'local://chat' - } + presenter.windowPresenter.createAppWindow({ + initialRoute: 'chat' }) } } diff --git a/src/renderer/shell/App.vue b/src/renderer/browser/App.vue similarity index 74% rename from src/renderer/shell/App.vue rename to src/renderer/browser/App.vue index 8d42bfaa9..510ce1a9c 100644 --- a/src/renderer/shell/App.vue +++ b/src/renderer/browser/App.vue @@ -19,34 +19,26 @@ import AppBar from './components/AppBar.vue' import BrowserToolbar from './components/BrowserToolbar.vue' import BrowserPlaceholder from './components/BrowserPlaceholder.vue' import { useDeviceVersion } from '@/composables/useDeviceVersion' -import { useTabStore } from '@shell/stores/tab' import { useElementSize } from '@vueuse/core' import { useFontManager } from '@/composables/useFontManager' +import { useBrowserWindowStore } from './stores/window' const { setupFontListener } = useFontManager() setupFontListener() // Detect platform to apply proper styling const { isWinMacOS } = useDeviceVersion() -const tabStore = useTabStore() +const browserWindowStore = useBrowserWindowStore() const windowId = ref(null) const appBarRef = ref | null>(null) const toolbarRef = ref | null>(null) -const activeTab = computed(() => tabStore.tabs.find((tab) => tab.id === tabStore.currentTabId)) -const isWebTabActive = computed(() => { - const tab = activeTab.value - if (!tab) return false - return Boolean(!tab.url?.startsWith('local://') && tab.browserTabId) -}) -const isAboutBlank = computed(() => { - const tab = activeTab.value - return tab?.url === 'about:blank' -}) -const shouldShowToolbar = computed(() => isWebTabActive.value) -const shouldShowPlaceholder = computed(() => isWebTabActive.value && isAboutBlank.value) -const webContentBackgroundClass = computed(() => (isWebTabActive.value ? 'bg-white' : '')) +const shouldShowToolbar = computed(() => Boolean(browserWindowStore.browserWindow)) +const shouldShowPlaceholder = computed(() => browserWindowStore.isAboutBlank) +const webContentBackgroundClass = computed(() => + browserWindowStore.browserWindow ? 'bg-white' : '' +) // Chrome height reporting — needed for browser windows (TabPresenter manages view bounds) const appBarSize = useElementSize(computed(() => appBarRef.value?.$el ?? null)) @@ -60,7 +52,7 @@ const chromeHeight = computed(() => { const sendChromeHeight = (height: number) => { if (windowId.value == null) return - window.electron.ipcRenderer.send('shell:chrome-height', { height }) + window.electron.ipcRenderer.send('browser:chrome-height', { height }) } watch( @@ -73,6 +65,7 @@ watch( onMounted(async () => { windowId.value = window.api.getWindowId?.() ?? null + await browserWindowStore.init() await nextTick() sendChromeHeight(chromeHeight.value) }) diff --git a/src/renderer/shell/assets/ChromeClose.svg b/src/renderer/browser/assets/ChromeClose.svg similarity index 100% rename from src/renderer/shell/assets/ChromeClose.svg rename to src/renderer/browser/assets/ChromeClose.svg diff --git a/src/renderer/shell/assets/ChromeMaximize-1.svg b/src/renderer/browser/assets/ChromeMaximize-1.svg similarity index 100% rename from src/renderer/shell/assets/ChromeMaximize-1.svg rename to src/renderer/browser/assets/ChromeMaximize-1.svg diff --git a/src/renderer/shell/assets/ChromeMaximize.svg b/src/renderer/browser/assets/ChromeMaximize.svg similarity index 100% rename from src/renderer/shell/assets/ChromeMaximize.svg rename to src/renderer/browser/assets/ChromeMaximize.svg diff --git a/src/renderer/shell/assets/ChromeMinimize.svg b/src/renderer/browser/assets/ChromeMinimize.svg similarity index 100% rename from src/renderer/shell/assets/ChromeMinimize.svg rename to src/renderer/browser/assets/ChromeMinimize.svg diff --git a/src/renderer/browser/components/AppBar.vue b/src/renderer/browser/components/AppBar.vue new file mode 100644 index 000000000..1693b753b --- /dev/null +++ b/src/renderer/browser/components/AppBar.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/renderer/shell/components/BrowserPlaceholder.vue b/src/renderer/browser/components/BrowserPlaceholder.vue similarity index 100% rename from src/renderer/shell/components/BrowserPlaceholder.vue rename to src/renderer/browser/components/BrowserPlaceholder.vue diff --git a/src/renderer/shell/components/BrowserToolbar.vue b/src/renderer/browser/components/BrowserToolbar.vue similarity index 67% rename from src/renderer/shell/components/BrowserToolbar.vue rename to src/renderer/browser/components/BrowserToolbar.vue index b3e72b196..583279f6d 100644 --- a/src/renderer/shell/components/BrowserToolbar.vue +++ b/src/renderer/browser/components/BrowserToolbar.vue @@ -1,5 +1,5 @@