diff --git a/README.jp.md b/README.jp.md index 4f95a6c07..54a994c5d 100644 --- a/README.jp.md +++ b/README.jp.md @@ -325,7 +325,6 @@ deepchatへの貢献をご検討いただきありがとうございます!貢 - [Vue](https://vuejs.org/) - [Electron](https://www.electronjs.org/) - [Electron-Vite](https://electron-vite.org/) -- [Rolldown-Vite](https://github.com/vitejs/rolldown-vite) - [oxlint](https://github.com/oxc-project/oxc) ## 📃 ライセンス diff --git a/README.md b/README.md index ebb5fa17a..c82bbbe1d 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,6 @@ This project is built with the help of these awesome libraries: - [Vue](https://vuejs.org/) - [Electron](https://www.electronjs.org/) - [Electron-Vite](https://electron-vite.org/) -- [Rolldown-Vite](https://github.com/vitejs/rolldown-vite) - [oxlint](https://github.com/oxc-project/oxc) ## 📃 License diff --git a/README.zh.md b/README.zh.md index b80bf7731..59cd8f09e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -325,7 +325,6 @@ DeepChat是一个活跃的开源社区项目,我们欢迎各种形式的贡献 - [Vue](https://vuejs.org/) - [Electron](https://www.electronjs.org/) - [Electron-Vite](https://electron-vite.org/) -- [Rolldown-Vite](https://github.com/vitejs/rolldown-vite) - [oxlint](https://github.com/oxc-project/oxc) ## 📃 许可证 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f86a75b4f..686c0accc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -23,7 +23,11 @@ export default defineConfig({ }, build: { rollupOptions: { - external: ['sharp'] + external: ['sharp','@duckdb/node-api'], + output: { + inlineDynamicImports: true, + manualChunks: undefined, // Disable automatic chunk splitting + } } } }, diff --git a/package.json b/package.json index dfc5d4f99..78ed24227 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "0.2.9", + "version": "0.3.0", "description": "DeepChat,一个简单易用的AI客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", @@ -42,13 +42,13 @@ "build:linux:x64": "pnpm run build && electron-builder --linux --x64", "build:linux:arm64": "pnpm run build && electron-builder --linux --arm64", "afterSign": "scripts/notarize.js", - "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun", - "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32", - "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32", - "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a arm64 -p darwin && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun -a arm64 -p darwin", - "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a x64 -p darwin && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun -a x64 -p darwin", - "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a x64 -p linux && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun -a x64 -p linux", - "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv -a arm64 -p linux && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun -a arm64 -p linux", + "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun --runtime-version v1.2.20", + "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32", + "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32", + "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p darwin && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a arm64 -p darwin", + "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p darwin && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a x64 -p darwin", + "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p linux && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a x64 -p linux", + "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p linux && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a arm64 -p linux", "installRuntime:duckdb:vss": "node scripts/installVss.js", "i18n": "i18n-check -s zh-CN -f i18next --locales src/renderer/src/i18n", "i18n:en": "i18n-check -s en-US -f i18next --locales src/renderer/src/i18n", @@ -61,9 +61,9 @@ "@e2b/code-interpreter": "^1.5.1", "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", - "@google/genai": "^1.5.1", + "@google/genai": "^1.13.0", "@jxa/run": "^1.4.0", - "@modelcontextprotocol/sdk": "^1.13.1", + "@modelcontextprotocol/sdk": "^1.17.2", "axios": "^1.7.9", "better-sqlite3-multiple-ciphers": "11.10.0", "cheerio": "^1.0.0", @@ -78,12 +78,12 @@ "file-type": "^20.5.0", "glob": "^11.0.3", "https-proxy-agent": "^7.0.6", - "jsonrepair": "^3.12.0", + "jsonrepair": "^3.13.0", "mammoth": "^1.9.0", "mime-types": "^2.1.35", "nanoid": "^5.1.5", "ollama": "^0.5.16", - "openai": "^5.3.0", + "openai": "^5.12.2", "pdf-parse-new": "^1.3.9", "run-applescript": "^7.0.0", "sharp": "^0.33.5", @@ -121,18 +121,18 @@ "@types/node": "^22.14.1", "@types/xlsx": "^0.0.35", "@vitejs/plugin-vue": "^6.0.1", - "@vitest/ui": "^3.2.3", + "@vitest/ui": "^3.2.4", "@vue/test-utils": "^2.4.6", "@vueuse/core": "^12.7.0", - "autoprefixer": "^10.4.20", + "autoprefixer": "^10.4.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dompurify": "^3.2.4", - "electron": "^35.5.1", + "dompurify": "^3.2.6", + "electron": "^36.7.4", "electron-builder": "26.0.12", "electron-vite": "^4.0.0", "jsdom": "^26.1.0", - "lint-staged": "^16.1.2", + "lint-staged": "^16.1.5", "lucide-vue-next": "^0.511.0", "mermaid": "^11.6.0", "minimatch": "^10.0.1", @@ -148,21 +148,21 @@ "tailwindcss-animate": "^1.0.7", "tippy.js": "^6.3.7", "typescript": "^5.8.3", - "vite": "npm:rolldown-vite@latest", + "vite": "7.1.1", "vite-plugin-monaco-editor-esm": "^2.0.2", "vite-plugin-vue-devtools": "^8.0.0", "vite-svg-loader": "^5.1.0", - "vitest": "^3.2.3", - "vue": "^3.5.14", - "vue-i18n": "^11.1.3", - "vue-renderer-markdown": "^0.0.32", + "vue-renderer-markdown": "^0.0.34", + "vitest": "^3.2.4", + "vue": "^3.5.18", + "vue-i18n": "^11.1.11", "vue-router": "4", "vue-tsc": "^2.2.10", - "vue-use-monaco": "^0.0.6", + "vue-use-monaco": "^0.0.8", "vue-virtual-scroller": "^2.0.0-beta.8", "vuedraggable": "^4.1.0", "yaml": "^2.8.0", - "zod-to-json-schema": "^3.24.5" + "zod-to-json-schema": "^3.24.6" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged && pnpm typecheck", @@ -177,9 +177,6 @@ ] }, "pnpm": { - "overrides": { - "vite": "npm:rolldown-vite@latest" - }, "onlyBuiltDependencies": [ "@tailwindcss/oxide", "electron", diff --git a/src/main/events.ts b/src/main/events.ts index dae18ecaa..996df620e 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -177,6 +177,7 @@ export const MEETING_EVENTS = { // 悬浮按钮相关事件 export const FLOATING_BUTTON_EVENTS = { CLICKED: 'floating-button:clicked', // 悬浮按钮被点击 + RIGHT_CLICKED: 'floating-button:right-clicked', // 悬浮按钮被右键点击 VISIBILITY_CHANGED: 'floating-button:visibility-changed', // 悬浮按钮显示状态改变 POSITION_CHANGED: 'floating-button:position-changed', // 悬浮按钮位置改变 ENABLED_CHANGED: 'floating-button:enabled-changed' // 悬浮按钮启用状态改变 diff --git a/src/main/index.ts b/src/main/index.ts index b1eecdefc..2e7b21a46 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -156,17 +156,18 @@ app.whenReady().then(async () => { // 注册 'deepcdn' 协议,用于加载应用内置资源 (模拟 CDN) protocol.handle('deepcdn', (request) => { try { + // console.log('deepcdn', request.url) const filePath = request.url.slice('deepcdn://'.length) - // 根据开发/生产环境确定资源路径 - const resourcesPath = is.dev - ? path.join(app.getAppPath(), 'resources') - : process.resourcesPath - // 检查资源是否被解包 (app.asar.unpacked),优先使用解包路径 - const unpackedResourcesPath = path.join(resourcesPath, 'app.asar.unpacked', 'resources') - - const baseResourcesDir = fs.existsSync(unpackedResourcesPath) - ? unpackedResourcesPath - : path.join(resourcesPath, 'resources') // 否则使用默认资源路径 + // 根据开发/生产环境确定资源路径(按候选目录探测,避免错误拼接导致重复 resources) + const candidates = is.dev + ? [path.join(app.getAppPath(), 'resources')] + : [ + path.join(process.resourcesPath, 'app.asar.unpacked', 'resources'), + path.join(process.resourcesPath, 'resources'), + process.resourcesPath + ] + const baseResourcesDir = + candidates.find((p) => fs.existsSync(path.join(p, 'cdn'))) || candidates[0] const fullPath = path.join(baseResourcesDir, 'cdn', filePath) diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 6dada2d34..291769dff 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -331,7 +331,7 @@ export class ConfigPresenter implements IConfigPresenter { // Cache miss: read from settings and cache the result const status = this.getSetting(statusKey) - const finalStatus = typeof status === 'boolean' ? status : true + const finalStatus = typeof status === 'boolean' ? status : false this.modelStatusCache.set(statusKey, finalStatus) return finalStatus @@ -359,7 +359,7 @@ export class ConfigPresenter implements IConfigPresenter { const modelId = uncachedModelIds[i] const statusKey = uncachedKeys[i] const status = this.getSetting(statusKey) - const finalStatus = typeof status === 'boolean' ? status : true + const finalStatus = typeof status === 'boolean' ? status : false // Cache the result and add to return object this.modelStatusCache.set(statusKey, finalStatus) @@ -1166,6 +1166,34 @@ export class ConfigPresenter implements IConfigPresenter { newConfigs ) } + + // 批量导入MCP服务器 + async batchImportMcpServers( + servers: Array<{ + name: string + description: string + package: string + version?: string + type?: any + args?: string[] + env?: Record + enabled?: boolean + source?: string + [key: string]: unknown + }>, + options: { + skipExisting?: boolean + enableByDefault?: boolean + overwriteExisting?: boolean + } = {} + ): Promise<{ imported: number; skipped: number; errors: string[] }> { + return this.mcpConfHelper.batchImportMcpServers(servers, options) + } + + // 根据包名查找服务器 + async findMcpServerByPackage(packageName: string): Promise { + return this.mcpConfHelper.findServerByPackage(packageName) + } } export { defaultShortcutKey } from './shortcutKeySettings' diff --git a/src/main/presenter/configPresenter/mcpConfHelper.ts b/src/main/presenter/configPresenter/mcpConfHelper.ts index 593eb211a..06399b9ae 100644 --- a/src/main/presenter/configPresenter/mcpConfHelper.ts +++ b/src/main/presenter/configPresenter/mcpConfHelper.ts @@ -26,6 +26,23 @@ interface IMcpSettings { } export type MCPServerType = 'stdio' | 'sse' | 'inmemory' | 'http' +// Extended MCP server config with additional properties for ModelScope sync +export interface ExtendedMCPServerConfig { + name: string + description: string + args: string[] + env: Record + enabled: boolean + type: MCPServerType + package?: string + version?: string + source?: string + logo_url?: string + publisher?: string + tags?: string[] + view_count?: number +} + // 检查当前系统平台 function isMacOS(): boolean { return process.platform === 'darwin' @@ -624,6 +641,158 @@ export class McpConfHelper { }) } + /** + * Batch import MCP servers from external source (like ModelScope) + * @param servers - Array of MCP server configs to import + * @param options - Import options + * @returns Promise<{ imported: number; skipped: number; errors: string[] }> + */ + async batchImportMcpServers( + servers: Array<{ + name: string + description: string + package: string + version?: string + type?: MCPServerType + args?: string[] + env?: Record + enabled?: boolean + source?: string + [key: string]: unknown + }>, + options: { + skipExisting?: boolean + enableByDefault?: boolean + overwriteExisting?: boolean + } = {} + ): Promise<{ imported: number; skipped: number; errors: string[] }> { + const { skipExisting = true, enableByDefault = false, overwriteExisting = false } = options + const result = { + imported: 0, + skipped: 0, + errors: [] as string[] + } + + const existingServers = await this.getMcpServers() + + for (const serverConfig of servers) { + try { + // Generate unique server name based on package name + const serverName = this.generateUniqueServerName(serverConfig.package, existingServers) + const existingServer = existingServers[serverName] + + // Check if server already exists + if (existingServer && !overwriteExisting) { + if (skipExisting) { + console.log(`Skipping existing MCP server: ${serverName}`) + result.skipped++ + continue + } else { + result.errors.push(`Server ${serverName} already exists`) + continue + } + } + + // Create MCP server config + const mcpConfig: ExtendedMCPServerConfig = { + name: serverConfig.name, + description: serverConfig.description, + args: serverConfig.args || [], + env: serverConfig.env || {}, + enabled: serverConfig.enabled ?? enableByDefault, + type: (serverConfig.type as MCPServerType) || 'stdio', + package: serverConfig.package, + version: serverConfig.version || 'latest', + source: serverConfig.source as string | undefined, + logo_url: serverConfig.logo_url as string | undefined, + publisher: serverConfig.publisher as string | undefined, + tags: serverConfig.tags as string[] | undefined, + view_count: serverConfig.view_count as number | undefined + } + + // Add or update the server + const success = await this.addMcpServer(serverName, mcpConfig as unknown as MCPServerConfig) + if (success || overwriteExisting) { + if (existingServer && overwriteExisting) { + await this.updateMcpServer(serverName, mcpConfig as unknown as Partial) + console.log(`Updated MCP server: ${serverName}`) + } else { + console.log(`Imported MCP server: ${serverName}`) + } + result.imported++ + } else { + result.errors.push(`Failed to import server: ${serverName}`) + } + } catch (error) { + const errorMsg = `Error importing server ${serverConfig.name}: ${error instanceof Error ? error.message : String(error)}` + console.error(errorMsg) + result.errors.push(errorMsg) + } + } + + console.log( + `MCP batch import completed. Imported: ${result.imported}, Skipped: ${result.skipped}, Errors: ${result.errors.length}` + ) + + // Emit event to notify about the import + eventBus.sendToRenderer(MCP_EVENTS.CONFIG_CHANGED, SendTarget.ALL_WINDOWS, { + action: 'batch_import', + result + }) + + return result + } + + /** + * Generate a unique server name based on package name + * @param packageName - The package name to base the server name on + * @param existingServers - Existing servers to check against + * @returns Unique server name + */ + private generateUniqueServerName( + packageName: string, + existingServers: Record + ): string { + // Clean up package name to create a suitable server name + let baseName = packageName + .replace(/[@/]/g, '-') + .replace(/[^a-zA-Z0-9-_]/g, '') + .toLowerCase() + + // If the base name doesn't exist, use it directly + if (!existingServers[baseName]) { + return baseName + } + + // If it exists, append a number suffix + let counter = 1 + let uniqueName = `${baseName}-${counter}` + while (existingServers[uniqueName]) { + counter++ + uniqueName = `${baseName}-${counter}` + } + + return uniqueName + } + + /** + * Check if a server with given package already exists + * @param packageName - Package name to check + * @returns Promise - Returns server name if exists, null otherwise + */ + async findServerByPackage(packageName: string): Promise { + const servers = await this.getMcpServers() + + for (const [serverName, config] of Object.entries(servers)) { + const extendedConfig = config as unknown as ExtendedMCPServerConfig + if (extendedConfig.package === packageName) { + return serverName + } + } + + return null + } + public onUpgrade(oldVersion: string | undefined): void { console.log('onUpgrade', oldVersion) if (oldVersion && compare(oldVersion, '0.0.12', '<=')) { diff --git a/src/main/presenter/configPresenter/modelConfig.ts b/src/main/presenter/configPresenter/modelConfig.ts index e80915b67..6a70117f5 100644 --- a/src/main/presenter/configPresenter/modelConfig.ts +++ b/src/main/presenter/configPresenter/modelConfig.ts @@ -142,7 +142,10 @@ export class ModelConfigHelper { functionCall: config.functionCall || false, reasoning: config.reasoning || false, type: config.type || ModelType.Chat, - thinkingBudget: config.thinkingBudget + thinkingBudget: config.thinkingBudget, + reasoningEffort: config.reasoningEffort, + verbosity: config.verbosity, + maxCompletionTokens: config.maxCompletionTokens } break } @@ -160,7 +163,10 @@ export class ModelConfigHelper { functionCall: false, reasoning: false, type: ModelType.Chat, - thinkingBudget: undefined + thinkingBudget: undefined, + reasoningEffort: undefined, + verbosity: undefined, + maxCompletionTokens: undefined } } } diff --git a/src/main/presenter/configPresenter/modelDefaultSettings.ts b/src/main/presenter/configPresenter/modelDefaultSettings.ts index 055eafdfb..6b0c19b11 100644 --- a/src/main/presenter/configPresenter/modelDefaultSettings.ts +++ b/src/main/presenter/configPresenter/modelDefaultSettings.ts @@ -671,24 +671,26 @@ export const defaultModelsSettings: DefaultModelSetting[] = [ { id: 'gpt-oss-120b', name: 'GPT OSS 120B', - temperature: 0.7, - maxTokens: 131000, - contextLength: 131000, + temperature: 0.6, + maxTokens: 32000, + contextLength: 128000, match: ['gpt-oss-120b'], vision: false, functionCall: true, - reasoning: false + reasoning: true, + reasoningEffort: 'medium' }, { id: 'gpt-oss-20b', name: 'GPT OSS 20B', - temperature: 0.7, - maxTokens: 33000, - contextLength: 131000, + temperature: 0.6, + maxTokens: 16000, + contextLength: 128000, match: ['gpt-oss-20b'], vision: false, functionCall: true, - reasoning: false + reasoning: true, + reasoningEffort: 'medium' }, { id: 'o4-mini-high', @@ -865,6 +867,58 @@ export const defaultModelsSettings: DefaultModelSetting[] = [ functionCall: false, reasoning: true }, + { + id: 'gpt-5-chat', + name: 'GPT-5 Chat', + maxTokens: 16384, + contextLength: 272000, + match: ['gpt-5-chat', 'gpt-5-chat-latest'], + vision: true, + functionCall: false, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 16384 + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5-mini', 'gpt-5-mini-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + }, + { + id: 'gpt-5-nano', + name: 'GPT-5 Nano', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5-nano', 'gpt-5-nano-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + }, + { + id: 'gpt-5', + name: 'GPT-5', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5', 'gpt-5-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + }, { id: 'gpt-4.5-preview', name: 'GPT-4.5 Preview', diff --git a/src/main/presenter/configPresenter/providerModelSettings.ts b/src/main/presenter/configPresenter/providerModelSettings.ts index 47d2ea171..b0d82dc53 100644 --- a/src/main/presenter/configPresenter/providerModelSettings.ts +++ b/src/main/presenter/configPresenter/providerModelSettings.ts @@ -13,13 +13,70 @@ export interface ProviderModelSetting { functionCall?: boolean // 是否支持函数调用 reasoning?: boolean // 是否支持推理能力 type?: ModelType // 模型类型,默认为Chat + // GPT-5 系列新参数 + reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' + verbosity?: 'low' | 'medium' | 'high' + maxCompletionTokens?: number // GPT-5 系列使用此参数替代 maxTokens } // 为每个提供商创建映射对象,使用models数组包装模型配置 export const providerModelSettings: Record = { // OpenAI提供商特定模型配置 openai: { - models: [] + models: [ + { + id: 'gpt-5-chat', + name: 'GPT-5 Chat', + maxTokens: 16384, + contextLength: 272000, + match: ['gpt-5-chat', 'gpt-5-chat-latest'], + vision: true, + functionCall: false, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 16384 + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5-mini', 'gpt-5-mini-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + }, + { + id: 'gpt-5-nano', + name: 'GPT-5 Nano', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5-nano', 'gpt-5-nano-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + }, + { + id: 'gpt-5', + name: 'GPT-5', + maxTokens: 128000, + contextLength: 272000, + match: ['gpt-5', 'gpt-5-2025-08-07'], + vision: true, + functionCall: true, + reasoning: true, + reasoningEffort: 'medium', + verbosity: 'medium', + maxCompletionTokens: 128000 + } + ] }, // 火山引擎(Doubao)提供商特定模型配置 @@ -320,24 +377,26 @@ export const providerModelSettings: Record { ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.CLICKED) + ipcMain.removeAllListeners(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) - ipcMain.on(FLOATING_BUTTON_EVENTS.CLICKED, () => { + ipcMain.on(FLOATING_BUTTON_EVENTS.CLICKED, async () => { try { - // 触发内置事件处理器 - handleShowHiddenWindow(true) - } catch {} + let floatingButtonPosition: { x: number; y: number; width: number; height: number } | null = + null + if (this.floatingWindow && this.floatingWindow.exists()) { + const buttonWindow = this.floatingWindow.getWindow() + if (buttonWindow && !buttonWindow.isDestroyed()) { + const bounds = buttonWindow.getBounds() + floatingButtonPosition = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + } + } + if (floatingButtonPosition) { + await presenter.windowPresenter.toggleFloatingChatWindow(floatingButtonPosition) + } else { + await presenter.windowPresenter.toggleFloatingChatWindow() + } + } catch (error) { + console.error('Failed to handle floating button click:', error) + } + }) + + ipcMain.on(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED, () => { + try { + this.showContextMenu() + } catch (error) { + console.error('Failed to handle floating button right click:', error) + } }) if (!this.floatingWindow) { @@ -122,5 +151,86 @@ export class FloatingButtonPresenter { // 悬浮按钮创建后立即显示 this.floatingWindow.show() + + this.preCreateFloatingChatWindow() + } + + private preCreateFloatingChatWindow(): void { + try { + presenter.windowPresenter.createFloatingChatWindow().catch((error) => { + console.error('Failed to pre-create floating chat window:', error) + }) + console.log('Started pre-creating floating chat window in background') + } catch (error) { + console.error('Error starting pre-creation of floating chat window:', error) + } + } + + private showContextMenu(): void { + const template = [ + { + label: '打开主窗口', + click: () => { + this.openMainWindow() + } + }, + { + type: 'separator' as const + }, + { + label: '退出应用', + click: () => { + this.exitApplication() + } + } + ] + + const contextMenu = Menu.buildFromTemplate(template) + + if (this.floatingWindow && this.floatingWindow.exists()) { + const buttonWindow = this.floatingWindow.getWindow() + if (buttonWindow && !buttonWindow.isDestroyed()) { + contextMenu.popup({ window: buttonWindow }) + return + } + } + + const mainWindow = presenter.windowPresenter.mainWindow + if (mainWindow) { + contextMenu.popup({ window: mainWindow }) + } else { + contextMenu.popup() + } + } + + private openMainWindow(): void { + try { + const windowPresenter = presenter.windowPresenter + if (windowPresenter) { + const mainWindow = windowPresenter.mainWindow + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + console.log('Main window opened from floating button context menu') + } else { + windowPresenter.createShellWindow({ initialTab: { url: 'local://chat' } }) + console.log('Created new main window from floating button context menu') + } + } + } catch (error) { + console.error('Failed to open main window from floating button:', error) + } + } + + private exitApplication(): void { + try { + console.log('Exiting application from floating button context menu') + app.quit() + } catch (error) { + console.error('Failed to exit application from floating button:', error) + } } } diff --git a/src/main/presenter/llmProviderPresenter/baseProvider.ts b/src/main/presenter/llmProviderPresenter/baseProvider.ts index d7ccac177..5cb4befc0 100644 --- a/src/main/presenter/llmProviderPresenter/baseProvider.ts +++ b/src/main/presenter/llmProviderPresenter/baseProvider.ts @@ -130,13 +130,11 @@ export abstract class BaseLLMProvider { this.configPresenter.getModelStatus(providerId, model.id) ) - // 如果没有任何已启用的模型,则自动启用所有模型 - // 这部分后续应该改为启用推荐模型 + // 不再自动启用模型,让用户手动选择启用需要的模型 if (!hasEnabledModels) { - console.info(`Auto enabling all models for provider: ${this.provider.name}`) - this.models.forEach((model) => { - this.configPresenter.enableModel(providerId, model.id) - }) + console.info( + `Provider ${this.provider.name} models loaded, please manually enable the models you need` + ) } } diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index 0cc2a5b46..33b76b575 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -8,7 +8,9 @@ import { ChatMessage, LLMAgentEvent, KeyStatus, - LLM_EMBEDDING_ATTRS + LLM_EMBEDDING_ATTRS, + ModelScopeMcpSyncOptions, + ModelScopeMcpSyncResult } from '@shared/presenter' import { BaseLLMProvider } from './baseProvider' import { OpenAIProvider } from './providers/openAIProvider' @@ -38,6 +40,7 @@ import { OpenRouterProvider } from './providers/openRouterProvider' import { MinimaxProvider } from './providers/minimaxProvider' import { AihubmixProvider } from './providers/aihubmixProvider' import { _302AIProvider } from './providers/_302AIProvider' +import { ModelscopeProvider } from './providers/modelscopeProvider' // 速率限制配置接口 interface RateLimitConfig { @@ -167,6 +170,9 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { if (provider.id === 'aihubmix') { return new AihubmixProvider(provider, this.configPresenter) } + if (provider.id === 'modelscope') { + return new ModelscopeProvider(provider, this.configPresenter) + } switch (provider.apiType) { case 'minimax': return new OpenAIProvider(provider, this.configPresenter) @@ -1599,4 +1605,139 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { } } } + + /** + * Sync MCP servers from ModelScope and import them to local configuration + * @param providerId - Provider ID (should be 'modelscope') + * @param syncOptions - Simplified sync options + * @returns Promise with sync result statistics + */ + async syncModelScopeMcpServers( + providerId: string, + syncOptions?: ModelScopeMcpSyncOptions + ): Promise { + console.log(`[ModelScope MCP Sync] Starting sync for provider: ${providerId}`) + console.log(`[ModelScope MCP Sync] Sync options:`, syncOptions) + + if (providerId !== 'modelscope') { + const error = 'MCP sync is only supported for ModelScope provider' + console.error(`[ModelScope MCP Sync] Error: ${error}`) + throw new Error(error) + } + + const provider = this.getProviderInstance(providerId) + + // Type check for ModelscopeProvider + if (provider.constructor.name !== 'ModelscopeProvider') { + const error = 'Provider is not a ModelScope provider instance' + console.error(`[ModelScope MCP Sync] Error: ${error}`) + throw new Error(error) + } + + const result: ModelScopeMcpSyncResult = { + imported: 0, + skipped: 0, + errors: [] + } + + try { + // Create async task to prevent blocking main thread + const syncTask = async () => { + console.log(`[ModelScope MCP Sync] Fetching MCP servers from ModelScope API...`) + + // Call ModelscopeProvider to fetch MCP servers + const modelscopeProvider = provider as any + const mcpResponse = await modelscopeProvider.syncMcpServers(syncOptions) + + if (!mcpResponse || !mcpResponse.success || !mcpResponse.data?.mcp_server_list) { + const errorMsg = 'Invalid response from ModelScope MCP API' + console.error(`[ModelScope MCP Sync] ${errorMsg}`, mcpResponse) + result.errors.push(errorMsg) + return result + } + + const mcpServers = mcpResponse.data.mcp_server_list + console.log(`[ModelScope MCP Sync] Fetched ${mcpServers.length} MCP servers from API`) + + // Convert ModelScope operational MCP servers to internal format + const convertedServers = mcpServers + .map((server: any) => { + try { + // Check if operational URLs are available + if (!server.operational_urls || server.operational_urls.length === 0) { + const errorMsg = `No operational URLs found for server ${server.id}` + console.warn(`[ModelScope MCP Sync] ${errorMsg}`) + result.errors.push(errorMsg) + return null + } + + // Use ModelScope provider's conversion method for consistency + const modelscopeProvider = provider as any + const converted = modelscopeProvider.convertMcpServerToConfig(server) + + console.log( + `[ModelScope MCP Sync] Converted operational server: ${converted.displayName} (${converted.name})` + ) + return converted + } catch (conversionError) { + const errorMsg = `Failed to convert server ${server.name || server.id}: ${conversionError instanceof Error ? conversionError.message : String(conversionError)}` + console.error(`[ModelScope MCP Sync] ${errorMsg}`) + result.errors.push(errorMsg) + return null + } + }) + .filter((server: any) => server !== null) + + console.log( + `[ModelScope MCP Sync] Successfully converted ${convertedServers.length} servers` + ) + + // Import servers to configuration using configPresenter + for (const serverConfig of convertedServers) { + try { + const existingServers = await this.configPresenter.getMcpServers() + + // Check if server already exists + if (existingServers[serverConfig.name]) { + console.log( + `[ModelScope MCP Sync] Server ${serverConfig.name} already exists, skipping` + ) + result.skipped++ + continue + } + + // Add server to configuration + const success = await this.configPresenter.addMcpServer(serverConfig.name, serverConfig) + if (success) { + console.log( + `[ModelScope MCP Sync] Successfully imported server: ${serverConfig.name}` + ) + result.imported++ + } else { + const errorMsg = `Failed to add server ${serverConfig.name} to configuration` + console.error(`[ModelScope MCP Sync] ${errorMsg}`) + result.errors.push(errorMsg) + } + } catch (importError) { + const errorMsg = `Failed to import server ${serverConfig.name}: ${importError instanceof Error ? importError.message : String(importError)}` + console.error(`[ModelScope MCP Sync] ${errorMsg}`) + result.errors.push(errorMsg) + } + } + + console.log( + `[ModelScope MCP Sync] Sync completed. Imported: ${result.imported}, Skipped: ${result.skipped}, Errors: ${result.errors.length}` + ) + return result + } + + // Execute async without blocking + return await syncTask() + } catch (error) { + const errorMsg = `ModelScope MCP sync failed: ${error instanceof Error ? error.message : String(error)}` + console.error(`[ModelScope MCP Sync] ${errorMsg}`) + result.errors.push(errorMsg) + return result + } + } } diff --git a/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts b/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts index da77f773f..8b78c2274 100644 --- a/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/anthropicProvider.ts @@ -168,6 +168,18 @@ export class AnthropicProvider extends BaseLLMProvider { // 默认的模型列表(如API调用失败或数据格式不正确) return [ + { + id: 'claude-opus-4-1-20250805', + name: 'Claude Opus 4.1', + providerId: this.provider.id, + maxTokens: 32_000, + group: 'Claude 4.1', + isCustom: false, + contextLength: 200000, + vision: true, + functionCall: true, + reasoning: true + }, { id: 'claude-opus-4-20250514', name: 'Claude Opus 4', diff --git a/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts b/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts index 3613a6f69..a01420db7 100644 --- a/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/geminiProvider.ts @@ -333,77 +333,14 @@ export class GeminiProvider extends BaseLLMProvider { this.configPresenter.getModelStatus(providerId, model.id) ) - // 如果没有任何已启用的模型,则自动启用推荐的模型 + // 不再自动启用模型,让用户手动选择启用需要的模型 if (!hasEnabledModels) { - // 提取推荐模型ID列表 - const recommendedModelIds = GeminiProvider.GEMINI_MODELS.map((model) => model.id) - - // 过滤出匹配推荐列表的模型 - const modelsToEnable = this.models.filter((model) => { - return this.isModelRecommended(model.id, recommendedModelIds) - }) - - if (modelsToEnable.length > 0) { - console.info( - `Auto enabling ${modelsToEnable.length} recommended models for provider: ${this.provider.name}` - ) - modelsToEnable.forEach((model) => { - console.info(`Enabling recommended model: ${model.id}`) - this.configPresenter.enableModel(providerId, model.id) - }) - } else { - console.warn(`No recommended models found for provider: ${this.provider.name}`) - } + console.info( + `Provider ${this.provider.name} models loaded, please manually enable the models you need` + ) } } - /** - * 检查模型ID是否与推荐模型列表匹配(模糊匹配) - * @param modelId 要检查的模型ID - * @param recommendedIds 推荐模型ID列表 - * @returns 是否匹配 - */ - private isModelRecommended(modelId: string, recommendedIds: string[]): boolean { - // 标准化模型ID,移除 models/ 前缀进行比较 - const normalizeId = (id: string) => id.replace(/^models\//, '') - const normalizedModelId = normalizeId(modelId) - - return recommendedIds.some((recommendedId) => { - const normalizedRecommendedId = normalizeId(recommendedId) - - // 精确匹配 - if (normalizedModelId === normalizedRecommendedId) { - return true - } - - // 模糊匹配:检查是否包含核心模型名称 - // 例如 "gemini-2.5-pro" 匹配 "gemini-2.5-pro-experimental" - if ( - normalizedModelId.includes(normalizedRecommendedId) || - normalizedRecommendedId.includes(normalizedModelId) - ) { - return true - } - - // 版本匹配:检查基础模型名称是否相同 - // 例如 "gemini-2.5-flash" 匹配 "gemini-2.5-flash-8b" - const getBaseModelName = (id: string) => { - // 移除版本号、实验标识等后缀 - return id - .replace(/-\d+$/, '') // 移除末尾数字 - .replace(/-latest$/, '') // 移除 -latest - .replace(/-exp.*$/, '') // 移除实验版本标识 - .replace(/-preview.*$/, '') // 移除预览版本标识 - .replace(/-\d{3,}$/, '') // 移除长数字版本号 - } - - const baseModelId = getBaseModelName(normalizedModelId) - const baseRecommendedId = getBaseModelName(normalizedRecommendedId) - - return baseModelId === baseRecommendedId - }) - } - // Helper function to get and format safety settings private async getFormattedSafetySettings(): Promise { const safetySettings: SafetySetting[] = [] diff --git a/src/main/presenter/llmProviderPresenter/providers/modelscopeProvider.ts b/src/main/presenter/llmProviderPresenter/providers/modelscopeProvider.ts new file mode 100644 index 000000000..eca4e601f --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/providers/modelscopeProvider.ts @@ -0,0 +1,327 @@ +import { LLM_PROVIDER, LLMResponse, ChatMessage, KeyStatus } from '@shared/presenter' +import { OpenAICompatibleProvider } from './openAICompatibleProvider' +import { ConfigPresenter } from '../../configPresenter' + +// Define interface for ModelScope MCP API response +interface ModelScopeMcpServerResponse { + code: number + data: { + mcp_server_list: ModelScopeMcpServer[] + total_count: number + } + message: string + request_id: string + success: boolean +} + +// Define interface for ModelScope MCP server (updated for operational API) +interface ModelScopeMcpServer { + name: string + description: string + id: string + chinese_name?: string // Chinese name field + logo_url: string + operational_urls: Array<{ + id: string + url: string + }> + tags: string[] + locales: { + zh: { + name: string + description: string + } + en: { + name: string + description: string + } + } +} + +export class ModelscopeProvider extends OpenAICompatibleProvider { + constructor(provider: LLM_PROVIDER, configPresenter: ConfigPresenter) { + super(provider, configPresenter) + } + + async completions( + messages: ChatMessage[], + modelId: string, + temperature?: number, + maxTokens?: number + ): Promise { + return this.openAICompletion(messages, modelId, temperature, maxTokens) + } + + async summaries( + text: string, + modelId: string, + temperature?: number, + maxTokens?: number + ): Promise { + return this.openAICompletion( + [ + { + role: 'user', + content: `You need to summarize the user's conversation into a title of no more than 10 words, with the title language matching the user's primary language, without using punctuation or other special symbols:\n${text}` + } + ], + modelId, + temperature, + maxTokens + ) + } + + async generateText( + prompt: string, + modelId: string, + temperature?: number, + maxTokens?: number + ): Promise { + return this.openAICompletion( + [ + { + role: 'user', + content: prompt + } + ], + modelId, + temperature, + maxTokens + ) + } + + /** + * Get current API key status from ModelScope + * @returns Promise API key status information + */ + public async getKeyStatus(): Promise { + if (!this.provider.apiKey) { + throw new Error('API key is required') + } + + try { + // Use models endpoint to check API key validity + const response = await this.openai.models.list({ timeout: 10000 }) + + return { + limit_remaining: 'Available', + remainNum: response.data?.length || 0 + } + } catch (error) { + console.error('ModelScope API key check failed:', error) + throw new Error( + `ModelScope API key check failed: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + /** + * Override check method to use ModelScope's API validation + * @returns Promise<{ isOk: boolean; errorMsg: string | null }> + */ + public async check(): Promise<{ isOk: boolean; errorMsg: string | null }> { + try { + await this.getKeyStatus() + return { isOk: true, errorMsg: null } + } catch (error: unknown) { + let errorMessage = 'An unknown error occurred during ModelScope API key check.' + if (error instanceof Error) { + errorMessage = error.message + } else if (typeof error === 'string') { + errorMessage = error + } + + console.error('ModelScope API key check failed:', error) + return { isOk: false, errorMsg: errorMessage } + } + } + + /** + * Sync operational MCP servers from ModelScope API + * @param _options - Sync options including filters (currently not used by operational API) + * @returns Promise MCP servers response + */ + public async syncMcpServers(_options?: { + page_number?: number + page_size?: number + }): Promise { + if (!this.provider.apiKey) { + throw new Error('API key is required for MCP sync') + } + + try { + // Use the operational API endpoint - GET request, no body needed + const response = await fetch('https://www.modelscope.cn/openapi/v1/mcp/servers/operational', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.provider.apiKey}` + }, + signal: AbortSignal.timeout(30000) // 30 second timeout + }) + + // Handle authentication errors + if (response.status === 401 || response.status === 403) { + throw new Error('ModelScope MCP sync unauthorized: Invalid or expired API key') + } + + // Handle server errors + if (response.status === 500 || !response.ok) { + const errorText = await response.text() + throw new Error( + `ModelScope MCP sync failed: ${response.status} ${response.statusText} - ${errorText}` + ) + } + + const data: ModelScopeMcpServerResponse = await response.json() + + if (!data.success) { + throw new Error(`ModelScope MCP sync failed: ${data.message}`) + } + + console.log( + `Successfully fetched ${data.data.mcp_server_list.length} operational MCP servers from ModelScope` + ) + return data + } catch (error) { + console.error('ModelScope MCP sync error:', error) + throw error + } + } + + /** + * Convert ModelScope operational MCP server to internal MCP server config format + * @param mcpServer - ModelScope MCP server data + * @returns Internal MCP server config + */ + public convertMcpServerToConfig(mcpServer: ModelScopeMcpServer) { + // Check if operational URLs are available + if (!mcpServer.operational_urls || mcpServer.operational_urls.length === 0) { + throw new Error(`No operational URLs found for server ${mcpServer.id}`) + } + + // Use the first operational URL + const baseUrl = mcpServer.operational_urls[0].url + + // Generate random emoji for icon + const emojis = [ + '🔧', + '⚡', + '🚀', + '🔨', + '⚙️', + '🛠️', + '🔥', + '💡', + '⭐', + '🎯', + '🎨', + '🔮', + '💎', + '🎪', + '🎭', + '🎨', + '🔬', + '📱', + '💻', + '🖥️', + '⌨️', + '🖱️', + '📡', + '🔊', + '📢', + '📣', + '📯', + '🔔', + '🔕', + '📻', + '📺', + '📷', + '📹', + '🎥', + '📽️', + '🔍', + '🔎', + '💰', + '💳', + '💸', + '💵', + '🎲', + '🃏', + '🎮', + '🕹️', + '🎯', + '🎳', + '🎨', + '🖌️', + '🖍️', + '📝', + '✏️', + '📏', + '📐', + '📌', + '📍', + '🗂️', + '📂', + '📁', + '📰', + '📄', + '📃', + '📜', + '📋', + '📊', + '📈', + '📉', + '📦', + '📫', + '📪', + '📬', + '📭', + '📮', + '🗳️', + '✉️', + '📧', + '📨', + '📩', + '📤', + '📥', + '📬', + '📭', + '📮', + '🗂️', + '📂', + '📁', + '🗄️', + '🗃️', + '📋', + '📑', + '📄', + '📃', + '📰', + '🗞️', + '📜', + '🔖' + ] + const randomEmoji = emojis[Math.floor(Math.random() * emojis.length)] + + // Get display name: chinese_name first, then name, then id + const displayName = mcpServer.chinese_name || mcpServer.name || mcpServer.id + + return { + command: '', // Not needed for SSE type + args: [], // Not needed for SSE type + env: {}, + descriptions: + mcpServer.locales?.zh?.description || + mcpServer.description || + `ModelScope MCP Server: ${displayName}`, + icons: randomEmoji, // Random emoji instead of URL + autoApprove: ['all'], + disable: false, // Default to disabled for safety + type: 'sse' as const, // SSE type for operational servers + baseUrl: baseUrl, // Use operational URL + source: 'modelscope', + sourceId: mcpServer.id + } + } +} diff --git a/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts b/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts index d5d49115b..2d7cda7e8 100644 --- a/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/ollamaProvider.ts @@ -492,7 +492,8 @@ export class OllamaProvider extends BaseLLMProvider { messages: processedMessages, options: { temperature: temperature || 0.7, - num_predict: maxTokens + num_predict: maxTokens, + ...(modelConfig?.reasoningEffort && { reasoning_effort: modelConfig.reasoningEffort }) }, stream: true as const, ...(supportsFunctionCall && ollamaTools && ollamaTools.length > 0 diff --git a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts index 7f7dfda8a..dadb89264 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts @@ -41,7 +41,11 @@ const OPENAI_REASONING_MODELS = [ 'o1-mini', 'o1-pro', 'o1-preview', - 'o1' + 'o1', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-chat' ] const OPENAI_IMAGE_GENERATION_MODELS = [ 'gpt-4o-all', @@ -210,7 +214,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { model: modelId, stream: false, temperature: temperature, - ...(modelId.startsWith('o1') || modelId.startsWith('o3') || modelId.startsWith('o4') + ...(modelId.startsWith('o1') || + modelId.startsWith('o3') || + modelId.startsWith('o4') || + modelId.startsWith('gpt-5') ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens }) } @@ -528,7 +535,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { model: modelId, stream: true, temperature, - ...(modelId.startsWith('o1') || modelId.startsWith('o3') || modelId.startsWith('o4') + ...(modelId.startsWith('o1') || + modelId.startsWith('o3') || + modelId.startsWith('o4') || + modelId.startsWith('gpt-5') ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens }) } diff --git a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts index 78a4fa6c0..9734f8540 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts @@ -31,7 +31,11 @@ const OPENAI_REASONING_MODELS = [ 'o1-mini', 'o1-pro', 'o1-preview', - 'o1' + 'o1', + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-chat' ] const OPENAI_IMAGE_GENERATION_MODELS = [ 'gpt-4o-all', @@ -224,6 +228,20 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { stream: false } + if (modelId.startsWith('gpt-5')) { + const modelConfig = this.configPresenter.getModelConfig(modelId, this.provider.id) + if (modelConfig.reasoningEffort) { + ;(requestParams as any).reasoning = { + effort: modelConfig.reasoningEffort + } + } + if (modelConfig.verbosity) { + ;(requestParams as any).text = { + verbosity: modelConfig.verbosity + } + } + } + OPENAI_REASONING_MODELS.forEach((noTempId) => { if (modelId.startsWith(noTempId)) { delete requestParams.temperature @@ -554,6 +572,19 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { requestParams.tools = apiTools } + if (modelId.startsWith('gpt-5')) { + if (modelConfig.reasoningEffort) { + ;(requestParams as any).reasoning = { + effort: modelConfig.reasoningEffort + } + } + if (modelConfig.verbosity) { + ;(requestParams as any).text = { + verbosity: modelConfig.verbosity + } + } + } + OPENAI_REASONING_MODELS.forEach((noTempId) => { if (modelId.startsWith(noTempId)) delete requestParams.temperature }) diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts b/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts index 44fc37e95..e500b1cd9 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts @@ -1027,12 +1027,18 @@ export class FileSystemServer { if (!parsed.success) { throw new Error(`Invalid arguments for move_files: ${parsed.error}`) } + const destInfo = await this.getFileStats(parsed.data.destination) const results = await Promise.all( parsed.data.sources.map(async (source) => { const validSourcePath = await this.validatePath(source) - const validDestPath = await this.validatePath( - path.join(parsed.data.destination, path.basename(source)) - ) + let validDestPath = '' + if (destInfo.isFile) { + validDestPath = await this.validatePath(parsed.data.destination) + } else { + validDestPath = await this.validatePath( + path.join(parsed.data.destination, path.basename(source)) + ) + } try { await fs.rename(validSourcePath, validDestPath) return `Successfully moved ${source} to ${parsed.data.destination}` diff --git a/src/main/presenter/mcpPresenter/index.ts b/src/main/presenter/mcpPresenter/index.ts index 2e7981eb8..3d1b7715a 100644 --- a/src/main/presenter/mcpPresenter/index.ts +++ b/src/main/presenter/mcpPresenter/index.ts @@ -12,6 +12,7 @@ import { } from '@shared/presenter' import { ServerManager } from './serverManager' import { ToolManager } from './toolManager' +import { McpRouterManager } from './mcprouterManager' import { eventBus, SendTarget } from '@/eventbus' import { MCP_EVENTS, NOTIFICATION_EVENTS } from '@/events' import { IConfigPresenter } from '@shared/presenter' @@ -83,6 +84,8 @@ export class McpPresenter implements IMCPPresenter { private toolManager: ToolManager private configPresenter: IConfigPresenter private isInitialized: boolean = false + // McpRouter + private mcprouter?: McpRouterManager constructor(configPresenter?: IConfigPresenter) { console.log('Initializing MCP Presenter') @@ -90,6 +93,12 @@ export class McpPresenter implements IMCPPresenter { this.configPresenter = configPresenter || presenter.configPresenter this.serverManager = new ServerManager(this.configPresenter) this.toolManager = new ToolManager(this.configPresenter, this.serverManager) + // init mcprouter manager + try { + this.mcprouter = new McpRouterManager(this.configPresenter) + } catch (e) { + console.warn('[MCP] McpRouterManager init failed:', e) + } // 监听自定义提示词服务器检查事件 eventBus.on(CONFIG_EVENTS.CUSTOM_PROMPTS_SERVER_CHECK_REQUIRED, async () => { @@ -181,6 +190,78 @@ export class McpPresenter implements IMCPPresenter { } } + // =============== McpRouter marketplace APIs =============== + async listMcpRouterServers( + page: number, + limit: number + ): Promise<{ + servers: Array<{ + uuid: string + created_at: string + updated_at: string + name: string + author_name: string + title: string + description: string + content?: string + server_key: string + config_name?: string + server_url?: string + }> + }> { + if (!this.mcprouter) throw new Error('McpRouterManager not available') + const data = await this.mcprouter.listServers(page, limit) + return { servers: data && data.servers ? data.servers : [] } + } + + async installMcpRouterServer(serverKey: string): Promise { + if (!this.mcprouter) throw new Error('McpRouterManager not available') + return this.mcprouter.installServer(serverKey) + } + + async getMcpRouterApiKey(): Promise { + return this.configPresenter.getSetting('mcprouterApiKey') || '' + } + + async setMcpRouterApiKey(key: string): Promise { + this.configPresenter.setSetting('mcprouterApiKey', key) + } + + async isServerInstalled(source: string, sourceId: string): Promise { + const servers = await this.configPresenter.getMcpServers() + for (const config of Object.values(servers)) { + if (config.source === source && config.sourceId === sourceId) { + return true + } + } + return false + } + + async updateMcpRouterServersAuth(apiKey: string): Promise { + const servers = await this.configPresenter.getMcpServers() + const updates: Array<{ name: string; config: Partial }> = [] + + for (const [serverName, config] of Object.entries(servers)) { + if (config.source === 'mcprouter' && config.customHeaders) { + const updatedHeaders = { + ...config.customHeaders, + Authorization: `Bearer ${apiKey}` + } + updates.push({ + name: serverName, + config: { customHeaders: updatedHeaders } + }) + } + } + + // 批量更新所有服务器的 Authorization + for (const update of updates) { + await this.configPresenter.updateMcpServer(update.name, update.config) + } + + console.log(`Updated Authorization for ${updates.length} mcprouter servers`) + } + private scheduleBackgroundRegistryUpdate(): void { setTimeout(async () => { try { diff --git a/src/main/presenter/mcpPresenter/mcprouterManager.ts b/src/main/presenter/mcpPresenter/mcprouterManager.ts new file mode 100644 index 000000000..186682847 --- /dev/null +++ b/src/main/presenter/mcpPresenter/mcprouterManager.ts @@ -0,0 +1,124 @@ +import { IConfigPresenter, MCPServerConfig } from '@shared/presenter' + +type McpRouterListResponse = { + code: number + message: string + data?: { + servers: Array<{ + uuid: string + created_at: string + updated_at: string + name: string + author_name: string + title: string + description: string + content?: string + server_key: string + config_name?: string + server_url?: string + }> + } +} + +type McpRouterGetResponse = { + code: number + message: string + data?: { + created_at: string + updated_at: string + name: string + author_name: string + title: string + description: string + content?: string + server_key: string + config_name: string + server_url: string + } +} + +const LIST_ENDPOINT = 'https://api.mcprouter.to/v1/list-servers' +const GET_ENDPOINT = 'https://api.mcprouter.to/v1/get-server' + +export class McpRouterManager { + constructor(private readonly configPresenter: IConfigPresenter) {} + + private getCommonHeaders(): Record { + return { + 'Content-Type': 'application/json', + 'HTTP-Referer': 'deepchatai.cn', + 'X-Title': 'DeepChat' + } + } + + async listServers(page: number, limit: number): Promise { + const res = await fetch(LIST_ENDPOINT, { + method: 'POST', + headers: this.getCommonHeaders(), + body: JSON.stringify({ page, limit }) + }) + if (!res.ok) throw new Error(`McpRouter list failed: HTTP ${res.status}`) + const json = (await res.json()) as McpRouterListResponse + if (json.code !== 0) throw new Error(json.message || 'List servers error') + return json.data || { servers: [] } + } + + async getServer(serverKey: string): Promise { + const apiKey = this.configPresenter.getSetting('mcprouterApiKey') || '' + if (!apiKey) throw new Error('McpRouter API key missing') + const headers = { + ...this.getCommonHeaders(), + Authorization: `Bearer ${apiKey}` + } + const res = await fetch(GET_ENDPOINT, { + method: 'POST', + headers, + body: JSON.stringify({ server: serverKey }) + }) + if (!res.ok) throw new Error(`McpRouter get failed: HTTP ${res.status}`) + const json = (await res.json()) as McpRouterGetResponse + if (json.code !== 0 || !json.data) throw new Error(json.message || 'Get server error') + return json.data + } + + private pickRandomEmoji(): string { + const emojis = ['🧩', '🛠️', '⚙️', '🚀', '🔧', '🧪', '📦', '🛰️', '🧠', '🔌', '📡', '🗂️'] + const idx = Math.floor(Math.random() * emojis.length) + return emojis[idx] + } + + /** + * Install a server from McpRouter to local MCP config as HTTP (Streamable) server + */ + async installServer(serverKey: string): Promise { + const detail = await this.getServer(serverKey) + if (!detail) throw new Error('Server detail not found') + + const apiKey = this.configPresenter.getSetting('mcprouterApiKey') || '' + if (!apiKey) throw new Error('McpRouter API key missing') + + // Build MCPServerConfig + const config: MCPServerConfig = { + command: '', + args: [], + env: {}, + descriptions: detail.description || detail.title || detail.name, + icons: this.pickRandomEmoji(), + autoApprove: ['all'], + disable: false, + type: 'http', + baseUrl: detail.server_url, + customHeaders: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + 'HTTP-Referer': 'deepchatai.cn', + 'X-Title': 'DeepChat' + }, + source: 'mcprouter', + sourceId: serverKey + } + + const serverName = detail.config_name || detail.server_key || detail.name + return await this.configPresenter.addMcpServer(serverName, config) + } +} diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 3b5d2504a..037b56379 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -10,6 +10,7 @@ import { getContextMenuLabels } from '@shared/i18n' import { app } from 'electron' import { addWatermarkToNativeImage } from '@/lib/watermark' import { stitchImagesVertically } from '@/lib/scrollCapture' +import { presenter } from './' export class TabPresenter implements ITabPresenter { // 全局标签页实例存储 @@ -533,6 +534,12 @@ export class TabPresenter implements ITabPresenter { // Once did-finish-load happens, emit first content loaded webContents.once('did-finish-load', () => { eventBus.sendToMain(WINDOW_EVENTS.FIRST_CONTENT_LOADED, windowId) + setTimeout(() => { + const windowPresenter = presenter.windowPresenter as any + if (windowPresenter && typeof windowPresenter.focusActiveTab === 'function') { + windowPresenter.focusActiveTab(windowId, 'initial') + } + }, 300) }) } @@ -935,4 +942,40 @@ export class TabPresenter implements ITabPresenter { } } } + + registerFloatingWindow(webContentsId: number, webContents: Electron.WebContents): void { + try { + console.log(`TabPresenter: Registering floating window as virtual tab, ID: ${webContentsId}`) + if (this.tabs.has(webContentsId)) { + console.warn(`TabPresenter: Tab ${webContentsId} already exists, skipping registration`) + return + } + const virtualView = { + webContents: webContents, + setVisible: () => {}, + setBounds: () => {}, + getBounds: () => ({ x: 0, y: 0, width: 400, height: 600 }) + } as any + this.webContentsToTabId.set(webContentsId, webContentsId) + this.tabs.set(webContentsId, virtualView) + console.log( + `TabPresenter: Virtual tab registered successfully for floating window ${webContentsId}` + ) + } catch (error) { + console.error('TabPresenter: Failed to register floating window:', error) + } + } + + unregisterFloatingWindow(webContentsId: number): void { + try { + console.log(`TabPresenter: Unregistering floating window virtual tab, ID: ${webContentsId}`) + this.webContentsToTabId.delete(webContentsId) + this.tabs.delete(webContentsId) + console.log( + `TabPresenter: Virtual tab unregistered successfully for floating window ${webContentsId}` + ) + } catch (error) { + console.error('TabPresenter: Failed to unregister floating window:', error) + } + } } diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index f66638f90..d8e377891 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -745,7 +745,7 @@ export class ThreadPresenter implements IThreadPresenter { if (defaultModelsSettings) { mergedSettings.maxTokens = defaultModelsSettings.maxTokens mergedSettings.contextLength = defaultModelsSettings.contextLength - mergedSettings.temperature = defaultModelsSettings.temperature + mergedSettings.temperature = defaultModelsSettings.temperature ?? 0.7 // 重置 thinkingBudget 为模型默认配置,如果模型配置中没有则设为 undefined mergedSettings.thinkingBudget = defaultModelsSettings.thinkingBudget } diff --git a/src/main/presenter/windowPresenter/FloatingChatWindow.ts b/src/main/presenter/windowPresenter/FloatingChatWindow.ts new file mode 100644 index 000000000..b97233600 --- /dev/null +++ b/src/main/presenter/windowPresenter/FloatingChatWindow.ts @@ -0,0 +1,398 @@ +import { BrowserWindow, screen, nativeImage } from 'electron' +import path from 'path' +import logger from '../../../shared/logger' +import { platform, is } from '@electron-toolkit/utils' +import icon from '../../../../resources/icon.png?asset' +import iconWin from '../../../../resources/icon.ico?asset' +import { eventBus } from '../../eventbus' +import { TAB_EVENTS } from '../../events' +import { presenter } from '../' + +interface FloatingChatConfig { + size: { + width: number + height: number + } + minSize: { + width: number + height: number + } + opacity: number + alwaysOnTop: boolean +} + +interface FloatingButtonPosition { + x: number + y: number + width: number + height: number +} + +const DEFAULT_FLOATING_CHAT_CONFIG: FloatingChatConfig = { + size: { + width: 400, + height: 600 + }, + minSize: { + width: 350, + height: 450 + }, + opacity: 0.95, + alwaysOnTop: true +} + +export class FloatingChatWindow { + private window: BrowserWindow | null = null + private config: FloatingChatConfig + private isVisible: boolean = false + private shouldShowWhenReady: boolean = false + + constructor(config?: Partial) { + this.config = { + ...DEFAULT_FLOATING_CHAT_CONFIG, + ...config + } + } + + public async create(floatingButtonPosition?: FloatingButtonPosition): Promise { + if (this.window) { + return + } + + try { + const position = this.calculatePosition(floatingButtonPosition) + const iconFile = nativeImage.createFromPath(process.platform === 'win32' ? iconWin : icon) + const isDev = is.dev + + this.window = new BrowserWindow({ + width: this.config.size.width, + height: this.config.size.height, + minWidth: this.config.minSize.width, + minHeight: this.config.minSize.height, + x: position.x, + y: position.y, + frame: false, + transparent: true, + alwaysOnTop: this.config.alwaysOnTop, + skipTaskbar: true, + resizable: true, + minimizable: false, + maximizable: false, + closable: true, + show: false, + movable: true, + autoHideMenuBar: true, + icon: iconFile, + vibrancy: platform.isMacOS ? 'under-window' : undefined, + visualEffectState: platform.isMacOS ? 'followWindow' : undefined, + backgroundMaterial: platform.isWindows ? 'mica' : undefined, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: path.join(__dirname, '../preload/index.mjs'), + webSecurity: false, + devTools: isDev, + sandbox: false + } + }) + + this.window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + this.window.setAlwaysOnTop(true, 'floating') + this.window.setOpacity(this.config.opacity) + this.setupWindowEvents() + this.registerVirtualTab() + + logger.info('FloatingChatWindow created successfully') + + this.loadPageContent() + .then(() => logger.info('FloatingChatWindow page content loaded')) + .catch((error) => logger.error('Failed to load FloatingChatWindow page content:', error)) + } catch (error) { + logger.error('Failed to create FloatingChatWindow:', error) + throw error + } + } + + public show(floatingButtonPosition?: FloatingButtonPosition): void { + if (!this.window) { + return + } + + if (floatingButtonPosition) { + const position = this.calculatePosition(floatingButtonPosition) + this.window.setPosition(position.x, position.y) + } + if (!this.window.isVisible()) { + if (this.window.webContents.isLoading() === false) { + this.window.show() + this.window.focus() + this.refreshWindowData() + } else { + this.window.show() + this.window.focus() + this.shouldShowWhenReady = true + this.window.webContents.once('did-finish-load', () => { + if (this.shouldShowWhenReady) { + this.refreshWindowData() + this.shouldShowWhenReady = false + } + }) + } + } else { + this.window.show() + this.window.focus() + this.refreshWindowData() + } + this.isVisible = true + logger.debug('FloatingChatWindow shown') + } + + public hide(): void { + if (!this.window) { + return + } + + this.window.hide() + this.isVisible = false + logger.debug('FloatingChatWindow hidden') + } + + public toggle(floatingButtonPosition?: FloatingButtonPosition): void { + if (this.isVisible) { + this.hide() + } else { + this.show(floatingButtonPosition) + } + } + + public destroy(): void { + if (this.window) { + this.unregisterVirtualTab() + try { + if (!this.window.isDestroyed()) { + this.window.destroy() + } + } catch (error) { + logger.error('Error destroying FloatingChatWindow:', error) + } + this.window = null + this.isVisible = false + logger.debug('FloatingChatWindow destroyed') + } + } + + public isShowing(): boolean { + return this.window !== null && !this.window.isDestroyed() && this.isVisible + } + + public getWindow(): BrowserWindow | null { + return this.window + } + + private refreshWindowData(): void { + if (this.window && !this.window.isDestroyed()) { + logger.debug('Refreshing floating window data') + setTimeout(() => { + if (this.window && !this.window.isDestroyed()) { + eventBus.sendToMain(TAB_EVENTS.RENDERER_TAB_READY, this.window.webContents.id) + } + }, 100) + } + } + + private registerVirtualTab(): void { + if (!this.window || this.window.isDestroyed()) { + return + } + + try { + const tabPresenter = presenter.tabPresenter + if (tabPresenter) { + const webContentsId = this.window.webContents.id + logger.info(`Registering virtual tab for floating window, WebContents ID: ${webContentsId}`) + tabPresenter.registerFloatingWindow(webContentsId, this.window.webContents) + } + } catch (error) { + logger.error('Failed to register virtual tab for floating window:', error) + } + } + + private unregisterVirtualTab(): void { + if (!this.window) { + return + } + + try { + const tabPresenter = presenter.tabPresenter + if (tabPresenter) { + const webContentsId = this.window.webContents.id + logger.info( + `Unregistering virtual tab for floating window, WebContents ID: ${webContentsId}` + ) + tabPresenter.unregisterFloatingWindow(webContentsId) + } + } catch (error) { + logger.error('Failed to unregister virtual tab for floating window:', error) + } + } + private calculatePosition(floatingButtonPosition?: FloatingButtonPosition): { + x: number + y: number + } { + const primaryDisplay = screen.getPrimaryDisplay() + const { workArea } = primaryDisplay + let x: number, y: number + + if (!floatingButtonPosition) { + x = workArea.x + workArea.width - this.config.size.width - 20 + y = workArea.y + workArea.height - this.config.size.height - 20 + return { x, y } + } + + const buttonX = floatingButtonPosition.x + const buttonY = floatingButtonPosition.y + const buttonWidth = floatingButtonPosition.width + const buttonHeight = floatingButtonPosition.height + const windowWidth = this.config.size.width + const windowHeight = this.config.size.height + const gap = 15 + const buttonCenterX = buttonX + buttonWidth / 2 + const buttonCenterY = buttonY + buttonHeight / 2 + const screenCenterX = workArea.x + workArea.width / 2 + const screenCenterY = workArea.y + workArea.height / 2 + + let positions: Array<{ x: number; y: number; priority: number }> = [] + if (buttonX + buttonWidth + gap + windowWidth <= workArea.x + workArea.width) { + positions.push({ + x: buttonX + buttonWidth + gap, + y: Math.max( + workArea.y, + Math.min( + buttonY + (buttonHeight - windowHeight) / 2, + workArea.y + workArea.height - windowHeight + ) + ), + priority: buttonCenterX < screenCenterX ? 1 : 3 + }) + } + + if (buttonX - gap - windowWidth >= workArea.x) { + positions.push({ + x: buttonX - gap - windowWidth, + y: Math.max( + workArea.y, + Math.min( + buttonY + (buttonHeight - windowHeight) / 2, + workArea.y + workArea.height - windowHeight + ) + ), + priority: buttonCenterX >= screenCenterX ? 1 : 3 + }) + } + + if (buttonY + buttonHeight + gap + windowHeight <= workArea.y + workArea.height) { + positions.push({ + x: Math.max( + workArea.x, + Math.min( + buttonX + (buttonWidth - windowWidth) / 2, + workArea.x + workArea.width - windowWidth + ) + ), + y: buttonY + buttonHeight + gap, + priority: buttonCenterY < screenCenterY ? 2 : 4 + }) + } + + if (buttonY - gap - windowHeight >= workArea.y) { + positions.push({ + x: Math.max( + workArea.x, + Math.min( + buttonX + (buttonWidth - windowWidth) / 2, + workArea.x + workArea.width - windowWidth + ) + ), + y: buttonY - gap - windowHeight, + priority: buttonCenterY >= screenCenterY ? 2 : 4 + }) + } + + if (positions.length === 0) { + x = workArea.x + workArea.width - windowWidth - 20 + y = workArea.y + workArea.height - windowHeight - 20 + } else { + positions.sort((a, b) => a.priority - b.priority) + x = positions[0].x + y = positions[0].y + } + x = Math.max(workArea.x + 10, Math.min(x, workArea.x + workArea.width - windowWidth - 10)) + y = Math.max(workArea.y + 10, Math.min(y, workArea.y + workArea.height - windowHeight - 10)) + return { x, y } + } + + private async loadPageContent(): Promise { + if (!this.window || this.window.isDestroyed()) { + throw new Error('Window is not available for page loading') + } + + const isDev = is.dev + if (isDev) { + await this.window.loadURL('http://localhost:5173/') + } else { + await this.window.loadFile(path.join(__dirname, '../renderer/index.html')) + } + + this.window.webContents.once('did-finish-load', () => { + logger.info('FloatingChatWindow did-finish-load, requesting fresh data') + setTimeout(async () => { + if (this.window && !this.window.isDestroyed()) { + logger.info(`Broadcasting thread list update for floating window`) + eventBus.sendToMain(TAB_EVENTS.RENDERER_TAB_READY, this.window.webContents.id) + } + }, 300) + }) + } + + private setupWindowEvents(): void { + if (!this.window) { + return + } + + this.window.on('ready-to-show', () => { + if (this.window && !this.window.isDestroyed()) { + if (this.shouldShowWhenReady) { + this.window.show() + this.window.focus() + this.shouldShowWhenReady = false + this.refreshWindowData() + } + } + }) + + this.window.on('close', (event) => { + const windowPresenter = presenter.windowPresenter + const isAppQuitting = windowPresenter?.isApplicationQuitting() || false + if (isAppQuitting) { + logger.info('App is quitting, allowing FloatingChatWindow to close normally') + return + } + event.preventDefault() + this.hide() + logger.debug('FloatingChatWindow close prevented, window hidden instead') + }) + + this.window.on('closed', () => { + this.window = null + this.isVisible = false + }) + + this.window.on('show', () => { + this.isVisible = true + }) + + this.window.on('hide', () => { + this.isVisible = false + }) + } +} diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index a041f2f26..89c3c8812 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -13,6 +13,7 @@ import windowStateManager from 'electron-window-state' // 窗口状态管理器 import { SHORTCUT_EVENTS } from '@/events' // 快捷键事件常量 // TrayPresenter 在 main/index.ts 中全局管理,本 Presenter 不负责其生命周期 import { TabPresenter } from '../tabPresenter' // TabPresenter 类型 +import { FloatingChatWindow } from './FloatingChatWindow' // 悬浮对话窗口 /** * 窗口 Presenter,负责管理所有 BrowserWindow 实例及其生命周期。 @@ -26,6 +27,19 @@ export class WindowPresenter implements IWindowPresenter { private isQuitting: boolean = false // 当前获得焦点的窗口 ID (内部记录) private focusedWindowId: number | null = null + // 主窗口 id + private mainWindowId: number | null = null + // 窗口聚焦状态管理 + private windowFocusStates = new Map< + number, + { + lastFocusTime: number + shouldFocus: boolean + isNewWindow: boolean + hasInitialFocus: boolean + } + >() + private floatingChatWindow: FloatingChatWindow | null = null constructor(configPresenter: ConfigPresenter) { this.windows = new Map() @@ -41,10 +55,22 @@ export class WindowPresenter implements IWindowPresenter { event.returnValue = event.sender.id }) + ipcMain.on('close-floating-window', (event) => { + // 检查发送者是否是悬浮聊天窗口 + const webContentsId = event.sender.id + if ( + this.floatingChatWindow && + this.floatingChatWindow.getWindow()?.webContents.id === webContentsId + ) { + this.hideFloatingChatWindow() + } + }) + // 监听应用即将退出的事件,设置退出标志,避免窗口关闭时触发隐藏逻辑 app.on('before-quit', () => { console.log('App is quitting, setting isQuitting flag.') this.isQuitting = true + this.destroyFloatingChatWindow() }) // 监听快捷键事件:创建新窗口 @@ -159,16 +185,26 @@ export class WindowPresenter implements IWindowPresenter { * @param filePath 文件路径。 */ previewFile(filePath: string): void { - const window = this.mainWindow - if (window) { + let targetWindow = this.getFocusedWindow() + if (!targetWindow && this.floatingChatWindow && this.floatingChatWindow.isShowing()) { + const floatingWindow = this.floatingChatWindow.getWindow() + if (floatingWindow) { + targetWindow = floatingWindow + } + } + if (!targetWindow) { + targetWindow = this.mainWindow + } + + if (targetWindow && !targetWindow.isDestroyed()) { console.log(`Previewing file: ${filePath}`) if (process.platform === 'darwin') { - window.previewFile(filePath) + targetWindow.previewFile(filePath) } else { shell.openPath(filePath) // 使用系统默认应用打开 } } else { - console.warn('Cannot preview file, no valid main window found.') + console.warn('Cannot preview file, no valid window found.') } } @@ -310,7 +346,6 @@ export class WindowPresenter implements IWindowPresenter { this.handleWindowRestore(targetWindow.id).catch((error) => { console.error(`Error handling restore logic after showing window ${targetWindow!.id}:`, error) }) - this.focusActiveTab(targetWindow.id) } /** @@ -375,19 +410,69 @@ export class WindowPresenter implements IWindowPresenter { return focusedWindow ? focusedWindow.id === windowId : false } + /** + * 检查是否应该聚焦标签页 + * @param windowId 窗口 ID + * @param reason 聚焦原因 + */ + private shouldFocusTab( + windowId: number, + reason: 'focus' | 'restore' | 'show' | 'initial' + ): boolean { + const state = this.windowFocusStates.get(windowId) + if (!state) { + return true + } + const now = Date.now() + if (now - state.lastFocusTime < 100) { + console.log(`Skipping focus for window ${windowId}, too frequent (${reason})`) + return false + } + switch (reason) { + case 'initial': + return !state.hasInitialFocus + case 'focus': + return state.shouldFocus + case 'restore': + case 'show': + return state.isNewWindow || state.shouldFocus + default: + return false + } + } + /** * 将焦点传递给指定窗口的活动标签页 * @param windowId 窗口 ID + * @param reason 聚焦原因 */ - private focusActiveTab(windowId: number): void { + public focusActiveTab( + windowId: number, + reason: 'focus' | 'restore' | 'show' | 'initial' = 'focus' + ): void { + if (!this.shouldFocusTab(windowId, reason)) { + return + } try { setTimeout(async () => { const tabPresenterInstance = presenter.tabPresenter as TabPresenter const tabsData = await tabPresenterInstance.getWindowTabsData(windowId) const activeTab = tabsData.find((tab) => tab.isActive) if (activeTab) { - console.log(`Focusing active tab ${activeTab.id} in window ${windowId}`) + console.log( + `Focusing active tab ${activeTab.id} in window ${windowId} (reason: ${reason})` + ) await tabPresenterInstance.switchTab(activeTab.id) + const state = this.windowFocusStates.get(windowId) + if (state) { + state.lastFocusTime = Date.now() + if (reason === 'initial') { + state.hasInitialFocus = true + } + if (reason === 'focus' || reason === 'initial') { + state.isNewWindow = false + } + } } }, 50) } catch (error) { @@ -426,6 +511,17 @@ export class WindowPresenter implements IWindowPresenter { console.warn(`Skipping sending message "${channel}" to destroyed window ${window.id}.`) } } + + if (this.floatingChatWindow && this.floatingChatWindow.isShowing()) { + const floatingWindow = this.floatingChatWindow.getWindow() + if (floatingWindow && !floatingWindow.isDestroyed()) { + try { + floatingWindow.webContents.send(channel, ...args) + } catch (error) { + console.error(`Error sending message "${channel}" to floating chat window:`, error) + } + } + } } /** @@ -531,6 +627,13 @@ export class WindowPresenter implements IWindowPresenter { const windowId = shellWindow.id this.windows.set(windowId, shellWindow) // 将窗口实例存入 Map + this.windowFocusStates.set(windowId, { + lastFocusTime: 0, + shouldFocus: true, + isNewWindow: true, + hasInitialFocus: false + }) + shellWindowState.manage(shellWindow) // 管理窗口状态 // 应用内容保护设置 @@ -558,7 +661,7 @@ export class WindowPresenter implements IWindowPresenter { if (!shellWindow.isDestroyed()) { shellWindow.webContents.send('window-focused', windowId) } - this.focusActiveTab(windowId) + this.focusActiveTab(windowId, 'focus') }) // 窗口失去焦点 @@ -606,7 +709,7 @@ export class WindowPresenter implements IWindowPresenter { this.handleWindowRestore(windowId).catch((error) => { console.error(`Error handling restore logic for window ${windowId}:`, error) }) - this.focusActiveTab(windowId) + this.focusActiveTab(windowId, 'restore') eventBus.sendToMain(WINDOW_EVENTS.WINDOW_RESTORED, windowId) } shellWindow.on('restore', handleRestore) @@ -656,18 +759,11 @@ export class WindowPresenter implements IWindowPresenter { // 如果应用不是正在退出过程中... if (!this.isQuitting) { // 实现隐藏到托盘逻辑: - // 在非 macOS 平台,或在 macOS 上且未配置关闭时退出 (或还有其他窗口),阻止默认关闭行为,仅隐藏窗口。 - const isLastWindow = this.windows.size === 1 - // 检查 macOS 配置:关闭时是否退出应用 - const shouldQuitOnClose = - process.platform === 'darwin' ? this.configPresenter.getCloseToQuit() : false - - // 是否应该阻止默认关闭并隐藏: - // - 非 macOS 平台总是阻止 (实现隐藏到托盘)。 - // - macOS 平台:如果不是最后一个窗口,或虽然是最后一个窗口但配置为不退出时,阻止。 - const shouldPreventDefault = - process.platform !== 'darwin' || - (process.platform === 'darwin' && (!isLastWindow || !shouldQuitOnClose)) + // 1. 如果是其他窗口,直接关闭 + // 2. 如果是主窗口,判断配置是否允许关闭 + // shouldPreventDefault: true隐藏, false关闭 + const shouldQuitOnClose = this.configPresenter.getCloseToQuit() + const shouldPreventDefault = windowId === this.mainWindowId && !shouldQuitOnClose if (shouldPreventDefault) { console.log(`Window ${windowId}: Preventing default close behavior, hiding instead.`) @@ -694,7 +790,6 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.hide() } } else { - // 如果是 macOS,且是最后一个窗口,且配置为关闭时退出,或者 isQuitting 为 true // 允许默认关闭行为。这将触发 'closed' 事件。 console.log( `Window ${windowId}: Allowing default close behavior (app is quitting or macOS last window configured to quit).` @@ -717,6 +812,7 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.removeListener('restore', handleRestore) this.windows.delete(windowIdBeingClosed) // 从 Map 中移除 + this.windowFocusStates.delete(windowIdBeingClosed) shellWindowState.unmanage() // 停止管理窗口状态 eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CLOSED, windowIdBeingClosed) console.log( @@ -813,6 +909,10 @@ export class WindowPresenter implements IWindowPresenter { } console.log(`Shell window ${windowId} created successfully.`) + + if (this.mainWindowId == null) { + this.mainWindowId = windowId // 如果这是第一个窗口,设置为主窗口 ID + } return windowId // 返回新创建窗口的 ID } @@ -1013,4 +1113,80 @@ export class WindowPresenter implements IWindowPresenter { return false // 过程中发生错误 } } + + public async createFloatingChatWindow(): Promise { + if (this.floatingChatWindow) { + console.log('FloatingChatWindow already exists') + return + } + + try { + this.floatingChatWindow = new FloatingChatWindow() + await this.floatingChatWindow.create() + console.log('FloatingChatWindow created successfully') + } catch (error) { + console.error('Failed to create FloatingChatWindow:', error) + this.floatingChatWindow = null + throw error + } + } + + public async showFloatingChatWindow(floatingButtonPosition?: { + x: number + y: number + width: number + height: number + }): Promise { + if (!this.floatingChatWindow) { + await this.createFloatingChatWindow() + } + + if (this.floatingChatWindow) { + this.floatingChatWindow.show(floatingButtonPosition) + console.log('FloatingChatWindow shown') + } + } + + public hideFloatingChatWindow(): void { + if (this.floatingChatWindow) { + this.floatingChatWindow.hide() + console.log('FloatingChatWindow hidden') + } + } + + public async toggleFloatingChatWindow(floatingButtonPosition?: { + x: number + y: number + width: number + height: number + }): Promise { + if (!this.floatingChatWindow) { + await this.createFloatingChatWindow() + } + + if (this.floatingChatWindow) { + this.floatingChatWindow.toggle(floatingButtonPosition) + console.log('FloatingChatWindow toggled') + } + } + + public destroyFloatingChatWindow(): void { + if (this.floatingChatWindow) { + this.floatingChatWindow.destroy() + this.floatingChatWindow = null + console.log('FloatingChatWindow destroyed') + } + } + + public isFloatingChatWindowVisible(): boolean { + return this.floatingChatWindow?.isShowing() || false + } + + public getFloatingChatWindow(): FloatingChatWindow | null { + return this.floatingChatWindow + } + + public isApplicationQuitting(): boolean { + return this.isQuitting + } } diff --git a/src/preload/floating-preload.ts b/src/preload/floating-preload.ts index 4639912b8..2839f9a9b 100644 --- a/src/preload/floating-preload.ts +++ b/src/preload/floating-preload.ts @@ -2,7 +2,8 @@ import { contextBridge, ipcRenderer } from 'electron' // 直接定义事件常量,避免路径解析问题 const FLOATING_BUTTON_EVENTS = { - CLICKED: 'floating-button:clicked' + CLICKED: 'floating-button:clicked', + RIGHT_CLICKED: 'floating-button:right-clicked' } as const // 定义悬浮按钮的 API @@ -16,6 +17,14 @@ const floatingButtonAPI = { } }, + onRightClick: () => { + try { + ipcRenderer.send(FLOATING_BUTTON_EVENTS.RIGHT_CLICKED) + } catch (error) { + console.error('FloatingPreload: Error sending right click IPC message:', error) + } + }, + // 监听来自主进程的事件 onConfigUpdate: (callback: (config: any) => void) => { ipcRenderer.on('floating-button-config-update', (_event, config) => { diff --git a/src/renderer/floating/FloatingButton.vue b/src/renderer/floating/FloatingButton.vue index 0806e973c..db4b0f9f7 100644 --- a/src/renderer/floating/FloatingButton.vue +++ b/src/renderer/floating/FloatingButton.vue @@ -7,6 +7,7 @@ class="w-15 h-15 rounded-full border-2 border-white/30 flex items-center justify-center cursor-pointer transition-all duration-300 relative overflow-hidden select-none floating-button no-drag" :class="{ 'floating-button-pulse': isPulsing }" @click="handleClick" + @contextmenu="handleRightClick" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" > @@ -48,6 +49,28 @@ const handleClick = () => { } } +const handleRightClick = (event: MouseEvent) => { + event.preventDefault() + if (floatingButton.value) { + floatingButton.value.style.transform = 'scale(0.9)' + setTimeout(() => { + if (floatingButton.value) { + floatingButton.value.style.transform = '' + } + }, 150) + } + + if (window.floatingButtonAPI) { + try { + window.floatingButtonAPI.onRightClick() + } catch (error) { + console.error('=== FloatingButton: Error calling onRightClick API ===:', error) + } + } else { + console.error('=== FloatingButton: floatingButtonAPI not available ===') + } +} + // 鼠标事件处理 const handleMouseEnter = () => { isPulsing.value = false diff --git a/src/renderer/floating/env.d.ts b/src/renderer/floating/env.d.ts index 6f624ddac..58ffc5d98 100644 --- a/src/renderer/floating/env.d.ts +++ b/src/renderer/floating/env.d.ts @@ -11,6 +11,7 @@ declare global { interface Window { floatingButtonAPI: { onClick: () => void + onRightClick: () => void onConfigUpdate: (callback: (config: any) => void) => void removeAllListeners: () => void } diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 9bc2b2d67..dd0532ff7 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -169,6 +169,13 @@ const handleGoSettings = () => { } } +// 处理ESC键 - 关闭悬浮聊天窗口 +const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + window.electron.ipcRenderer.send('close-floating-window') + } +} + getInitComplete() onMounted(() => { @@ -179,6 +186,8 @@ onMounted(() => { document.body.classList.add(themeStore.themeMode) document.body.classList.add(settingsStore.fontSizeClass) + window.addEventListener('keydown', handleEscKey) + // 监听全局错误通知事件 window.electron.ipcRenderer.on(NOTIFICATION_EVENTS.SHOW_ERROR, (_event, error) => { showErrorToast(error) @@ -287,6 +296,8 @@ onBeforeUnmount(() => { errorDisplayTimer.value = null } + window.removeEventListener('keydown', handleEscKey) + // 移除快捷键事件监听 window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.ZOOM_IN) window.electron.ipcRenderer.removeAllListeners(SHORTCUT_EVENTS.ZOOM_OUT) diff --git a/src/renderer/src/components/ChatConfig.vue b/src/renderer/src/components/ChatConfig.vue index 254e00fa8..aae03d02b 100644 --- a/src/renderer/src/components/ChatConfig.vue +++ b/src/renderer/src/components/ChatConfig.vue @@ -9,6 +9,13 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { useLanguageStore } from '@/stores/language' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' // Define props to receive config from parent const props = defineProps<{ @@ -21,6 +28,8 @@ const props = defineProps<{ thinkingBudget?: number modelId?: string providerId?: string + reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' + verbosity?: 'low' | 'medium' | 'high' }>() const systemPrompt = defineModel('systemPrompt') @@ -30,6 +39,8 @@ const emit = defineEmits<{ 'update:contextLength': [value: number] 'update:maxTokens': [value: number] 'update:thinkingBudget': [value: number | undefined] + 'update:reasoningEffort': [value: 'minimal' | 'low' | 'medium' | 'high'] + 'update:verbosity': [value: 'low' | 'medium' | 'high'] // 'update:artifacts': [value: 0 | 1] }>() @@ -73,6 +84,16 @@ const showThinkingBudget = computed(() => { return isGemini && isGemini25 }) +const isGPT5Model = computed(() => { + const modelId = props.modelId?.toLowerCase() || '' + return modelId.startsWith('gpt-5') +}) + +// 判断模型是否支持 reasoningEffort 参数 +const supportsReasoningEffort = computed(() => { + return props.reasoningEffort !== undefined +}) + // 当前显示的思考预算值 const displayThinkingBudget = computed({ get: () => { @@ -126,8 +147,8 @@ const handleDynamicThinkingToggle = (enabled: boolean) => { /> - -
+ +
@@ -271,6 +292,94 @@ const handleDynamicThinkingToggle = (enabled: boolean) => {
+ +
+
+ + + + + + + + +

{{ t('settings.model.modelConfig.reasoningEffort.description') }}

+
+
+
+
+ +
+ + +
+
+ + + + + + + + +

{{ t('settings.model.modelConfig.verbosity.description') }}

+
+
+
+
+ +
+ -
+ +